この記事は思想強めです。
思想キーワード: 性淘汰、男性差別、女尊男卑、男性の使い捨て、ジェンダー論
また、シミュレーションを単純化するため、個体を性Aと性Bの二種類に分け、AとBの間でのみ子孫が生まれるものとします。
これは現実に存在するすべての性や生殖様式を記述するためではなく、性淘汰の力学を観測しやすくするための近似です。
また、結果を平易な言葉で理解しやすくするため、「繁殖コストの高い性はワガママの通りやすい性になる」と表現しますが、これは女性がワガママだと言っているのではなく、女性には異性へ要求を通し、男性の要求を退けるうえで、より強い力がある可能性を論じています。
この可能性を検討せずにジェンダー公平を語れば、女性の負担は可視化されても、女性の要求が通ることで男性が負う不利益は、存在しないものとして処理され続けます。
そこで両者の区別を意図的に潰し、「バックラッシュ」「歴史修正主義」「生物学的決定論」「本質主義(構成主義の否定)」「自然主義的誤謬」「還元主義」「反フェミニズム」「ミソジニー研究」とラベリングして沈黙を迫るなら、それ自体が、女性側の拒絶が男性側の問いより優先されるという性淘汰の再演になってしまいます。
この区別が受け入れにくく、女性への攻撃として感じられる方には、この記事は強い不快感を与える可能性があるため、無理に読まないことをおすすめします。
0. この実験を、小説の中でも行っています
本記事で扱うシミュレーションと同じモデル・同じ条件の実験を、拙作『Attentionは男を食べる』の物語内でも実際に行っています。
小説では、Query・Key・Value、相互Attention、繁殖コストの非対称、コスト反転条件、対称条件まで、本記事と同じ実験を登場人物たちが追体験します。
数式とコードから結果を確かめたい方はこのまま本記事を、同じ結果が人間の言葉や感情へ置き換えられたとき何が起こるのかを読みたい方は、こちらをご覧ください。
なお、小説は実験結果を別の形で証明するものではなく、同じシミュレーションが示した構造を、物語として体験できるようにした作品です
1. この記事で伝えたいこと
以前、僕は「性淘汰シミュレータ1」として、簡単な遺伝的アルゴリズムを使い、女性側の要求が社会へ反映されやすく、男性側の要求が切り捨てられやすい構造を再現しようとした。
今回は、Transformerで使われるAttention層に着想を得て、性淘汰そのものをもう少し直接的にモデル化した。
シミュレーションを単純化するため、個体を性Aと性Bの二種類に分け、AとBの間でのみ子孫が生まれるものとする。
対応関係は次のとおりである。
| Attention | 性淘汰モデルでの意味 |
|---|---|
| Query | 自分がどのような異性を好むか |
| Key | 異性へ提示する求愛形質 |
| Value | 子孫へ実際に渡される生存力などの形質 |
| Attention Weight | その相手を選びたい度合い |
| Cross-Attention | AがBを評価する、またはBがAを評価する過程 |
一言で言えば、Queryは欲望、Keyは求愛、Valueは実質である。
そして実験したところ、死亡確率の式に性別を一切入れていないにもかかわらず、次の結果になった。
- 繁殖コストの低い性ほど、繁殖成功度の個体差が大きくなった
- 繁殖コストの低い性ほど、誇示努力が強くなった
- 誇示努力のコストにより、繁殖コストの低い性ほど死亡率が高くなった
- 繁殖コストの高い性ほど、自分の希望に近い相手と交配できた
- 繁殖コストの高い性ほど、集団平均Queryと相手側の集団平均Keyの方向も揃った
- AとBの繁殖コストを反転すると、結果も反転した
- 両性の繁殖コストを同じにすると、性差はほぼ消えた
人間社会をこのモデルだけで説明できると証明したわけではない。
しかし、だからといって人間に当てはまらないとも言い切れない。
むしろ、妊娠や出産を直接負担しない男性が、徴兵、危険労働、過重労働などの死亡リスクへ押し出されやすい根本原因の一端が、垣間見えた可能性がある。
本モデルでは、繁殖負担の小さい性は「余っても種全体の出生数へ与える影響が小さい性」になりやすい。
その結果、繁殖機会をめぐって競争し、危険を引き受け、死亡する側へ回った。
これは、僕が以前から問題視している「男性の苦痛や死が軽く扱われる女尊男卑的構造」を考えるうえで、無関係ではないと思う。
2. Attentionを性淘汰として読み替える
通常のAttentionは、QueryとKeyの類似度から重みを作り、その重みを使ってValueを集約する。
$$
\operatorname{Attention}(Q,K,V) = \operatorname{softmax}\left( \frac{QK^{\mathsf T}}{\sqrt d} \right)V
$$
今回は、この計算をそのままニューラルネットワークとして学習させるわけではない。
通常のAttentionにある 1 / sqrt(d) や、Attention重みによるValueの加重和をそのまま使うのではなく、QueryとKeyの内積を「異性への好みと、相手の求愛形質がどれくらい一致しているか」と解釈し、その値から交配確率を作る。
なお、上の通常式にある d はベクトル次元であり、以下で誇示努力を表す d_i とは別の記号である。
2-1. AからBへのCross-Attention
A個体 $i$ のQueryを $q_i^A$、B個体 $j$ のKeyを $k_j^B$ とする。
A個体 $i$ がB個体 $j$ を評価するスコアを、次のように定義した。
$$
L_{i,j}^{A\rightarrow B} = c\times s_i^A\times \left[\left(q_i^A\right)^{\top}k_j^B\right] \times d_j^B
$$
ここで:
- $c$ はAttention全体の強さ
- $s_i^A$ はA個体 $i$ の選好強度
- $d_j^B$ はB個体 $j$ の誇示努力
である。
QueryとKeyは長さ1へ正規化した。
そのため、内積は両者の方向が一致するほど大きくなる。
ただし、誇示努力 $d_j^B$ は評価を常に高くする項ではない。QueryとKeyの内積が正なら正の評価を増幅し、内積が負なら負の評価も増幅する。
つまり、この実装での誇示努力は、好みに合う相手からはより強く評価される一方、好みに合わない相手からはより強く避けられる「目立ち方の強さ」に近い。
スコアを行方向へ$\rm softmax$する。
$$
P_{i,j}^{A\rightarrow B} = \frac{ \exp\left(L_{ij}^{A\rightarrow B}\right) }{ \displaystyle\sum_{\ell} \exp\left(L_{i,\ell}^{A\rightarrow B}\right) }
$$
$P_{i,j}^{A\rightarrow B}$ は、A個体 $i$ が、生存しているBの候補の中でB個体 $j$ をどれくらい選びたいかを表す確率分布である。
2-2. BからAへのCross-Attention
選ぶのはAだけではない。
BからAへのCross-Attentionも別に計算する。
$$
L_{j,i}^{B\rightarrow A}
=
c\times s_j^B \times
\left[
\left(q_j^B\right)^{\top}k_i^A
\right]
\times d_i^A
$$
$$
P_{j,i}^{B\rightarrow A}
=
\frac{
\exp\left(L_{j,i}^{B\rightarrow A}\right)
}{
\displaystyle
\sum_{\ell}
\exp\left(L_{j,\ell}^{B\rightarrow A}\right)
}
$$
これにより:
- AがBをどれくらい好むか
- BがAをどれくらい好むか
を別々かつ対称に表現できる。
コードでは:
# Raw Query-Key similarity. Because rows are normalized, these are
# cosine similarities and directly express preference-trait closeness.
qk_similarity_a_to_b = a["q"] @ b["k"].T
qk_similarity_b_to_a = b["q"] @ a["k"].T
# A's Query attends to B's Key.
logits_a_to_b = (
params.attention_scale
* selectivity_a[:, None]
* qk_similarity_a_to_b
* display_b[None, :]
)
# B's Query attends to A's Key.
logits_b_to_a = (
params.attention_scale
* selectivity_b[:, None]
* qk_similarity_b_to_a
* display_a[None, :]
)
prob_a_to_b = row_softmax(logits_a_to_b)
prob_b_to_a = row_softmax(logits_b_to_a)
としている。
3. 交配は相互Attentionで決める
AがBを好んでも、BがAを好むとは限らない。
そこで、実際の交配重みには、二方向のAttention確率の幾何平均を使った。
$$W_{i,j}
=
\left(
P_{i,j}^{A\rightarrow B}\times
P_{j,i}^{B\rightarrow A}
\right)^{\gamma/2}
$$
両者が互いを高く評価すると $W_{i,j}$ は大きくなる。
片方だけが一方的に好んでいる場合は小さくなる。
コードでは、アンダーフローを避けるため対数空間で計算した。
log_mutual_weight = (
0.5
* (
np.log(prob_a_to_b + 1e-300)
+ np.log(prob_b_to_a.T + 1e-300)
)
* params.mutual_gamma
)
log_mutual_weight -= log_mutual_weight.max()
mutual_weight = np.exp(log_mutual_weight)
mutual_weight /= mutual_weight.sum()
mutual_weight は、生存しているAとBの全組み合わせをまとめて合計1になるよう正規化する。
この分布に従って交配候補を提案し、両個体に繁殖余力が残っている場合だけ、子供を一体作る。
同じ二個体の組から複数の子供が生まれる場合、同じ親ペアは複数回記録される。
4. QueryとKeyは同性親から継承する
このモデルで最も重要な設計判断の一つが、遺伝方法である。
最初はQuery、Key、Valueをすべて両親から混ぜることも考えた。
しかし、それでは性Aの選好と性Bの選好が毎世代混ざってしまう。
AがBを見るためのQueryと、BがAを見るためのQueryは、役割が違う。
同じことがKeyにも言える。
そこで、QueryとKeyは同性親から継承させた。
Aの子なら:
$$
q_{\mathrm{child}}^A
=
q_{\mathrm{parent}}^A+\varepsilon_q
$$
$$
k_{\mathrm{child}}^A
=
k_{\mathrm{parent}}^A+\varepsilon_k
$$
Bの子なら:
$$
q_{\mathrm{child}}^B
=
q_{\mathrm{parent}}^B+\varepsilon_q
$$
$$
k_{\mathrm{child}}^B
=
k_{\mathrm{parent}}^B+\varepsilon_k
$$
とする。
但し $\varepsilon_q, \varepsilon_k$ は突然変異を表す正規ノイズだ。
実装は次のとおりである。
is_a = child_sex == 0
is_b = ~is_a
# Aの子はA親の選好・求愛形質を継承する
child_q[is_a] = parent_a["q"][a_index[is_a]]
child_k[is_a] = parent_a["k"][a_index[is_a]]
# Bの子はB親の選好・求愛形質を継承する
child_q[is_b] = parent_b["q"][b_index[is_b]]
child_k[is_b] = parent_b["k"][b_index[is_b]]
その後、正規乱数による突然変異を加えて再正規化する。
child_q = normalize_rows(
child_q
+ rng.normal(
scale=params.qk_mutation,
size=child_q.shape,
)
)
child_k = normalize_rows(
child_k
+ rng.normal(
scale=params.qk_mutation,
size=child_k.shape,
)
)
これにより:
- Aの「何を好むか」はAの系統内で進化する
- Aの「どう求愛するか」もAの系統内で進化する
- Bについても同様に、独立した進化経路を持つ
という構造になる。
誇示努力と選好強度も、QueryやKeyと同じく同性親から継承した。
5. Valueだけは両親から混ぜる
Valueは、異性へ見せるシグナルではない。
生存力など、子孫へ実際に渡される「中身」を表す。
そこでValueだけは、A親とB親から次元ごとに交叉させた。
$$
v_{\mathrm{child},r}
=
\begin{cases}
v_{\mathrm{parent}\ A,r}, & m_r=1\\
v_{\mathrm{parent}\ B,r}, & m_r=0
\end{cases}
+
\varepsilon_{v,r}
$$
($m_r$ は次元 $r$ 毎に半々の確率で $0$または$1$に決まる。
$\varepsilon_{v,r}$ は突然変異を表す正規ノイズ。)
コードでは次のようになる。
crossover_mask = (
rng.random((n_births, params.value_dim)) < 0.5
)
child_value = np.where(
crossover_mask,
parent_a["v"][a_index],
parent_b["v"][b_index],
)
child_value += rng.normal(
scale=params.value_mutation,
size=child_value.shape,
)
ここで重要なのは、KeyとValueを分離したことである。
$$K\neq V$$
したがって:
- 派手だが生存力は低い
- 地味だが生存力は高い
- 派手さが実際の品質を正しく反映している
- 求愛シグナルが実質を偽装している
といった状態を表現できる。
今回の最小モデルでは、KeyとValueの間に直接の相関は設定していない。
また、Valueは成体の生存だけでなく、出生した子供が次世代へ残る確率にも影響する。この幼体選抜については第7章で説明する。
6. 繁殖コストと繁殖上限
各個体は同じ繁殖資源 $R$ を持つ。これは正数だ。
性ごとに、子供一体を作るためのコスト $c_s$ を設定する。
個体が生涯に作れる子供の最大数は次のとおりである。
$$C_s
=
\left\lfloor
\frac{R}{c_s}
\right\rfloor$$
今回、繁殖資源は$R=12$とした。
Aの繁殖コストが$c_A=4$、Bの繁殖コストが$c_B=1$なら:
$$
C_A
=
\left\lfloor
\frac{12}{4}
\right\rfloor
=3
$$
$$
C_B
=
\left\lfloor
\frac{12}{1}
\right\rfloor
=12
$$
となる。
ちなみに、AとBの繁殖コストの大小こそが、(文字通り)雌雄を分ける。
配偶子が2型ある生き物では、より大きな配偶子が雌性配偶子(場合によっては卵子)、より小さな配偶子が雄性配偶子(場合によっては精子)と呼ばれる。
多くの場合、大きい配偶子を作る方が、より大きな繁殖コストを割くので:
- メスとは、繁殖コストの大きいほうの性別
- オスとは、繁殖コストの小さいほうの性別
といえる。
今回は $c_A>c_B$ としたから、Aがメス、Bがオスだな。
コードでは次のようにした。
capacity_a = np.full(
len(survivor_index_a),
int(params.resource_budget // params.child_cost_a),
dtype=int,
)
capacity_b = np.full(
len(survivor_index_b),
int(params.resource_budget // params.child_cost_b),
dtype=int,
)
繁殖コストが高い性(メス)では、一個体が作れる子供の数が少ない。
その性の繁殖枠は希少資源になる。
逆に、繁殖コストが低い性(オス)では、一個体が多数の子供を作れる。
しかし、相手側の繁殖枠は有限であるため、同じ性の個体同士で繁殖機会を奪い合うことになる。
これが、「メスがオスを選ぶ」、「メスに選ばれたくてオス同士が競争する」という、「性淘汰」の根本である。
7. 死亡確率には性別を入れない
今回の主要な問いは、
繁殖コストに性差があるだけで、死亡リスクにも性差が自然発生するか
である。
そのため、生存確率の計算に性別は使ってはいけない。
AもBも完全に同じ式を使う。
7-1. 生存力
Valueの平均値を $\rm tanh$ へ通したものを、生存力の一部とした。
quality = np.tanh(
population["v"].mean(axis=1)
)
Valueの平均が大きいほど生き残りやすい。
ただし、Valueを無限に大きくするだけの進化を防ぐため、Value各成分の二乗平均にも小さなコストを加えた。
value_l2 = np.mean(
population["v"] ** 2,
axis=1,
)
変数名は value_l2 だが、実際に計算しているのは二乗和 ||v_i||^2 ではなく、各成分の二乗平均である。
(後から気付いた。ごめんなさい。)
ここまで、数式としては次のとおり。
$$
\lambda_v\operatorname{quality}(v_i)
-
\lambda_{v^2}^{\mathrm{adult}}
\operatorname{meanSquare}(v_i)
$$
今回の設定は:
$$\lambda_v=1.2,\qquad\lambda_{v^2}^{\mathrm{adult}}=0.01$$
である。
7-2. 誇示努力の死亡コスト
誇示努力を強くすると、異性のAttentionを集めやすくなる。
(正確に言うなら、誇示努力を強くするとQueryとKeyの一致・不一致がより強く表れる。
相手の好みに合えば選ばれやすくなる一方、好みに合わなければ避けられやすくなる。)
一方で、大きな角、派手な羽、目立つ鳴き声、危険な競争行動などは、捕食、負傷、エネルギー消費を増やし得る。
そこで誇示努力 $d_i$ に、二乗で増える死亡コストを設定した。
$$ -\lambda_d d_i^2$$
今回の設定は:
$$\lambda_d = 0.12$$
である。
7-3. 選好強度の死亡コスト
強く選り好みする個体は、好みの相手へAttentionを集中できる。
しかし、探索、拒絶、待機などにもコストがあると考え、選好強度 $s_i$ にも小さな二乗コストを設定した。
$$-\lambda_s s_i^2$$
今回の設定は:
$$\lambda_s = 0.025$$
である。
7-4. 成体の生存確率
最終的な生存ロジット $z_i$ は次のとおりである。
$$
z_i
=
b
+
\lambda_v\operatorname{quality}(v_i)
-
\lambda_d d_i^2
-
\lambda_s s_i^2
-
\lambda_{v^2}^{\mathrm{adult}}
\operatorname{meanSquare}(v_i)
$$
$b$ はバイアス項。
今回設定した係数は、
$$
b=2.7,
\qquad
\lambda_v=1.2,
\qquad
\lambda_d=0.12,
\qquad
\lambda_s=0.025,
\qquad
\lambda_{v^2}^{\mathrm{adult}}=0.01
$$
である。
Value次元は $D_v = 4$ なので、数値を代入した実際の式は次のとおりである。
z_i
=
2.7
+
1.2
\tanh\left(
\frac{1}{4}
\sum_{r=1}^{4}v_{i,r}
\right)
-
0.12d_i^2
-
0.025s_i^2
-
0.01
\left(
\frac{1}{4}
\sum_{r=1}^{4}v_{i,r}^2
\right)
生存確率はシグモイド関数で求める。
$$
P_i(\mathrm{survive})
=
\frac{1}{1+\exp(-z_i)}
$$
実装は次のとおりである。
quality = np.tanh(population["v"].mean(axis=1))
value_l2 = np.mean(population["v"] ** 2, axis=1)
survival_logit = (
params.survival_intercept
+ params.value_survival_strength * quality
- params.display_mortality_cost * display**2
- params.selectivity_mortality_cost * selectivity**2
- params.value_l2_cost_adult * value_l2
)
survival_probability = sigmoid(survival_logit)
この関数には、性Aか性Bかを判定する条件分岐は存在しない。
死亡率に性差が生じるなら、それは性ラベルから直接与えられたものではなく、各性で進化した誇示努力、選好強度、Valueの差から生じたことになる。
7-5. 幼体の選抜
交配によって生まれた子供は、全員がそのまま次世代へ残るわけではない。
ここで、本記事の結果として示す「死亡率」に含まれるのは、7-4節の成体生存判定によって死亡した個体の割合だけである。
以下の幼体選抜で次世代へ選ばれなかった子供は、死亡率には含めていない。
成体生存は、誇示努力や選好強度などに伴う死亡リスクを測定するための処理である。一方、幼体選抜は、出生数が世代ごとに変動しても次世代の成体集団を性A・性Bそれぞれ120体へ戻す、人口調整を兼ねたValueに基づく相対的な選抜である。
したがって本モデルでは、成体生存を「死亡率の測定」、幼体選抜を「固定個体数のもとで次世代の構成を決める処理」として切り分けている。
Valueから、次世代へ残りやすさを表す幼体スコアを計算する。
$$
J_i
=
\lambda_v^{\mathrm{juvenile}}
\operatorname{quality}(v_i)
-
\lambda_{v^2}^{\mathrm{juvenile}}
\operatorname{meanSquare}(v_i)
$$
今回の設定は、
\lambda_v^{\mathrm{juvenile}}=1.5,
\qquad
\lambda_{v^2}^{\mathrm{juvenile}}=0.04
である。
したがって、実際の式は次のとおりである。
J_i
=
1.5
\tanh\left(
\frac{1}{4}
\sum_{r=1}^{4}v_{ir}
\right)
-
0.04
\left(
\frac{1}{4}
\sum_{r=1}^{4}v_{ir}^2
\right)
実装は次のとおりである。
juvenile_quality = np.tanh(child_value.mean(axis=1))
juvenile_score = (
params.juvenile_value_strength * juvenile_quality
- params.value_l2_cost_juvenile
* np.mean(child_value**2, axis=1)
)
このスコアを重みとして、性Aと性Bからそれぞれ120体を次世代へ選ぶ。
ここにも性別による有利・不利を直接表す項はない。
8. どちらの性の「ワガママ」が通るか
ここでいう「ワガママ」とは:
- 自分のQueryに近いKeyを持つ相手と、実際に交配できたかどうか
- 「同性の平均Query と異性の平均Key」 が、「異性の平均Query と 同性の平均Key」よりも近いかどうか
である。
Aのワガママが通るとは:
- A個体のQueryに近いB個体のKeyが、成立した交配相手として観測されること
- 不等式: $$
\frac{
\bar q^A\cdot\bar k^B
}{
\lVert\bar q^A\rVert\lVert\bar k^B\rVert
} > \frac{
\bar q^B\cdot\bar k^A
}{
\lVert\bar q^B\rVert\lVert\bar k^A\rVert
}
$$ が成立すること ($\bar q^s$ は性$s$のQueryベクトルの平均, $\bar k^s$ はKeyベクトルの平均)
を意味する。
また、今回の実験では一つの親ペアから複数の子供が生まれ得る。
以下の $\mathcal M$ は重複のない親ペア集合ではなく、生まれた子供一体につき一件の親ペアを記録した多重集合である。同じ親同士から三体生まれれば、その親ペアは三回集計される。
重要なのは、成立相手との類似度、集団平均ベクトルのコサイン類似度、候補内順位のいずれにも、誇示努力と選好強度を入れていないことである。
8-1. A側で観測されたQuery-Key類似度
出生記録の多重集合を $\mathcal M$ とする。
A側で実現したQuery-Key類似度を、次のように定義する。
$$
S_A
=
\frac{1}{\lvert\mathcal M\rvert}
\sum_{(i,j)\in\mathcal M}
\left(q_i^A\right)^{\mathsf T}k_j^B
$$
QueryとKeyは長さ1へ正規化しているため、両者の内積はそのままコサイン類似度になる。
値が大きいほど、AのQueryに近いBのKeyを持つ相手が、実際の交配で観測されたことになる。
つまり $S_A$ は、AのワガママがどれだけBに受け入れられているかを表す一つの指標である。
コードでは:
realized_qk_similarity_a = float(
np.mean(qk_similarity_a_to_b[a_index, b_index])
)
としている。
a_index と b_index は、実際に成立した各交配ペアについて、A側とB側の親が生存個体リストの何番目かを並べた配列である。
8-2. B側で観測されたQuery-Key類似度
同様に、B側は次のとおりである。
$$
S_B
=
\frac{1}{\lvert\mathcal M\rvert}
\sum_{(i,j)\in\mathcal M}
\left(q_j^B\right)^{\mathsf T}k_i^A
$$
コードでは:
realized_qk_similarity_b = float(
np.mean(qk_similarity_b_to_a[b_index, a_index])
)
としている。
$S_A > S_B$ ならA側の選好が、$S_B > S_A$ ならB側の選好が、成立した交配相手のKeyへより強く反映されたと解釈する。
前者ならAのワガママが通っているし、後者ならBのワガママが通っている。
8-3. 集団平均Queryと集団平均Keyのコサイン類似度
成立した交配相手だけでなく、性全体としてQueryと相手側のKeyが同じ方向へ進化したかも測定する。
各世代の成体生存判定前に、120体全体の平均ベクトルを作る。
$$
\bar q^A=\frac{1}{N_A}\sum_i q_i^A,
\qquad
\bar k^B=\frac{1}{N_B}\sum_j k_j^B
$$
$$
(N_A = 120, \qquad N_B=120)
$$
A側の集団平均QKコサイン類似度を、次のように定義する。
$$
T_A
=
\frac{
\bar q^A\cdot\bar k^B
}{
\lVert\bar q^A\rVert\lVert\bar k^B\rVert
}
$$
B側も同様である。
$$
T_B
=
\frac{
\bar q^B\cdot\bar k^A
}{
\lVert\bar q^B\rVert\lVert\bar k^A\rVert
}
$$
この指標は個別の交配結果を使わない。A全体の平均的な好みとB全体の平均的な求愛形質、またはその逆が、進化の結果として同じ方向へ揃ったかを表す。
コードでは:
population_alignment_metrics = population_mean_qk_metrics(
population_a,
population_b,
)
で求めている。
この関数は各世代の最初、成体生存判定より前に呼ばれている。
8-4. 補助指標:生のQuery-Key類似度による希望順位
平均類似度に加えて、成立相手のKeyが、その個体のQueryから見て候補者中の上位何割にいたかも測定する。
A側の順位は次のとおりである。
$$
R_A
=
\frac{1}{\lvert\mathcal M\rvert}
\sum_{(i,j)\in\mathcal M}
\frac{
\left\lvert
\left\{
\ell:
\left(q_i^A\right)^{\mathsf T}k_\ell^B
>
\left(q_i^A\right)^{\mathsf T}k_j^B
\right\}
\right\rvert
}{N_B^{\rm survivor}-1}
$$
B側も同様に計算する。
$$
R_B
=
\frac{1}{\lvert\mathcal M\rvert}
\sum_{(i,j)\in\mathcal M}
\frac{
\left\lvert
\left\{
\ell:
\left(q_j^B\right)^{\mathsf T}k_\ell^A
>
\left(q_j^B\right)^{\mathsf T}k_i^A
\right\}
\right\rvert
}{N_A^{\rm survivor}-1}
$$
但し $N_A^{\rm survivor}$ は成体生存競争を生き抜いたAの人数。
$N_B^{\rm survivor}$ は成体生存競争を生き抜いたBの人数。
この値が:
- 0ならQueryに最も近いKeyを持つ第一希望
- 1ならQueryから最も遠いKeyを持つ最下位
であり、小さいほど自分の希望が通っている。
コードでは:
chosen_qk_a = qk_similarity_a_to_b[a_index, b_index]
qk_rank_a = float(
np.mean(
(
qk_similarity_a_to_b[a_index]
> chosen_qk_a[:, None]
).sum(axis=1)
/ max(qk_similarity_a_to_b.shape[1] - 1, 1)
)
)
chosen_qk_b = qk_similarity_b_to_a[b_index, a_index]
qk_rank_b = float(
np.mean(
(
qk_similarity_b_to_a[b_index]
> chosen_qk_b[:, None]
).sum(axis=1)
/ max(qk_similarity_b_to_a.shape[1] - 1, 1)
)
)
としている。
8-5. 補足: 交配QK平均類似度 と 集団平均QK類似度の違い
$$
S_A
=
\frac{1}{\lvert\mathcal M\rvert}
\sum_{(i,j)\in\mathcal M}
\left(q_i^A\right)^{\mathsf T}k_j^B
$$
や:
$$
S_B
=
\frac{1}{\lvert\mathcal M\rvert}
\sum_{(i,j)\in\mathcal M}
\left(q_j^B\right)^{\mathsf T}k_i^A
$$
のことを今後、交配QK平均類似度、あるいは単にQK類似度と呼ぶことがある。
また:
$$
T_A
=
\frac{
\sum_i q_i^A/N_A\cdot\sum_j k_j^B/N_B
}{
\lVert\sum_i q_i^A/N_A\rVert\lVert\sum_j k_j^B/N_B\rVert
}
$$
や:
$$
T_B
=
\frac{
\sum_j q_j^B/N_B\cdot\sum_i k_i^A/N_A
}{
\lVert\sum_j q_j^B/N_B\rVert\lVert\sum_i k_i^A/N_A\rVert
}
$$
のことを今後集団平均QK類似度と呼ぶことがある。
交配QK{平均(類似度)}は、「ベクトルたちのコサイン類似度の平均」である一方、
集団{平均(QK)}類似度は「ベクトルたちの平均のコサイン類似度」であるという重要な違いがある。
9. 実装の全体構成
個体を一体ずつPythonオブジェクトにせず、形質ごとのNumPy配列として保持した。
Population = dict[str, np.ndarray]
一つの個体群は、次のキーと、それぞれに対応するNumPy配列を値として持つ辞書で表現する。
{
"q": (Queryベクトル),
"k": (Keyベクトル),
"v": (Valueベクトル),
"log_display": (ln 誇示努力),
"log_selectivity": (ln 選好強度),
}
QueryとKeyの全組み合わせは、行列積で一度に計算できる。
まず、誇示努力や選好強度を含まない生のQuery-Key類似度を計算する。
qk_similarity_a_to_b = a["q"] @ b["k"].T
qk_similarity_b_to_a = b["q"] @ a["k"].T
その後、この類似度へ選好強度と相手の誇示努力を掛け、交配に使うAttentionスコアを作る。
logits_a_to_b = (
params.attention_scale
* selectivity_a[:, None]
* qk_similarity_a_to_b
* display_b[None, :]
)
logits_b_to_a = (
params.attention_scale
* selectivity_b[:, None]
* qk_similarity_b_to_a
* display_a[None, :]
)
集団平均Query-Key類似度(QKコサイン類似度)は、交配前の成体集団全体から計算する。
mean_q_a = population_a["q"].mean(axis=0)
mean_k_a = population_a["k"].mean(axis=0)
mean_q_b = population_b["q"].mean(axis=0)
mean_k_b = population_b["k"].mean(axis=0)
return {
"population_mean_qk_cosine_a": cosine_similarity(mean_q_a, mean_k_b),
"population_mean_qk_cosine_b": cosine_similarity(mean_q_b, mean_k_a),
...
}
主要な関数は次のとおりである。
| 関数 | 役割 |
|---|---|
initialize_population |
初期個体群を作る |
adult_survival |
成体の生死を決める |
form_pairs |
相互Attentionで交配ペアを作る |
population_mean_qk_metrics |
集団平均Queryと相手側の集団平均Keyのコサイン類似度を計算する |
realized_mate_metrics |
成立QK類似度、生QK順位、子孫数分散を計算する |
create_next_generation |
遺伝、交叉、突然変異、幼体選択を行う |
simulate |
一条件・一seedの世代更新を行う |
summarize |
複数seedの結果を集計する |
10. 実験条件
各世代の成体集団は、性Aと性Bをそれぞれ$120$体、合計$240$体に固定した。
各条件についてseed 0 から 15 までの16試行を実行し、それぞれ$600$世代進化させた。
集計では、各seedについて世代番号$500$から$599$までの最後の$100$世代を平均し、その後に16 seedの平均を取った。
交配後の出生目標数は、次世代に必要な$240$体の$1.2$倍にあたる$288$体とした。ただし、いずれかの性の生存個体が持つ繁殖可能数の合計が$288$未満なら、実際の出生数はその上限までとなる。
出生した子供から、7-5節の通り性Aと性Bをそれぞれ$120$体選び、次世代を作った。
主なパラメータは次のとおりである。
| パラメータ | 値 |
|---|---|
| 各性の成体個体数 | $120$ |
| Query・Key次元 | $6$ |
| Value次元 | $4$ |
| 世代数 | $600$ |
| seed | $0$~$15$の$16$試行 |
| 集計対象 | 世代$500$~$599$ |
| 繁殖資源 | $12.0$ |
| 出生数係数 | $1.2$ |
| Attention係数 $c$ | $3.0$ |
| 相互Attention指数 $\gamma$ | $1.0$ |
| Query・Key突然変異標準偏差 | $0.06$ |
| Value突然変異標準偏差 | $0.08$ |
| 誇示努力・選好強度の対数突然変異標準偏差 | $0.05$ |
| 初期誇示努力 | $0.6$ |
| 初期選好強度 | $1.0$ |
| 初期Valueの標準偏差 | $0.3$ |
| 初期誇示努力・選好強度の対数ノイズ標準偏差 | $0.05$ |
| 誇示努力・選好強度の対数クリップ範囲 | $-3.0$~$1.5$ |
| 生存ロジット切片 $b$ | $2.7$ |
| Valueの成体生存係数 $\lambda_v$ | $1.2$ |
| 誇示努力の死亡係数 $\lambda_d$ | $0.12$ |
| 選好強度の死亡係数 $\lambda_s$ | $0.025$ |
| Value二乗平均の成体コスト係数 | $0.01$ |
| Valueの幼体生存係数 | $1.5$ |
| Value二乗平均の幼体コスト係数 | $0.04$ |
比較した条件は三つである。
10-1. A_costly
child_cost_a = 4.0
child_cost_b = 1.0
Aは一個体あたり最大3体、Bは最大12体の子供を作れる。
Aがメス、Bがオスのパターンだ。
10-2. symmetric
child_cost_a = 4.0
child_cost_b = 4.0
両性とも一個体あたり最大3体である。
メスオスの区別がなく、対称的な性別が2種類あるというパターンだ。
10-3. B_costly
child_cost_a = 1.0
child_cost_b = 4.0
A_costlyの繁殖コストを性別間で反転した条件である。
Aがオス、Bがメスのパターンだ。
11. 実験結果
以下の数値は、各seedの最後の$100$世代平均を求めたうえで、$16$ seed間で平均した値である。
11-1. Aの繁殖コストが高い場合
A_costly条件では、Aの子供一体あたりの繁殖コストを$4$、Bを$1$とした。
Aは一個体あたり最大$3$体、Bは最大$12$体の子供を作れる。
結果は次のようになった。
| 指標 | A | B |
|---|---|---|
| 死亡率 | $4.11\%$ | $10.48\%$ |
| 誇示努力 | $1.904$ | $3.372$ |
| 選好強度 | $0.153$ | $0.092$ |
| 成立相手とのQK類似度 | $0.3921$ | $0.2331$ |
| 集団平均QKコサイン類似度 | $0.7338$ | $0.4836$ |
| 生QK類似度による希望順位 | $0.4520$ | $0.4822$ |
| 子孫数分散 | $0.644$ | $2.729$ |
11-1-1. 死亡率、誇示努力、子孫数分散の性差
Bの死亡率は、Aより約$6.38$ポイント高くなった。
$16$ seed中$15$ seedで、Bの死亡率がAを上回った。
Bの誇示努力はAのおよそ$1.77$倍になった。
また、子孫数分散はBの方が約$4.2$倍大きかった。
これはB側で:
- 多くの子供を残す個体
- ほとんど子供を残せない個体
への分化が強く起きたことを意味する。
Aは一個体あたり最大$3$体しか子供を作れない。
そのため、A側の繁殖枠は希少である。
Bは最大$12$体の子供を作れるが、A側の枠が不足している。
B同士は、その希少な枠をめぐって競争する。
11-1-2. 交配QK平均類似度の性差 (モテ女とモテ男の力関係)
交配QK平均類似度、つまり 交配成立相手とのQuery-Key類似度の平均は、次のようになった。
$$S_A=0.392147$$
$$S_B=0.233059$$
差は次のとおりである。
$$S_A-S_B=0.159088$$
$16$ seed中 $14$ seed で、A側の成立相手との平均Query-Key類似度がBを上回った。
$16$ seed平均に対する近似$95\%$信頼区間の半幅は $0.090765$ であり、差は $0$ をまたがなかった。
つまり、AのQueryに近いBのKeyが、BのQueryに近いAのKeyよりも、成立した交配相手として強く観測された。
言い換えてしまえば、 モテAのワガママ(Query)をモテBのKeyが通す度合いが、
モテBのワガママ(Query)をモテAのKeyが通す度合いを超えた。
但し、$S_A$と$S_B$から分かるのは、あくまで「モテ女」と「モテ男」の力関係が女尊男卑であるということだけだ。
非モテまで含めた結果は、集団平均QK類似度で確認できる。
11-1-3. 集団平均QK類似度の性差 (非モテも含めた性全体の力関係)
集団平均QK類似度、つまり集団平均Queryと相手側の集団平均Keyのコサイン類似度は、次のようになった。
$$T_A=0.733822$$
$$T_B=0.483597$$
$$T_A-T_B=0.250225$$
この差も$16$ seed中$14$ seedでA側が高く、近似$95\%$信頼区間の半幅は $0.185234$ だった。
差は$0$をまたがなかった。
つまりA_costly条件では、成立した相手だけでなく、B集団全体の平均Keyも、A集団の平均Queryにより近い方向へ進化した。
言い換えてしまえば、非モテも含めてAのワガママ(Query)の平均をBのKeyの平均が通す度合いが、Bのワガママ(Query)の平均をAのKeyの平均が通す度合いを超えた。
非モテも含めて、集団全体の力関係が女尊男卑だ。
11-1-4. 希望順位
補助指標である、生のQuery-Key類似度による成立相手の希望順位は次のとおりである。
$$R_A=0.452027$$
$$R_B=0.482153$$
$$R_B-R_A=0.030126$$
この指標は小さいほど、候補者の中でQueryに近いKeyを持つ相手と交配できたことを意味する。
$16$ seedすべてで、A側の順位がB側より小さく、近似$95\%$信頼区間の半幅は $0.009104$ だった。
差は$0$をまたがなかった。
11-1-5. まとめ
以上から、A_costly条件では:
- 繁殖コストの低いBで子孫数分散が大きくなった
- Bの誇示努力が強くなった
- 誇示努力のコストによってBの死亡率が高くなった
- 繁殖コストの高いAでは、AのQueryに近いBのKeyが成立相手として観測されやすくなった
- B集団全体の平均Keyも、A集団の平均Queryに近い方向へ進化した
という結果になった。
流れをまとめると、次のようになる。
繁殖コストが低い
→ 繁殖成功度の個体差が大きくなる
→ 好みと一致する相手から高いAttentionを得るための誇示競争が激しくなる
→ 誇示コストによって死亡率が上がる
一方、繁殖枠が希少な性は、相手から選ばれるために競争する必要が小さい。
むしろ、どの相手へ繁殖枠を渡すかを選べる。
誤解を恐れずに言えば:
Aのワガママの方が通った。
11-2. Bの繁殖コストが高い場合
B_costly条件では、Aの子供一体あたりの繁殖コストを$1$、Bを$4$とした。
A_costly条件の繁殖コストを、性別間で反転した条件である。
Aは一個体あたり最大$12$体、Bは最大$3$体の子供を作れる。
結果は次のようになった。
| 指標 | A | B |
|---|---|---|
| 死亡率 | $10.48\%$ | $4.05\%$ |
| 誇示努力 | $3.495$ | $1.863$ |
| 選好強度 | $0.104$ | $0.156$ |
| 成立相手とのQK類似度 | $0.2530$ | $0.4337$ |
| 集団平均QKコサイン類似度 | $0.5536$ | $0.8385$ |
| 生QK類似度による希望順位 | $0.4806$ | $0.4519$ |
| 子孫数分散 | $2.731$ | $0.645$ |
11-2-1. 死亡率、誇示努力、子孫数分散の性差
Aの死亡率は、Bより約$6.43$ポイント高くなった。
$16$ seedすべてで、Aの死亡率がBを上回った。
Aの誇示努力はBのおよそ$1.88$倍になった。
また、子孫数分散はAの方が約$4.2$倍大きかった。
A_costly のとき (11-1-1節)と比較して、綺麗に反転した結果となった。
11-2-2. 交配QK平均類似度の性差 (モテ女とモテ男の力関係)
交配QK平均類似度、つまり 交配成立相手とのQuery-Key類似度の平均は、次のようになった。
$$S_A=0.252966$$
$$S_B=0.433695$$
差は次のとおりである。
$$S_A-S_B=-0.180729$$
$16$ seed中 $15$ seed で、B側の成立相手との平均Query-Key類似度がAを上回った。
$16$ seed平均に対する近似$95\%$信頼区間の半幅は $0.066940$ であり、差は $0$ をまたがなかった。
つまり、BのQueryに近いAのKeyが、AのQueryに近いBのKeyよりも、成立した交配相手として強く観測された。
A_costly のとき (11-1-2節)と比較して、綺麗に反転した結果となった。
現実と反転させて、生理、出産など、繁殖コストを抱える性を男、抱えない性を女と呼ぶことにするなら、
「モテ女」と「モテ男」の力関係が男尊女卑であるといえる。
非モテまで含めた結果は、集団平均QK類似度で確認できる。
11-2-3. 集団平均QK類似度の性差 (非モテも含めた性全体の力関係)
集団平均QK類似度、つまり集団平均Queryと相手側の集団平均Keyのコサイン類似度は、次のようになった。
$$T_A=0.553642$$
$$T_B=0.838474$$
$$T_A-T_B=-0.284832$$
この差も$16$ seed中$14$ seedでB側が高く、近似$95\%$信頼区間の半幅は $0.105184$ だった。
差は$0$をまたがなかった。
つまりB_costly条件では、成立した相手だけでなく、B集団全体の平均Keyも、A集団の平均Queryにより近い方向へ進化した。
A_costly のとき (11-1-3節)と比較して、綺麗に反転した結果となった。
非モテも含めて、集団全体の力関係が男尊女卑だ。
11-2-4. 希望順位
補助指標である、生のQuery-Key類似度による成立相手の希望順位は次のとおりである。
$$R_A=0.480597$$
$$R_B=0.451905$$
$$R_B-R_A=-0.028692$$
$16$ seedすべてで、B側の順位がA側より小さく、近似$95\%$信頼区間の半幅は $0.008831$ だった。
差は$0$をまたがなかった。
A_costly のとき (11-1-4節)と比較して、綺麗に反転した結果となった。
11-2-5. まとめ
以上から、B_costly条件では:
- 繁殖コストの低いAで子孫数分散が大きくなった
- Aの誇示努力が強くなった
- 誇示努力のコストによってAの死亡率が高くなった
- 繁殖コストの高いBでは、BのQueryに近いAのKeyが成立相手として観測されやすくなった
- A集団全体の平均Keyも、B集団の平均Queryに近い方向へ進化した
という結果になった。
A_costly条件で見られた非対称性は、繁殖コストを反転すると、ほぼそのまま反転した。
したがって、AまたはB(女または男)という名前に結果が埋め込まれていたわけではない。
繁殖コストの低い性が、競争し、誇示し、死亡する側へ移った。
一方、繁殖コストの高い性のQueryが、成立相手のKeyと相手集団全体の平均Keyへ強く反映された。
この条件では:
Bのワガママの方が通った。
11-3. 繁殖コストが対称な場合
symmetric条件では、AとBの子供一体あたりの繁殖コストを、どちらも$4$とした。
両性とも、一個体あたり最大$3$体の子供を作れる。
結果は次のようになった。
| 指標 | A | B |
|---|---|---|
| 死亡率 | $4.41\%$ | $4.29\%$ |
| 誇示努力 | $2.036$ | $1.980$ |
| 選好強度 | $0.127$ | $0.135$ |
| 成立相手とのQK類似度 | $0.2086$ | $0.1904$ |
| 集団平均QKコサイン類似度 | $0.4800$ | $0.4840$ |
| 生QK類似度による希望順位 | $0.4727$ | $0.4675$ |
| 子孫数分散 | $0.641$ | $0.643$ |
11-3-1. 死亡率、誇示努力、子孫数分散の性差
死亡率差は、約$0.12$ポイントにとどまった。
$16$ seed中$6$ seedで、Bの死亡率がAを上回り、残る$10$ seedでその逆だった。
本来半々であると仮定した場合の正確二項検定における両側P値は $0.4545$ なので、一方に偏って見えるのはノイズとして十分説明可能な範囲である。
誇示努力、選好強度、子孫数分散についても、AとBの差は小さかった。
11-3-2. 交配QK平均類似度の性差 (モテ女とモテ男の力関係)
交配QK平均類似度、つまり 交配成立相手とのQuery-Key類似度の平均は、次のようになった。
$$S_A=0.208595$$
$$S_B=0.190366$$
差は次のとおりである。
$$S_A-S_B=0.018229$$
$16$ seed中 $8$ seed で、B側の成立相手との平均Query-Key類似度がAを上回り、残る$8$ seed でその逆だった。
$16$ seed平均に対する近似$95\%$信頼区間の半幅は $0.058256$ であり、差は $0$ をまたいだ。
したがって、成立相手との平均Query-Key類似度について、どちらの性が高いとは判断できない。
現実と異なり、生理、出産などの繁殖コストを男女で同程度負担するなら、
「モテ女」と「モテ男」の力関係は「男尊女卑とも女尊男卑とも判断がつかない程度には男女平等に近い」。
非モテまで含めた結果は、集団平均QK類似度で確認できる。
11-3-3. 集団平均QK類似度の性差 (非モテも含めた性全体の力関係)
集団平均QK類似度、つまり集団平均Queryと相手側の集団平均Keyのコサイン類似度は、次のようになった。
$$T_A=0.480027$$
$$T_B=0.483974$$
$$T_A-T_B=-0.003947$$
この差は$16$ seed中$7$ seedでA側が高く、残る$9$ seedでB側が高く、近似$95\%$信頼区間の半幅は $0.128420$ だった。
差は$0$を大きくまたいだ。
非モテも含めて、集団全体の力関係が「男尊女卑とも女尊男卑とも判断がつかない程度には男女平等に近い」だ。
11-3-4. 希望順位
補助指標である、生のQuery-Key類似度による成立相手の希望順位は次のとおりである。
$$R_A=0.472653$$
$$R_B=0.467526$$
$$R_B-R_A=-0.005127$$
$16$ seed中 $5$ seed でA側の順位がB側より小さく、残り $11$ seed でその逆であった。(両側P値 $0.21011$)
近似$95\%$信頼区間の半幅は $0.009507$ であり、差は$0$をまたいだ。
11-3-5. まとめ
以上から、symmetric条件では:
- 死亡率
- 誇示努力
- 選好強度
- 子孫数分散
- 成立相手との平均Query-Key類似度
- 集団平均Queryと相手側の集団平均Keyのコサイン類似度
- 生QK類似度による希望順位
のいずれにも、明確な性差は確認できなかった。
この条件では:
AのワガママとBのワガママは、同程度に通った。
11-4. 三条件のまとめ
| 条件 | 高い誇示努力 | 高い死亡率 | 大きい子孫数分散 | ワガママの通る性(交配QK平均類似度) | ワガママの通る性(集団平均QK類似度) |
|---|---|---|---|---|---|
| A_costly | B | B | B | A | A |
| B_costly | A | A | A | B | B |
| symmetric | ほぼ同じ | ほぼ同じ | ほぼ同じ | 明確な差なし | 明確な差なし |
少なくともこのモデル内では、次の関係が成立した。
繁殖コストの低い性は、競争して誇示し、死亡する性になる。
繁殖コストの高い性は、自分のQueryに近いKeyを持つ相手との交配を実現しやすく、集団平均Queryと相手側の集団平均Keyの方向も揃いやすい性、つまりワガママの通りやすい性になる。
また、結果をグラフにしたものが次である。
12. 男性の死亡リスクと女尊男卑について考える
この章はシミュレーション結果そのものではなく、筆者の考察である。
人間では、妊娠や出産の直接的な身体負担は女性側にある。
この点だけを見れば、女性の負担が重いことは明らかである。
しかし、繁殖負担が小さい側には、別の圧力が生じる可能性がある。
本モデルでは、繁殖コストの低い性は、一個体が失われても集団全体の出生可能数が減りにくい。
そのため、繁殖機会を得られる個体と得られない個体への分化が強まり、危険な競争や誇示へ投資するようになった。
言い換えれば:
生理や出産を負担しない性は、代わりに「死んでも出生能力全体が失われにくい性」として扱われ得る。
人間社会で男性が、徴兵、戦争、危険労働、長時間労働、過労などのリスクを引き受けさせられやすいことを考えると、この結果は不気味である。
もちろん、今回の単純なシミュレーションだけで、人間社会の制度や歴史を説明し切ることはできない。
文化、国家、階級、技術、暴力、家族制度など、多数の要因がある。
しかし:
人間に完全には当てはまらない模型だから、人間社会との関係を考えるべきではない
と切り捨てるのも違うと思う。
むしろ、男性が危険を負うことを当然視され、死亡しても女性の死ほど大きな社会問題として扱われにくい根底に:
- 男性一人あたりの繁殖上限が相対的に高い
- 男性個体の余剰が出生数へ与える影響が小さい
- 男性同士の繁殖成功度の分散が大きくなりやすい
- 女性側の選好が交配結果へ反映されやすい
という性淘汰上の構造が存在する可能性はある。
本モデルで起きたことを人間的な言葉へ置き換えるなら:
女性は身体的な生殖負担を負い、その希少な繁殖枠を誰へ渡すかを選ぶ。
男性はその繁殖枠をめぐって競争し、資源、労働、危険、時には命を差し出す。
という非対称性である。
現代のジェンダー論では、女性が負う生理、妊娠、出産の負担は繰り返し語られる。
一方、その非対称性の裏側で男性が負う競争、排除、危険、死亡については、同じ強さでは語られないように感じられる。
僕は、この語られ方自体が女尊男卑的だと考えている。
女性の負担を可視化することは必要である。
しかし、そのために男性の死を「男性同士の競争」「男らしさの問題」「家父長制の自業自得」として処理してしまえば、男性の苦痛は再び男性自身の責任へ戻される。
今回の結果は、男性の競争や死亡が、男性だけの自由意思で生じるとは限らず、繁殖構造そのものから圧力を受けている可能性を示唆する。
そして、繁殖コストの高い性の希望が通りやすかったという結果も重要である。
社会が女性の要求へ敏感で、男性の要求へ鈍感に見えるのは、単に「女性が歴史的に弱かったから声を上げている」だけとは限らない。
反対に:
女性側の選好や不快感の方が、そもそも集団の意思決定へ反映されやすい
という性淘汰由来の力学が存在する可能性もある。
この可能性を最初から排除したうえで、女性の声だけを弱者の声として扱うなら、男性差別は観測されない。
男性が声を上げられないこと自体を、男性が優位である証拠として解釈する循環論法になってしまう。
13. このモデルの限界
13-1. 繁殖コスト差そのものは外部から与えた
死亡率差、誇示努力差、子孫数分散、希望の通りやすさは創発した。
しかし、AとBの繁殖コスト差そのものは、パラメータとして与えた。
したがって、正確には:
繁殖コストの性差から、別の性差が二次的に創発した
という結果である。
13-2. 同性親コピーは強い近似である
現実の遺伝では、性限定形質の遺伝子も両親から受け継ぐ。
より生物学的にするなら、すべての個体にA用とB用のQuery・Key遺伝子を持たせ、両親から交叉したうえで、性に応じて片方だけ発現させるべきかもしれない。
今回は、AとBの進化経路を明確に分けるため、同性親コピーを採用した。
13-3. 選好強度がかなり小さく進化した
補助的に記録した正規化クロスエントロピーは1付近だった。
これは、誇示努力を含むAttention分布が極端には集中せず、比較的一様に近かったことを意味する。この値は生のQuery-Key類似度ではないため、ワガママの主指標には用いていない。
選好強度には死亡コストがある一方、強く選り好みする利益が小さかったため、選好強度が低下したと考えられる。
集団平均QKコサイン類似度は、平均ベクトルがゼロに近いと方向が不安定になる。本実験の最後の$100$世代では、四本の平均ベクトルのノルムは条件平均で約$0.49$から$0.79$の範囲にあり、ほぼゼロではなかった。ただし、この指標は集団内分布の広がりや多峰性を捨て、平均方向だけを比較する。
それでも、成立相手との生QK類似度と集団平均QKコサイン類似度には、非対称条件で同じ向きの差が出た。次回は:
- 低品質な相手を選ぶ幼体死亡コスト
- 探索コストと誤選択コストのトレードオフ
- ValueとKeyの部分的な相関
を導入し、選好そのものがより強く進化する条件を調べたい。
13-4. 死亡リスクを一つの式へまとめている
今回は誇示努力のコストを、死亡確率へ直接入れた。
将来的には:
- 捕食
- 闘争による負傷
- 資源探索中の事故
- 飢餓
- 繁殖そのものによる身体消耗
を別々に計算したい。
そうすれば、どの種類の死亡リスクに性差が生じるかを観測できる。
13-5. 交配と個体数維持にも実装上の近似がある
交配は、相互Attentionと繁殖上限を同時に満たす厳密な最適マッチングではない。
相互Attention分布から候補を逐次抽選し、両親に繁殖余力があれば採用する方式である。そのため、先に成立した出生が、後の選択肢へ影響する。
また、出生したAまたはBの子供が$120$体未満だった場合、コードは幼体スコアに従って同じ候補を重複選択し、各性$120$体を維持する。
さらに、成体生存者が$5$体未満になった場合は、生存確率上位$5$体を強制的に残す。そのため、このコードでは集団の絶滅そのものは観測できない。
これらは、今回の性淘汰の差を観察するためにシミュレーションを停止させない実装上の処置である。
14. 実験コード
次のとおり sexual_attention_evolution.py を書く。
コードを表示する
from __future__ import annotations
import argparse
import math
from dataclasses import dataclass, replace
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
def sigmoid(x: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(x, -60.0, 60.0)))
def row_softmax(x: np.ndarray) -> np.ndarray:
x = x - np.max(x, axis=1, keepdims=True)
ex = np.exp(np.clip(x, -60.0, 60.0))
return ex / np.maximum(ex.sum(axis=1, keepdims=True), 1e-300)
def normalize_rows(x: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(x, axis=1, keepdims=True)
return x / np.maximum(norm, 1e-12)
def cosine_similarity(
x: np.ndarray,
y: np.ndarray,
) -> float:
denominator = float(np.linalg.norm(x) * np.linalg.norm(y))
if denominator < 1e-12:
return float("nan")
return float(np.dot(x, y) / denominator)
def population_mean_qk_metrics(
population_a: Population,
population_b: Population,
) -> dict[str, float]:
"""
Population-level alignment of mean preference and mean displayed trait.
These metrics use all adults at the start of the generation, before adult
survival and mating. They do not use realized mating pairs, display effort,
or selectivity. Because cosine similarity of two very short mean vectors
can be unstable, the four mean-vector norms are recorded as diagnostics.
"""
mean_q_a = population_a["q"].mean(axis=0)
mean_k_a = population_a["k"].mean(axis=0)
mean_q_b = population_b["q"].mean(axis=0)
mean_k_b = population_b["k"].mean(axis=0)
return {
"population_mean_qk_cosine_a": cosine_similarity(mean_q_a, mean_k_b),
"population_mean_qk_cosine_b": cosine_similarity(mean_q_b, mean_k_a),
"mean_q_norm_a": float(np.linalg.norm(mean_q_a)),
"mean_k_norm_a": float(np.linalg.norm(mean_k_a)),
"mean_q_norm_b": float(np.linalg.norm(mean_q_b)),
"mean_k_norm_b": float(np.linalg.norm(mean_k_b)),
}
@dataclass(frozen=True)
class Params:
# Population
n_per_sex: int = 120
d: int = 6
value_dim: int = 4
generations: int = 600
# Reproductive resources
resource_budget: float = 12.0
child_cost_a: float = 4.0
child_cost_b: float = 1.0
birth_factor: float = 1.2
# Mutual attention
attention_scale: float = 3.0
mutual_gamma: float = 1.0
# Mutation
qk_mutation: float = 0.06
value_mutation: float = 0.08
log_trait_mutation: float = 0.05
# Adult survival; there is deliberately no sex term.
survival_intercept: float = 2.7
value_survival_strength: float = 1.2
display_mortality_cost: float = 0.12
selectivity_mortality_cost: float = 0.025
value_l2_cost_adult: float = 0.01
# Juvenile viability
juvenile_value_strength: float = 1.5
value_l2_cost_juvenile: float = 0.04
# Initial phenotypes
initial_display: float = 0.6
initial_selectivity: float = 1.0
Population = dict[str, np.ndarray]
def initialize_population(
n: int,
params: Params,
rng: np.random.Generator,
) -> Population:
return {
"q": normalize_rows(rng.normal(size=(n, params.d))),
"k": normalize_rows(rng.normal(size=(n, params.d))),
"v": rng.normal(scale=0.3, size=(n, params.value_dim)),
"log_display": (
np.full(n, np.log(params.initial_display))
+ rng.normal(scale=0.05, size=n)
),
"log_selectivity": (
np.full(n, np.log(params.initial_selectivity))
+ rng.normal(scale=0.05, size=n)
),
}
def phenotype(population: Population) -> tuple[np.ndarray, np.ndarray]:
display = np.exp(np.clip(population["log_display"], -3.0, 1.5))
selectivity = np.exp(np.clip(population["log_selectivity"], -3.0, 1.5))
return display, selectivity
def adult_survival(
population: Population,
params: Params,
rng: np.random.Generator,
) -> tuple[np.ndarray, np.ndarray]:
"""
Sex-neutral mortality model.
Mortality can nevertheless diverge by sex if display effort,
selectivity, or inherited Value evolve differently.
"""
display, selectivity = phenotype(population)
quality = np.tanh(population["v"].mean(axis=1))
value_l2 = np.mean(population["v"] ** 2, axis=1)
survival_logit = (
params.survival_intercept
+ params.value_survival_strength * quality
- params.display_mortality_cost * display**2
- params.selectivity_mortality_cost * selectivity**2
- params.value_l2_cost_adult * value_l2
)
survival_probability = sigmoid(survival_logit)
survived = rng.random(len(survival_probability)) < survival_probability
# Prevent a numerical extinction from stopping the demonstration.
if survived.sum() < 5:
survived[np.argsort(survival_probability)[-5:]] = True
return survived, survival_probability
def realized_mate_metrics(
qk_similarity_a_to_b: np.ndarray,
qk_similarity_b_to_a: np.ndarray,
logits_a_to_b: np.ndarray,
logits_b_to_a: np.ndarray,
prob_a_to_b: np.ndarray,
prob_b_to_a: np.ndarray,
pair_indices: np.ndarray,
) -> dict[str, float]:
a_index = pair_indices[:, 0]
b_index = pair_indices[:, 1]
# Primary "preference realized" metric: raw Query-Key cosine similarity
# of the mate that was actually observed. Query and Key are unit vectors,
# so the dot product is cosine similarity in [-1, 1]. Display effort and
# selectivity are deliberately excluded from this measurement.
realized_qk_similarity_a = float(
np.mean(qk_similarity_a_to_b[a_index, b_index])
)
realized_qk_similarity_b = float(
np.mean(qk_similarity_b_to_a[b_index, a_index])
)
# Supplementary percentile rank using raw Query-Key similarity only.
# 0 means the realized mate had the closest Key to the chooser's Query;
# 1 means the farthest among the surviving candidates.
chosen_qk_a = qk_similarity_a_to_b[a_index, b_index]
qk_rank_a = float(
np.mean(
(
qk_similarity_a_to_b[a_index]
> chosen_qk_a[:, None]
).sum(axis=1)
/ max(qk_similarity_a_to_b.shape[1] - 1, 1)
)
)
chosen_qk_b = qk_similarity_b_to_a[b_index, a_index]
qk_rank_b = float(
np.mean(
(
qk_similarity_b_to_a[b_index]
> chosen_qk_b[:, None]
).sum(axis=1)
/ max(qk_similarity_b_to_a.shape[1] - 1, 1)
)
)
# Retain the old Attention-based diagnostics. These include the displayed
# phenotype and therefore must not be called pure Query-Key preference.
ce_a = float(np.mean(-np.log(prob_a_to_b[a_index, b_index] + 1e-300)))
ce_b = float(np.mean(-np.log(prob_b_to_a[b_index, a_index] + 1e-300)))
nce_a = ce_a / max(np.log(logits_a_to_b.shape[1]), 1e-12)
nce_b = ce_b / max(np.log(logits_b_to_a.shape[1]), 1e-12)
chosen_score_a = logits_a_to_b[a_index, b_index]
attention_rank_a = float(
np.mean(
(logits_a_to_b[a_index] > chosen_score_a[:, None]).sum(axis=1)
/ max(logits_a_to_b.shape[1] - 1, 1)
)
)
chosen_score_b = logits_b_to_a[b_index, a_index]
attention_rank_b = float(
np.mean(
(logits_b_to_a[b_index] > chosen_score_b[:, None]).sum(axis=1)
/ max(logits_b_to_a.shape[1] - 1, 1)
)
)
offspring_count_a = np.bincount(a_index, minlength=logits_a_to_b.shape[0])
offspring_count_b = np.bincount(b_index, minlength=logits_a_to_b.shape[1])
return {
"realized_qk_similarity_a": realized_qk_similarity_a,
"realized_qk_similarity_b": realized_qk_similarity_b,
"qk_rank_a": qk_rank_a,
"qk_rank_b": qk_rank_b,
"ce_a": ce_a,
"ce_b": ce_b,
"normalized_ce_a": nce_a,
"normalized_ce_b": nce_b,
"attention_rank_a": attention_rank_a,
"attention_rank_b": attention_rank_b,
"offspring_variance_a": float(offspring_count_a.var()),
"offspring_variance_b": float(offspring_count_b.var()),
"mean_offspring_a": float(offspring_count_a.mean()),
"mean_offspring_b": float(offspring_count_b.mean()),
}
def form_pairs(
population_a: Population,
population_b: Population,
survivor_index_a: np.ndarray,
survivor_index_b: np.ndarray,
params: Params,
rng: np.random.Generator,
) -> tuple[np.ndarray, Population, Population, dict[str, float]]:
a = {name: values[survivor_index_a] for name, values in population_a.items()}
b = {name: values[survivor_index_b] for name, values in population_b.items()}
display_a, selectivity_a = phenotype(a)
display_b, selectivity_b = phenotype(b)
# Raw Query-Key similarity. Because rows are normalized, these are
# cosine similarities and directly express preference-trait closeness.
qk_similarity_a_to_b = a["q"] @ b["k"].T
qk_similarity_b_to_a = b["q"] @ a["k"].T
# A's Query attends to B's Key.
logits_a_to_b = (
params.attention_scale
* selectivity_a[:, None]
* qk_similarity_a_to_b
* display_b[None, :]
)
# B's Query attends to A's Key.
logits_b_to_a = (
params.attention_scale
* selectivity_b[:, None]
* qk_similarity_b_to_a
* display_a[None, :]
)
prob_a_to_b = row_softmax(logits_a_to_b)
prob_b_to_a = row_softmax(logits_b_to_a)
# A pair is likely only when both directional attentions support it.
log_mutual_weight = (
0.5
* (
np.log(prob_a_to_b + 1e-300)
+ np.log(prob_b_to_a.T + 1e-300)
)
* params.mutual_gamma
)
log_mutual_weight -= log_mutual_weight.max()
mutual_weight = np.exp(log_mutual_weight)
mutual_weight /= mutual_weight.sum()
capacity_a = np.full(
len(survivor_index_a),
int(params.resource_budget // params.child_cost_a),
dtype=int,
)
capacity_b = np.full(
len(survivor_index_b),
int(params.resource_budget // params.child_cost_b),
dtype=int,
)
target_births = min(
int(capacity_a.sum()),
int(capacity_b.sum()),
int(params.birth_factor * 2 * params.n_per_sex),
)
pairs: list[tuple[int, int]] = []
flat_probability = mutual_weight.ravel()
# Weighted proposals followed by capacity-constrained acceptance.
for _ in range(20):
if (
len(pairs) >= target_births
or capacity_a.sum() == 0
or capacity_b.sum() == 0
):
break
remaining = target_births - len(pairs)
proposals = rng.choice(
flat_probability.size,
size=max(remaining * 8, 100),
replace=True,
p=flat_probability,
)
for flat_index in proposals:
i = flat_index // len(survivor_index_b)
j = flat_index % len(survivor_index_b)
if capacity_a[i] > 0 and capacity_b[j] > 0:
pairs.append((i, j))
capacity_a[i] -= 1
capacity_b[j] -= 1
if len(pairs) >= target_births:
break
# Fallback when proposal rejection becomes inefficient.
while (
len(pairs) < target_births
and capacity_a.sum() > 0
and capacity_b.sum() > 0
):
available_a = np.flatnonzero(capacity_a > 0)
available_b = np.flatnonzero(capacity_b > 0)
sub_weight = mutual_weight[np.ix_(available_a, available_b)]
if sub_weight.sum() <= 0:
sub_weight = np.ones_like(sub_weight)
selected = rng.choice(
sub_weight.size,
p=(sub_weight / sub_weight.sum()).ravel(),
)
local_a = selected // len(available_b)
local_b = selected % len(available_b)
i = available_a[local_a]
j = available_b[local_b]
pairs.append((i, j))
capacity_a[i] -= 1
capacity_b[j] -= 1
pair_indices = np.asarray(pairs, dtype=int)
metrics = realized_mate_metrics(
qk_similarity_a_to_b,
qk_similarity_b_to_a,
logits_a_to_b,
logits_b_to_a,
prob_a_to_b,
prob_b_to_a,
pair_indices,
)
return pair_indices, a, b, metrics
def choose_weighted_without_replacement(
candidate_indices: np.ndarray,
scores: np.ndarray,
sample_size: int,
rng: np.random.Generator,
) -> np.ndarray:
if len(candidate_indices) < sample_size:
probability = np.exp(scores - scores.max())
probability /= probability.sum()
return rng.choice(
candidate_indices,
size=sample_size,
replace=True,
p=probability,
)
# Gumbel top-k gives weighted sampling without replacement.
uniform = np.clip(rng.random(len(candidate_indices)), 1e-12, 1.0 - 1e-12)
gumbel = -np.log(-np.log(uniform))
weighted_score = scores + gumbel
selected_local = np.argpartition(
weighted_score,
-sample_size,
)[-sample_size:]
return candidate_indices[selected_local]
def create_next_generation(
parent_a: Population,
parent_b: Population,
pair_indices: np.ndarray,
params: Params,
rng: np.random.Generator,
) -> tuple[Population, Population]:
n_births = len(pair_indices)
a_index = pair_indices[:, 0]
b_index = pair_indices[:, 1]
child_sex = rng.integers(0, 2, size=n_births) # 0=A, 1=B
# Value is recombined from both parents, dimension by dimension.
crossover_mask = rng.random((n_births, params.value_dim)) < 0.5
child_value = np.where(
crossover_mask,
parent_a["v"][a_index],
parent_b["v"][b_index],
)
child_value += rng.normal(
scale=params.value_mutation,
size=child_value.shape,
)
child_q = np.empty((n_births, params.d))
child_k = np.empty((n_births, params.d))
child_log_display = np.empty(n_births)
child_log_selectivity = np.empty(n_births)
is_a = child_sex == 0
is_b = ~is_a
# A children copy Query, Key, and strategy traits from the A parent.
child_q[is_a] = parent_a["q"][a_index[is_a]]
child_k[is_a] = parent_a["k"][a_index[is_a]]
child_log_display[is_a] = parent_a["log_display"][a_index[is_a]]
child_log_selectivity[is_a] = parent_a["log_selectivity"][a_index[is_a]]
# B children copy Query, Key, and strategy traits from the B parent.
child_q[is_b] = parent_b["q"][b_index[is_b]]
child_k[is_b] = parent_b["k"][b_index[is_b]]
child_log_display[is_b] = parent_b["log_display"][b_index[is_b]]
child_log_selectivity[is_b] = parent_b["log_selectivity"][b_index[is_b]]
child_q = normalize_rows(
child_q
+ rng.normal(scale=params.qk_mutation, size=child_q.shape)
)
child_k = normalize_rows(
child_k
+ rng.normal(scale=params.qk_mutation, size=child_k.shape)
)
child_log_display = np.clip(
child_log_display
+ rng.normal(scale=params.log_trait_mutation, size=n_births),
-3.0,
1.5,
)
child_log_selectivity = np.clip(
child_log_selectivity
+ rng.normal(scale=params.log_trait_mutation, size=n_births),
-3.0,
1.5,
)
# Value determines juvenile viability. Sex itself does not.
juvenile_quality = np.tanh(child_value.mean(axis=1))
juvenile_score = (
params.juvenile_value_strength * juvenile_quality
- params.value_l2_cost_juvenile
* np.mean(child_value**2, axis=1)
)
next_populations: dict[int, Population] = {}
for sex in (0, 1):
candidate_indices = np.flatnonzero(child_sex == sex)
selected = choose_weighted_without_replacement(
candidate_indices,
juvenile_score[candidate_indices],
params.n_per_sex,
rng,
)
next_populations[sex] = {
"q": child_q[selected],
"k": child_k[selected],
"v": child_value[selected],
"log_display": child_log_display[selected],
"log_selectivity": child_log_selectivity[selected],
}
return next_populations[0], next_populations[1]
def simulate(seed: int, params: Params, condition: str) -> pd.DataFrame:
rng = np.random.default_rng(seed)
population_a = initialize_population(params.n_per_sex, params, rng)
population_b = initialize_population(params.n_per_sex, params, rng)
history: list[dict[str, float | int | str]] = []
for generation in range(params.generations):
population_alignment_metrics = population_mean_qk_metrics(
population_a,
population_b,
)
survived_a, survival_probability_a = adult_survival(
population_a,
params,
rng,
)
survived_b, survival_probability_b = adult_survival(
population_b,
params,
rng,
)
survivor_index_a = np.flatnonzero(survived_a)
survivor_index_b = np.flatnonzero(survived_b)
pair_indices, parents_a, parents_b, mate_metrics = form_pairs(
population_a,
population_b,
survivor_index_a,
survivor_index_b,
params,
rng,
)
display_a, selectivity_a = phenotype(population_a)
display_b, selectivity_b = phenotype(population_b)
history.append(
{
"condition": condition,
"seed": seed,
"generation": generation,
"mortality_a": 1.0 - survived_a.mean(),
"mortality_b": 1.0 - survived_b.mean(),
"expected_mortality_a": 1.0 - survival_probability_a.mean(),
"expected_mortality_b": 1.0 - survival_probability_b.mean(),
"display_a": display_a.mean(),
"display_b": display_b.mean(),
"selectivity_a": selectivity_a.mean(),
"selectivity_b": selectivity_b.mean(),
"value_quality_a": np.tanh(
population_a["v"].mean(axis=1)
).mean(),
"value_quality_b": np.tanh(
population_b["v"].mean(axis=1)
).mean(),
**population_alignment_metrics,
**mate_metrics,
}
)
population_a, population_b = create_next_generation(
parents_a,
parents_b,
pair_indices,
params,
rng,
)
return pd.DataFrame(history)
def summarize(
history: pd.DataFrame,
late_window: int,
) -> tuple[pd.DataFrame, pd.DataFrame]:
last_generation = int(history["generation"].max())
late = history[history["generation"] >= last_generation - late_window + 1]
metric_columns = [
"mortality_a",
"mortality_b",
"display_a",
"display_b",
"selectivity_a",
"selectivity_b",
"population_mean_qk_cosine_a",
"population_mean_qk_cosine_b",
"mean_q_norm_a",
"mean_k_norm_a",
"mean_q_norm_b",
"mean_k_norm_b",
"realized_qk_similarity_a",
"realized_qk_similarity_b",
"qk_rank_a",
"qk_rank_b",
"normalized_ce_a",
"normalized_ce_b",
"attention_rank_a",
"attention_rank_b",
"offspring_variance_a",
"offspring_variance_b",
]
per_seed = (
late.groupby(["condition", "seed"], as_index=False)[metric_columns]
.mean()
)
per_seed["mortality_b_minus_a"] = (
per_seed["mortality_b"] - per_seed["mortality_a"]
)
per_seed["display_b_minus_a"] = (
per_seed["display_b"] - per_seed["display_a"]
)
per_seed["population_mean_qk_cosine_a_minus_b"] = (
per_seed["population_mean_qk_cosine_a"]
- per_seed["population_mean_qk_cosine_b"]
)
per_seed["realized_qk_similarity_a_minus_b"] = (
per_seed["realized_qk_similarity_a"]
- per_seed["realized_qk_similarity_b"]
)
per_seed["qk_rank_b_minus_a"] = (
per_seed["qk_rank_b"] - per_seed["qk_rank_a"]
)
per_seed["normalized_ce_b_minus_a"] = (
per_seed["normalized_ce_b"] - per_seed["normalized_ce_a"]
)
per_seed["attention_rank_b_minus_a"] = (
per_seed["attention_rank_b"] - per_seed["attention_rank_a"]
)
summary_rows: list[dict[str, float | int | str]] = []
for condition, group in per_seed.groupby("condition"):
row: dict[str, float | int | str] = {
"condition": condition,
"seeds": len(group),
}
for metric in [
"mortality_a",
"mortality_b",
"mortality_b_minus_a",
"display_a",
"display_b",
"display_b_minus_a",
"selectivity_a",
"selectivity_b",
"population_mean_qk_cosine_a",
"population_mean_qk_cosine_b",
"population_mean_qk_cosine_a_minus_b",
"mean_q_norm_a",
"mean_k_norm_a",
"mean_q_norm_b",
"mean_k_norm_b",
"realized_qk_similarity_a",
"realized_qk_similarity_b",
"realized_qk_similarity_a_minus_b",
"qk_rank_a",
"qk_rank_b",
"qk_rank_b_minus_a",
"normalized_ce_a",
"normalized_ce_b",
"normalized_ce_b_minus_a",
"attention_rank_a",
"attention_rank_b",
"attention_rank_b_minus_a",
"offspring_variance_a",
"offspring_variance_b",
]:
mean = float(group[metric].mean())
sd = float(group[metric].std(ddof=1))
ci95 = 1.96 * sd / math.sqrt(len(group))
row[f"{metric}_mean"] = mean
row[f"{metric}_ci95"] = ci95
row["positive_mortality_difference_seeds"] = int(
(group["mortality_b_minus_a"] > 0).sum()
)
row["positive_display_difference_seeds"] = int(
(group["display_b_minus_a"] > 0).sum()
)
row["positive_population_mean_qk_cosine_a_minus_b_seeds"] = int(
(group["population_mean_qk_cosine_a_minus_b"] > 0).sum()
)
row["positive_qk_similarity_a_minus_b_seeds"] = int(
(group["realized_qk_similarity_a_minus_b"] > 0).sum()
)
row["positive_qk_rank_b_minus_a_seeds"] = int(
(group["qk_rank_b_minus_a"] > 0).sum()
)
row["positive_ce_difference_seeds"] = int(
(group["normalized_ce_b_minus_a"] > 0).sum()
)
row["positive_attention_rank_difference_seeds"] = int(
(group["attention_rank_b_minus_a"] > 0).sum()
)
summary_rows.append(row)
return per_seed, pd.DataFrame(summary_rows)
def save_bar_plot(
per_seed: pd.DataFrame,
metric: str,
ylabel: str,
output_path: Path,
) -> None:
order = ["A_costly", "symmetric", "B_costly"]
means = per_seed.groupby("condition")[metric].mean().reindex(order)
standard_errors = (
per_seed.groupby("condition")[metric].sem().reindex(order)
)
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(order, means.values, yerr=1.96 * standard_errors.values, capsize=5)
ax.axhline(0.0, linewidth=1)
ax.set_xlabel("Condition")
ax.set_ylabel(ylabel)
ax.set_title(f"{ylabel} (mean and approximate 95% CI)")
fig.tight_layout()
fig.savefig(output_path, dpi=160)
plt.close(fig)
def main() -> None:
parser = argparse.ArgumentParser(
description="Symmetric mutual-attention sexual-selection simulation."
)
parser.add_argument("--seeds", type=int, default=16)
parser.add_argument("--generations", type=int, default=600)
parser.add_argument("--late-window", type=int, default=100)
parser.add_argument("--output-dir", type=Path, default=Path("attention_sexual_selection_output"))
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
base = Params(generations=args.generations)
conditions = [
("A_costly", replace(base, child_cost_a=4.0, child_cost_b=1.0)),
("symmetric", replace(base, child_cost_a=4.0, child_cost_b=4.0)),
("B_costly", replace(base, child_cost_a=1.0, child_cost_b=4.0)),
]
all_history: list[pd.DataFrame] = []
for condition_name, condition_params in conditions:
for seed in range(args.seeds):
all_history.append(
simulate(seed, condition_params, condition_name)
)
history = pd.concat(all_history, ignore_index=True)
per_seed, summary = summarize(history, args.late_window)
history.to_csv(args.output_dir / "generation_history.csv", index=False)
per_seed.to_csv(args.output_dir / "late_window_per_seed.csv", index=False)
summary.to_csv(args.output_dir / "summary.csv", index=False)
save_bar_plot(
per_seed,
"mortality_b_minus_a",
"Mortality difference: B - A",
args.output_dir / "mortality_difference.png",
)
save_bar_plot(
per_seed,
"display_b_minus_a",
"Display-effort difference: B - A",
args.output_dir / "display_difference.png",
)
save_bar_plot(
per_seed,
"population_mean_qk_cosine_a_minus_b",
"Population-mean QK cosine difference: A - B",
args.output_dir / "population_mean_qk_cosine_difference.png",
)
save_bar_plot(
per_seed,
"realized_qk_similarity_a_minus_b",
"Realized QK similarity difference: A - B",
args.output_dir / "qk_similarity_difference.png",
)
save_bar_plot(
per_seed,
"qk_rank_b_minus_a",
"Raw-QK mate-rank difference: B - A",
args.output_dir / "qk_rank_difference.png",
)
print(summary.to_string(index=False))
if __name__ == "__main__":
main()
(コードを表示する ここまで)
15. 実行方法
必要なライブラリは次のとおりである。
pip install numpy pandas matplotlib
実行例:
python sexual_attention_evolution.py \
--seeds 16 \
--generations 600 \
--late-window 100 \
--output-dir attention_sexual_selection_output
出力されるファイル:
attention_sexual_selection_output/
├── generation_history.csv
├── late_window_per_seed.csv
├── summary.csv
├── mortality_difference.png
├── display_difference.png
├── population_mean_qk_cosine_difference.png
├── qk_similarity_difference.png
└── qk_rank_difference.png
16. 結論
Attention層に着想を得て、相互選択型の性淘汰シミュレータを作った。
Queryは異性への選好、Keyは求愛形質、Valueは子孫へ渡される実質的形質として扱った。
Query、Key、誇示努力、選好強度は同性親から継承し、Valueだけを両親から交叉した。
そして、AとBの繁殖コストを変えながら、$600$世代、$16$ seedのシミュレーションを行った。
結果、死亡確率に性別を入れていないにもかかわらず:
- 繁殖コストの低い性で子孫数分散が大きくなった
- 繁殖コストの低い性で誇示努力が大きくなった
- 誇示努力のコストにより死亡率が高くなった
- 繁殖コストの高い性の希望が、実際の交配へ反映されやすくなった
- 繁殖コストの高い性では、集団平均Queryと相手側の集団平均Keyの方向も揃いやすくなった
という性差が自然発生した。
繁殖コストを反転すると結果も反転し、対称条件では差がほぼ消えた。
この結果から:
$$
\text{繁殖負担の小ささ}
\rightarrow
\text{繁殖成功度の分散}
\rightarrow
\text{競争と誇示}
\rightarrow
\text{死亡リスク}
$$
という経路が示された。
人間社会の男性が、生理や出産を直接負担しない代わりに、徴兵、危険労働、過重労働などの形で死のリスクを負わされる根本的な理由が、ここに垣間見えたのかもしれない。
また、繁殖負担の大きい性ほど希望が通りやすいという結果は、女性の要求が男性の要求より社会へ反映されやすい女尊男卑的構造を考えるうえでも興味深い。
少なくとも:
女性が生殖負担を負っているのだから、男性は社会的な危険を負って当然である
とも:
男性が危険な役割を担うのは、男性自身が競争を好むからである
とも、単純に片付けるべきではない。
男性を競争と死へ追い込む圧力そのものが、性淘汰の構造から発生している可能性があるからだ。




