変動型報酬システムを支えるアルゴリズムの導入について

こんにちは。
デザインシステム室の濱口です。

直近の業務で重み付き乱択アルゴリズムを導入しました。
導入するにあたり知らないことが多く、学ぶことがあったので今回記事にしました。

重み付き乱択アルゴリズムとは?

重み付き乱択アルゴリズムとは、各要素に「重み(Weight)」と呼ばれる数値を設定し、その重みの大きさに比例した確率で要素をランダムに選ぶアルゴリズムです。

プログラムの世界では、単純にランダムな数(乱数)を生成すると、すべての選択肢が同じ確率で選ばれてしまいます。そこで、抽選対象のオブジェクトに「出やすさの数値(重み)」を持たせ、合計値の中での割合に応じて当選を決める仕組みが必要になります。これが重み付き乱択アルゴリズムです。

累積和について

このアルゴリズムを調べてる中で、累積和というテクニックが重要になっているのでこちらも紹介したいと思います。累積和(cumulative sum)とは、配列の「先頭からの合計」をあらかじめ計算しておくことで、後続の処理にて好きな区間の合計を一瞬で求められるようにするテクニックのこと。

例えば、ある1週間の日ごとの勤務時間(データ)と、その日までの合計勤務時間(累積和)の表です。

日数 (1日目 (月)2日目 (火)3日目 (水)4日目 (木)5日目 (金)6日目 (土)7日目 (日)
勤務時間4時間5時間0時間3時間4時間8時間6時間
合計時間(累積和)49912162430

この表を使って、「4日目(木)から6日目(土)までの3日間に、合計で何時間働いたか」を計算してみます。

1. 普通に計算する場合

4日目、5日目、6日目の時間をそれぞれ確認して足し算します。

3 + 4 + 8 = 15時間となります。

今回は3日間だけなので簡単ですが、これが「12番目から250番目までの合計」のように範囲が広くなると、何十回も足し算を繰り返さなければならず大変になります。

2. 累積和を使って計算する場合

累積和の表があれば、範囲がどれだけ広くても「知りたいエリアの右端の数字」から「不要なエリアの右端の数字」を引き算するだけで答えが出ます。

1〜6日目までの合計時間:24時間(必要な範囲の右端)
1〜3日目までの合計時間:9時間(いらない範囲の右端)

(1日~6日間の合計) – (1日~3日間の合計) = 24 – 9 = 15時間

このように、「6日目までの累計(24)」から「4日目の直前、つまり3日目までの累計(9)」を一回引き算するだけで、間の3日間の合計時間が正確に導き出せます。
これが累積和の基本的なやり方になります。

実務で使うにあたりどれだけデータが大量にあっても、この表(累積和の配列)をあらかじめプログラムに作らせておけば、任意の区間の合計値を出す計算が一瞬で終わります。

ガチャのレア度(UR, SR, N)で見る具体例

それでは本題の重み付き乱択アルゴリズムについて例を挙げながら説明します。よく活用されているのがソーシャルゲームのガチャです。新しく実装するガチャに、「UR(ウルトラレア)」「SR(スーパーレア)」「N(ノーマル)」の3つのレア度を入れるケースを考えてみます。

「重み付き」で設定する

重みで設定するときは、全体の合計値はいくらでも構いません。それぞれのレア度に「出やすさのパワー(数値)」を割り振るイメージです。

  • UR3
  • SR17
  • N80
  • 合計:100

この場合は合計が100なので確率と同じように見えますが、重みのメリットは合計値が柔軟に変えられる点にあります。たとえば、以下の設定も比率(3 : 17 : 80)が全く同じであるため、ゲーム内では完全に同じ確率のガチャとして動作します。

  • UR30
  • SR170
  • N800
  • 合計:1000

なぜ開発で「重み」を使うのか?

なぜ『重み』というアルゴリズムを使うの?」という疑問が湧くかもしれません。 その理由は、ゲームの運用中に「新しいレア度や、限定キャラを追加したい!」となったときに、重みを使った方が圧倒的にメンテナンスしやすいからです。

ガチャに新レア度「SSR」を追加する場合

重み付き乱択アルゴリズムを使っていると……(追加するだけ)

重みの場合は、全体の合計を気にする必要がありません。新レア度「SSR」の出やすさのパワー(重み)を「5」と決めたら、ただそれをリストに追加するだけでシステムが成立します。

  • UR3
  • SR17
  • N80
  • SSR5 (新しく追加!)
  • 新しい合計:105

重み付き乱択アルゴリズムのプログラムは、自動的に「全体の合計値(105)のうち、5の割合でSSRを出す」という計算を処理してくれます。開発者は他のレア度の数値を書き換える必要が一切ありません。

イメージ

実験

最後に実際にコードを使って重み付けの乱択を試してみます。
以下がコードになります。

# 1. ガチャの中身と「重み」を設定
# 比率は UR:3, SR:17, N:80(合計100)
gacha_box = { "UR" => 3, "SR" => 17, "N" => 80 }

# 2. 合計の重みを計算(3 + 17 + 80 = 100)
total_weight = gacha_box.values.sum

# 結果を記録するためのハッシュ(初期値はすべて0回)
results = { "UR" => 0, "SR" => 0, "N" => 0 }

# 3. ガチャを1000回引くシミュレーション
total_rolls = 1000

total_rolls.times do
  random_value = rand(0..total_weight)
  
  # どのレア度に当選したかを判定するループ
  current_sum = 0
  gacha_box.each do |rarity, weight|
    current_sum += weight # 重みを順番に足していく(境界線を作る)
    
    # ランダムな数字が、現在の境界線より小さければ当選!
    if random_value < current_sum
      results[rarity] += 1
      break # 当選したら今回のガチャは終了、次の回へ
    end
  end
end

# 4. 検証結果の出力
puts "=== ガチャ1000回の実行結果 ==="
results.each do |rarity, count|
  # 実際に当たった確率(%)を計算
  actual_percentage = (count.to_f / total_rolls * 100).round(1)
  
  # 設定した本来の理論上の確率(%)を計算
  theoretical_percentage = (gacha_box[rarity].to_f / total_weight * 100).round(1)
  
  puts "[#{rarity}] 当選回数: #{count}回 | 実際の確率: #{actual_percentage}% (理論値: #{theoretical_percentage}%)"
end<

実行結果

ruby free/gacha.rb
=== ガチャ1000回の実行結果 ===
[UR] 当選回数: 33回 | 実際の確率: 3.3% (理論値: 3.0%)
[SR] 当選回数: 148回 | 実際の確率: 14.8% (理論値: 17.0%)
[N] 当選回数: 819回 | 実際の確率: 81.9% (理論値: 80.0%)

多少のズレはありますが、重み付けした比率の抽選結果になりました。

感想

実際の業務では、今回のガチャとは全く異なる対象の設計と実装に取り組みました。対象が複雑であったため形にするまでは非常に大変でしたが、粘り強く向き合ったことでシステムを構築する上での貴重な経験と深い理解を得ることができました。
近年はAIなどの技術を活用することで、以前に比べプログラムの実装自体は比較的簡単に行える環境が整ってきています。今後はこの利点を活かし、実装のハードルに阻まれることなく、さらに多様なアルゴリズムの仕組みや設計に触れ、エンジニアとしての引き出しを増やしていきたいと考えています。


さいごに

現在デザイン・システム室では、新しいメンバーを募集しています。少しでも興味を持たれた方は、ぜひご応募ください✨💻
皆様からのご応募、心よりお待ちしております。

参考資料
https://harucoder.jp/blog/algorithm-cumulative-sum#%E7%B4%AF%E7%A9%8D%E5%92%8C%E3%81%A8%E3%81%AF
https://qiita.com/kohnosuke/items/4b09507c63fbee32a372
https://qiita.com/pocokhc/items/a8f0281d54e1de7d3a30
https://www.shokasonjuku.com/ux-psychology/variable-reward