1. 登場人物と3つの「社会の形」
このシミュレーションでは、600人の「人」が四角いエリアの中に住んでいます。彼らは3つの異なるパターンの社会を作ります。
-
① 多様な社会(Diverse society)
-
みんながバラバラに、自分の好きな場所に住んでいる状態です。
-
② 小グループに分かれた社会(Society with multiple groups)
-
いくつかの小さなコミュニティ(村や町のようなもの)に分かれて固まって住んでいる状態です。
-
③ 完全に統合された社会(Fully unified society)
-
全員が1か所にギュッと集まって、ひとつの大きな塊として住んでいる状態です。
2. ストーリーの流れ
シミュレーションは3つのステップで進みます。
- 平時(Normal times): それぞれの社会の形で、安定して暮らしています。
- ショック発生(Shock): 突然、画面に「安全地帯(円)」が現れます。この円の外にいる人は「危険な状態」になります。
- 復旧・救助(Recovery): 安全地帯がじわじわと広がり、外にいる人たちが円の中に逃げ込もうと移動します。
3. このモデルが示している「教訓」
このシミュレーションの面白いところは、「ひとつの場所にまとまっていることが、必ずしも安全とは限らない」という点を見せていることです。
特に「③ 完全に統合された社会」に注目してください。
- もし、全員が集まっている場所がたまたま「安全地帯」から外れてしまったら、その社会のほぼ全員(100%に近い人数)が一度に危機に陥ってしまいます。
- 一方で、「① 多様な社会」や「② 小グループの社会」では、場所が分散しているため、一部の人は円の外に出てしまいますが、最初から円の中にいて無事な人も必ず一定数存在します。
4. まとめ
グラフの右下に出るメッセージ「Outcome depends strongly on where the group is located(結果は、グループがどこにいるかに強く依存する)」が、このモデルの核心です。
- みんなで1か所に集まる(団結する)ことは、平時は効率が良いかもしれませんが、災害時には「全滅」のリスクを孕んでいます。
- バラバラに住む(分散する)ことは、誰かは困るかもしれませんが、社会全体が一度にダメになるリスクを避ける「リスク分散」の役割を果たしています。
一言でいうと、「リスク管理における『卵をひとつのカゴに盛るな』という教訓を、社会の形で表現したモデル」と言えます。
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
# ============================================================
# 0) Global settings
# ============================================================
SEED = 7
rng = np.random.default_rng(SEED)
N = 600
T = 220
L_SIM = 1.10 # reflecting wall (simulation)
L_VIEW = 1.35 # plotting range (bigger so circles are never clipped)
# storyboard times
t_form = 80
t_shock = 120
rescue_len = 50
t_end = min(T - 1, t_shock + rescue_len)
# Safe zone (circle): appears at shock, expands during recovery
C = np.array([0.25, 0.05], float)
R0 = 0.55
R1 = 0.90
# Rescue dynamics (capacity-limited pull toward center during recovery)
RESCUE_CAP_PER_STEP = 60
RESCUE_PULL = 0.18
# Multiple groups
K_GROUPS = 6
MIN_SEP = 0.60
# Fully unified society (single cluster) placed "unluckily" to show location risk
HOME_ONE = np.array([-0.70, 0.05], float)
# ============================================================
# 1) Helper: reflecting boundary
# ============================================================
def reflect_1d(pos, vel, L):
span = 2.0 * L
x = pos + L
n = np.floor(x / span).astype(np.int64)
r = x - n * span
odd = (n % 2) == 1
pos2 = np.where(odd, span - r, r) - L
vel2 = np.where(odd, -vel, vel)
return pos2, vel2
def reflect_square(p, v, L):
p[:, 0], v[:, 0] = reflect_1d(p[:, 0], v[:, 0], L)
p[:, 1], v[:, 1] = reflect_1d(p[:, 1], v[:, 1], L)
return p, v
def inside_circle(p, c, R):
return np.linalg.norm(p - c[None, :], axis=1) <= R
# ============================================================
# 2) Same people across societies
# ============================================================
p0 = rng.uniform(-0.95, 0.95, size=(N, 2))
trait = rng.beta(2.0, 2.0, size=N) # fixed diversity attribute (color only)
# ============================================================
# 3) Group hubs + fixed membership
# ============================================================
def sample_hubs(K, low=-0.85, high=0.85, min_sep=0.55, seed=0,
max_tries=60000, relax=0.97, min_sep_floor=0.20):
base_seed = int(seed)
sep = float(min_sep)
while sep >= min_sep_floor:
r = np.random.default_rng(base_seed)
hubs = []
tries = 0
while len(hubs) < K and tries < max_tries:
x = r.uniform(low, high, size=2)
if len(hubs) == 0:
hubs.append(x)
else:
d = np.linalg.norm(np.asarray(hubs) - x[None, :], axis=1)
if np.all(d >= sep):
hubs.append(x)
tries += 1
if len(hubs) == K:
return np.asarray(hubs, float)
sep *= relax
raise RuntimeError("Could not place hubs. Reduce K or widen the area.")
hubs = sample_hubs(K_GROUPS, min_sep=MIN_SEP, seed=SEED + 11)
d2 = ((p0[:, None, :] - hubs[None, :, :])**2).sum(axis=2)
z = d2.argmin(axis=1)
# refine hubs to initial centroids (natural)
for k in range(K_GROUPS):
m = (z == k)
if m.any():
hubs[k] = p0[m].mean(axis=0)
# ============================================================
# 4) Simulation
# ============================================================
def R_safe(t):
if t < t_shock:
return None
if t >= t_end:
return R1
s = (t - t_shock) / max(1, (t_end - t_shock))
return R0 + (R1 - R0) * s
def simulate(kind, seed=0):
"""
kind: 'diverse' | 'groups' | 'one'
"""
r = np.random.default_rng(seed)
p = p0.copy()
v = r.normal(0.0, 0.01, size=(N, 2))
P = np.zeros((T, N, 2), float)
# formation ramp
ramp_start = 10
ramp_len = 60
# parameters
if kind == "diverse":
noise = 0.003
anchor_k = 0.090
tau_group = 0.0
tau_one = 0.0
elif kind == "groups":
noise = 0.006
anchor_k = 0.025
tau_group = 0.055
tau_one = 0.0
elif kind == "one":
noise = 0.006
anchor_k = 0.000
tau_group = 0.0
tau_one = 0.085
else:
raise ValueError("kind must be 'diverse', 'groups', or 'one'")
OUTSIDE_NOISE_MULT = 1.7
OUTSIDE_DRAG = 0.12
vmax = 0.22
for t in range(T):
rr = 0.0 if t < ramp_start else min(1.0, (t - ramp_start) / ramp_len)
Rs = R_safe(t)
safe = np.zeros(N, dtype=bool) if Rs is None else inside_circle(p, C, Rs)
outside = ~safe if Rs is not None else np.zeros(N, dtype=bool)
eps = r.normal(0.0, noise, size=(N, 2))
if np.any(outside):
eps[outside] *= OUTSIDE_NOISE_MULT
anchor = anchor_k * (p0 - p)
group_force = np.zeros_like(p)
if kind == "groups":
group_force = rr * tau_group * (hubs[z] - p)
one_force = np.zeros_like(p)
if kind == "one":
one_force = rr * tau_one * (HOME_ONE[None, :] - p)
pull = np.zeros_like(p)
if (Rs is not None) and (t_shock <= t < t_end):
idx = np.where(~inside_circle(p, C, Rs))[0]
if idx.size > 0 and RESCUE_CAP_PER_STEP > 0:
r.shuffle(idx)
cand = idx[:min(RESCUE_CAP_PER_STEP, idx.size)]
pull[cand] = -RESCUE_PULL * (p[cand] - C[None, :])
v = 0.92 * v + eps + anchor + group_force + one_force + pull
if np.any(outside) and OUTSIDE_DRAG > 0:
v[outside] *= (1.0 - OUTSIDE_DRAG)
sp = np.linalg.norm(v, axis=1, keepdims=True)
v *= np.minimum(1.0, vmax / np.maximum(sp, 1e-12))
p = p + v
p, v = reflect_square(p, v, L_SIM)
P[t] = p
return P
P_div = simulate("diverse", seed=100)
P_grp = simulate("groups", seed=101)
P_one = simulate("one", seed=102)
# snapshots
snap = {
"normal": (P_div[t_form], P_grp[t_form], P_one[t_form], False, None),
"shock": (P_div[t_shock], P_grp[t_shock], P_one[t_shock], True, R0),
"recovery":(P_div[t_end], P_grp[t_end], P_one[t_end], True, R1),
}
# ============================================================
# 5) Plot: 3 rows x 4 columns (left labels + 3 societies)
# ============================================================
TAB = plt.get_cmap("tab10")
def style_ax(ax):
ax.set_aspect("equal")
ax.set_xlim(-L_VIEW, L_VIEW)
ax.set_ylim(-L_VIEW, L_VIEW)
ax.set_xticks([]); ax.set_yticks([])
for sp in ax.spines.values():
sp.set_visible(False)
def add_circle(ax, radius):
ax.add_patch(Circle(tuple(C), radius, fill=False, linewidth=2))
def outside_rate(p, radius):
return (~inside_circle(p, C, radius)).mean()
def draw_div(ax, p):
ax.scatter(p[:,0], p[:,1], s=18, marker="o", alpha=0.85,
c=trait, cmap="viridis", vmin=0, vmax=1, linewidths=0)
def draw_groups(ax, p):
for k in range(K_GROUPS):
m = (z == k)
if m.any():
ax.scatter(p[m,0], p[m,1], s=18, marker="o", alpha=0.85,
color=TAB(k), linewidths=0)
def draw_one(ax, p):
ax.scatter(p[:,0], p[:,1], s=18, marker="o", alpha=0.90,
color=TAB(1), linewidths=0)
# ---- Text (all English) ----
TITLE = "Storyboard: Formation → Shock (safe zone) → Recovery"
row_labels = [
"Normal times:\nshape of society",
"Shock:\ninside the circle is safe",
"Recovery:\nthe safe zone expands\nand people move inward",
]
# (改行つきで「重ならない」列タイトル)
col_titles = [
"Diverse society",
"Society with\nmultiple groups",
"Fully unified\nsociety",
]
# (長文は2行にして、ショック右列の下に固定)
MESSAGE = "Outcome depends strongly\non where the group is located"
# ---- Figure & Grid ----
fig = plt.figure(figsize=(14.6, 8.6))
gs = fig.add_gridspec(
3, 4,
width_ratios=[2.45, 3.10, 3.10, 3.10],
wspace=0.18,
hspace=0.36
)
# Left column: row labels (no clipping)
for i in range(3):
ax = fig.add_subplot(gs[i, 0])
ax.axis("off")
ax.text(0.02, 0.50, row_labels[i], ha="left", va="center",
fontsize=18, color="#777777")
# Panels
row_keys = ["normal", "shock", "recovery"]
axs = [[None]*3 for _ in range(3)]
ax_unified_shock = None
for i, key in enumerate(row_keys):
pd, pg, po, show_circle, rad = snap[key]
for j, p in enumerate([pd, pg, po]):
ax = fig.add_subplot(gs[i, j+1])
axs[i][j] = ax
style_ax(ax)
if j == 0:
draw_div(ax, p)
elif j == 1:
draw_groups(ax, p)
else:
draw_one(ax, p)
if show_circle:
add_circle(ax, rad)
out = outside_rate(p, rad)
ax.text(0.02, 0.02, f"Outside {out*100:.1f}%",
transform=ax.transAxes, ha="left", va="bottom",
fontsize=12, color="#888888",
bbox=dict(facecolor="white", edgecolor="none", alpha=0.65, pad=1.5))
if (key == "shock") and (j == 2):
ax_unified_shock = ax
# ---- Layout margins (reserve top area for titles) ----
fig.subplots_adjust(left=0.03, right=0.99, top=0.86, bottom=0.06)
# ---- Column titles placed with fig.text (so they NEVER overlap with suptitle) ----
# Use the 1st row axes positions
y_header = max(axs[0][0].get_position().y1,
axs[0][1].get_position().y1,
axs[0][2].get_position().y1) + 0.015
for j in range(3):
pos = axs[0][j].get_position()
x = pos.x0 + pos.width/2
fig.text(x, y_header, col_titles[j],
ha="center", va="bottom",
fontsize=18, color="#777777")
# ---- Suptitle higher (no overlap) ----
fig.suptitle(TITLE, fontsize=20, y=0.965, color="#777777")
# ---- Message under the unified-shock panel (figure coords, no overlap) ----
if ax_unified_shock is not None:
pos = ax_unified_shock.get_position()
x = pos.x0 + pos.width/2
y = pos.y0 - 0.035 # small gap below the shock panel
fig.text(x, y, MESSAGE, ha="center", va="top",
fontsize=15, color="#888888")
plt.show()
# Optional save:
# fig.savefig("storyboard_clean.png", dpi=300, bbox_inches="tight", pad_inches=0.02)
参考
Python3ではじめるシステムトレード【第2版】環境構築と売買戦略
