モバイル&ワイヤレスブロードバンドでインターネットへ

gwaw.jp
 
GPGPU × PARTICLES · 技術ノート

群れが、相場をつくる
— エージェント市場をWebGPUで描く

株価はなぜ、理由もなく急騰し、ある日突然暴落するのか。その答えは、市場に集う一人ひとりの「群れの振る舞い」にある。数千の参加者を、それぞれ独立した粒子としてGPU上で動かし、その総意が価格を動かす様子を可視化する。テクスチャの「面」で計算した反応拡散系に対し、こちらはバッファの「点群」で計算する——もう一つのGPGPUの形を解説する。

WebGPUエージェントモデルstorage buffer 粒子描画compute→render群集心理

01市場は、群れである

バブルと暴落は、なぜ起きるのか。経済学の教科書は「価格は需要と供給で決まる」と教えるが、その需要と供給を生み出すのは、市場に集う無数の参加者一人ひとりの判断だ。彼らは互いを見ながら売り買いし、その総意が価格を動かし、動いた価格がまた彼らの判断を変える。

この「群れの振る舞い」を捉えるのがエージェントベースモデルだ。市場全体を方程式で表すのではなく、参加者一人ひとり(エージェント)に単純な行動ルールを与え、彼らを大量に動かして、全体として何が起きるかを観察する。鳥の群れや魚の群れと同じく、単純な個の集まりから複雑な全体が立ち現れる——その様子を、WebGPUで数千のエージェントを同時に動かして可視化する。

023種類のエージェント

市場参加者の行動を単純化し、3つのタイプに分ける。それぞれ価格への反応が異なる。

逆張り(contrarian) トレンド追従(trend-follow) ノイズ(noise)

逆張りは「価格が高すぎれば売り、安すぎれば買う」。基準価格に引き戻す力だ。 トレンド追従は「上がっていれば買い、下がっていれば売る」。流れを増幅する力で、バブルと暴落の主役になる。 ノイズは理由なくランダムに売買する、市場の揺らぎだ。この3つの力の綱引きが、相場の表情を決める。

03状態をバッファに持つ — もう一つのGPGPU

反応拡散系では、状態を2次元テクスチャに持ち、各ピクセルが近傍と影響し合った。市場のエージェントは違う。一人ひとりが独立した存在で、空間的な近傍関係を持たない。この「独立した多数の個」を表すのに最適なのが、1次元のstorage bufferだ。

各エージェントの属性をvec4に詰める。タイプ(逆張り/トレンド/ノイズ)、反応の強さの係数、表示位置のばらつき、そして個体差の種。これを N 人分の配列としてGPUに置く。

JavaScript — エージェントをバッファに
// 1エージェント = vec4(type, coef, jitter, 個体差seed) agentBuf = device.createBuffer({ size: N * 16, // vec4 = 4 × 4バイト usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, }); // 需要の出力先(compute が書き込む) demandBuf = device.createBuffer({ size: N * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, });
テクスチャ vs バッファ 状態の持ち方が、問題の構造を映す。反応拡散は「場」——各点が隣と相互作用するのでテクスチャ(2次元格子)が自然。市場は「群れ」——各個が独立なのでバッファ(1次元配列)が自然。同じ「compute→render」でも、扱う対象によって器が変わる。

04需要を計算する compute シェーダー

コンピュートシェーダーが、全エージェントの「いま売りたいか買いたいか」(需要)を並列に計算する。各エージェントは自分のタイプに応じて、現在価格と基準価格、トレンドを見て需要を決める。

WGSL — タイプ別の需要計算
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) gid: vec3<u32>) { let i = gid.x; if (i >= U.n) { return; } let a = AG[i]; let ty = a.x; let coef = a.y; var d : f32 = 0.0; if (ty < 0.5) { // 逆張り:fair乖離に逆らう d = coef * (U.logF - U.cur) * 2.5; } else if (ty < 1.5) { // トレンド追従:流れに乗る d = coef * clamp(U.trend, -0.06, 0.06) * 1.8; } else { // ノイズ:ランダム let g = hash(i * 2654435761u + U.step * 40503u); d = coef * (g - 0.5) * 0.5; } DEM[i] = vec4<f32>(d, ty, a.z, a.w); // 需要を書き出す }

ここで効くのが個体差だ。同じトレンド追従でも、全員が全く同じ反応をすると、群れは「四角い塊」のように一様に動いてしまう。各エージェントに固有の微小なノイズを加えることで、塊がほぐれ、自然な「ざわめく群れ」になる。

WGSL — 個体差で群れをほぐす
// 各エージェント固有の揺らぎ(塊をほぐして自然な群れに) let wob = hash(i * 374761393u + U.step * 668265263u); d = d + (wob - 0.5) * 0.06;
乱数はハッシュで GPUには状態を持つ乱数生成器がない。代わりに、インデックスiとステップ数からハッシュ関数で疑似乱数を作る。同じ入力には同じ出力が返るので再現性があり、しかもエージェントごと・時刻ごとに違う値になる。fract(sin(x))系は精度が荒く偏るため、ビット演算ベースのハッシュを使う。

05頂点バッファのない粒子描画

計算した需要を、画面に描く。各エージェントを1つの粒子(点)として表示するが、ここでWebGPUらしい技法を使う。頂点バッファを一切用意しないのだ。

1つの粒子を、2枚の三角形(6頂点)からなる小さな四角形で描く。頂点シェーダーはvertex_index(何番目の頂点か)だけを受け取り、それを6で割ってエージェントID、6で割った余りで四角形のどの角かを判定する。エージェントの属性はストレージバッファから直接読む。

WGSL — vertex_index だけで粒子を配置
@vertex fn vs(@builtin(vertex_index) vi: u32) -> VOut { let id = vi / 6u; // 何番目のエージェントか let corner = vi % 6u; // 四角形のどの角か let dem = DEM[id]; // storage から直接読む let demand = dem.x; let ty = dem.y; // 需要を横位置に(左=売り、右=買い) let xC = clamp(demand * 2.2, -0.9, 0.9); // タイプを縦の帯に振り分け var bandCenter = 0.0; if (ty < 0.5) { bandCenter = 0.38; } // 逆張り else if (ty < 1.5) { bandCenter = -0.12; } // トレンド else { bandCenter = -0.62; } // ノイズ // ... corner に応じて四角形の頂点を生成 ... }

描画呼び出しはdraw(N * 6)——エージェント数×6頂点を指定するだけ。GPUが頂点インデックスを連番で渡し、シェーダーがその場で位置を計算する。頂点データの転送が一切不要で、計算結果(demandBuf)をそのまま描画に使える。compute が書いたバッファを、render が直接読む——これがバッファGPGPUの要だ。

横軸が語ること 粒子の横位置は、そのエージェントの「売買意欲」を表す。左に寄れば売り、右に寄れば買い。トレンド層(シアン)と逆張り層(赤)が逆方向に動くとき、市場は二つの心理に引き裂かれている。価格が急落すれば、トレンドは一斉に左(パニック売り)へ、逆張りは右(押し目買い)へ——群れが割れる瞬間が見える。

06発光する点 — 群れに命を吹き込む

四角い点のままでは無機質だ。フラグメントシェーダーで、各粒子を中心が明るく外へ向かって減衰する光の点にする。頂点シェーダーが四角形の各頂点にローカル座標(-1〜1)を渡し、フラグメントが中心からの距離を計算して、円の外を切り捨て、内側を発光させる。

WGSL — 発光する円
@fragment fn fs(in: VOut) -> @location(0) vec4<f32> { let r = length(in.uv); // 中心からの距離 if (r > 1.0) { discard; } // 四角の角を切って円に let glow = pow(1.0 - r, 1.8); // 中心が明るく縁が柔らかい return vec4<f32>(in.col.rgb * glow, glow * 0.85); }

さらに、描画を加算ブレンドに設定する。光が重なると明るさが足し合わされ、粒子が密集する場所ほど輝く。これにより、群れの密度が光の濃淡として見えるようになる。無機質な点の集まりが、生物の群れのようにざわめく光になる。

07群れの総意が、価格になる

最後に、群れの総意から価格を決める。各エージェントの需要を合計し、買いが多ければ価格が上がり、売りが多ければ下がる。この集計は、GPUが計算したdemandBufをCPU側に読み戻して行う。

JavaScript — 需要を集計して価格を更新
// GPUのdemandBufをCPUに読み戻し(mapAsync) await readBuf.mapAsync(GPUMapMode.READ); const dem = new Float32Array(readBuf.getMappedRange()); let total = 0; for (let i = 0; i < N; i++) total += dem[i*4]; // 需要の合計 // 総需要から価格を更新(買い超→上昇、売り超→下落) logPrice += LAMBDA * (total / N);

更新された価格は次のフレームのcompute(各エージェントの判断)に戻され、ループが閉じる。価格がエージェントを動かし、エージェントが価格を動かす——この相互フィードバックこそが、バブルと暴落を生む。トレンド追従が多いほどループは自己強化的になり、小さなきっかけが暴騰や暴落へ増幅される。

フィードバックの暴走に注意 トレンド追従の力を強くしすぎると、価格が一方向に発散して画面外へ飛んでいく。実装では、トレンド項をclampで制限し、価格を対数空間で扱い、上下限を設けることで安定させている。現実の市場にサーキットブレーカーがあるのと同じく、シミュレーションにも暴走を抑える仕掛けが要る。

08試してみる

下のデモは、ここまで解説したエージェント市場がそのまま動いている。3タイプの構成比を変えたり、ショックを与えたり、スピードを調整できる。トレンド追従を増やすとバブルと暴落が激しくなり、逆張りを増やすと市場が安定する——群集心理が相場を動かす様子を体感してほしい。

WebGPU 判定中...
構成比: ノイズ 20%
逆張り トレンド追従 ノイズ |横軸:←売り 買い→
各点が1エージェント。横位置=その瞬間の売買意欲(左=売り、中央=中立、右=買い)。縦の3帯は上から逆張り・トレンド追従・ノイズ。上部のシアンの線が価格推移です。価格が動くと、トレンド層は順方向に、逆張り層は逆方向に群れます。

09結論 — バッファとテクスチャ、2つのGPGPU

このデモと、反応拡散系のデモは、対をなしている。どちらも「compute がデータを更新し、render がそれを描く」という同じ骨格を持つが、データの器が違う。

反応拡散はテクスチャに状態を持った。各ピクセルが近傍と相互作用する「場」のシミュレーションで、2次元格子と近傍アクセスが自然だった。市場はバッファに状態を持った。独立した多数の個が振る舞う「群れ」のシミュレーションで、1次元配列と個別計算が自然だった。流体や煙は前者、群衆や粒子や物理シミュレーションは後者——問題の構造が、器を決める

そして両者に共通する美しさは、計算と描画が地続きなことだ。computeが書いたメモリを、renderが追加コピーなしに直接読む。CPUとGPUの間でデータを往復させる必要がない。手のひらの中のGPUで数千のエージェントが同時に思考し、その総意がリアルタイムに絵になる。群集心理という捉えどころのないものが、光る群れとして目の前で動く——計算は、見えないものを見せる道具になる。

『群れが、相場をつくる — エージェント市場をWebGPUで描く』を公開しました。