Skip to content

Commit 4748d93

Browse files
doesburg11claudecodex
committed
Predator sexual reproduction: analyze policies in the env they were trained in; add provisioning test
Bug: every rollout-based analysis script (energy sources, mate contingency, pair fitness, prey approach) built the plain base PredPreyGrass from a run's saved config, silently ignoring the prey floor and predator density target the run was trained with. Measured on SUCCESSONLY (uncapped): 2 of 3 base-env episodes collapsed to zero prey after ~110-140 steps, against 1000 steps with prey held at 20 (52-62 predators) in the trained env; on the density-target runs the population was no longer pinned at the target. Iterations 12-15's rollout-based energy-share tables and Iteration 14's coordination tests were therefore computed in a different ecology than training. Shared-state comparisons are unaffected (they query policies on one fixed base-env state bank shared by all runs; now documented as deliberate). Fix: new analysis_env.py picks the trained env class from run_config.json (density target -> FixedPredatorDensityEnv, prey floor -> FixedPreyDensityEnv, else base); InstrumentedEnv became an InstrumentedMixin so the same energy bookkeeping sits on any of them. The interactive checkpoint evaluator also uses the run's env now. All affected analyses are being re-run. New analyze_provisioning.py: sharper test of the "mate provisioning explains the female proximity association" hypothesis -- splits near-mate females by recent gift receipt and recomputes the near-vs-away difference within bands of the female's own energy (episode-cluster bootstrap). Codex review of the fix found no functional bug; it asked for the evaluator fix, the bank-env documentation, and tests that actually force a hunt, fruit pickup, mate gift and parental care through each generated class -- all added (88 tests pass). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Codex <codex@openai.com>
1 parent e3c976d commit 4748d93

9 files changed

Lines changed: 467 additions & 10 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Build the environment a saved run was TRAINED in, for rollout-based analysis.
3+
4+
Why this exists: `tune_ppo_fixed_prey_density.py` trains `FixedPreyDensityEnv` (prey floor) or, when
5+
`predator_density_target` is set, `FixedPredatorDensityEnv`; runs with a `predator_population_cap` are
6+
FixedPreyDensityEnv plus the cap (which lives in the base class). The rollout analysis scripts originally
7+
built the plain base `PredPreyGrass` from the saved config, which silently ignores the prey floor and the
8+
density target -- so for those runs the policies were evaluated in a different ecology than they were
9+
trained in (measured on SUCCESSONLY uncapped: 2 of 3 base-env episodes collapsed to zero prey after
10+
~110-140 steps, against 1000 steps with prey held at 20 in the trained env; on the density-target runs the
11+
population was no longer pinned at the target). Iterations 12-15's rollout-based energy-share tables and
12+
Iteration 14's coordination tests were computed that way and were re-run after this fix.
13+
14+
The env class is chosen from `config_env` in the run's `run_config.json`:
15+
predator_density_target set -> FixedPredatorDensityEnv
16+
prey_density_floor present -> FixedPreyDensityEnv (includes predator_population_cap runs)
17+
otherwise -> PredPreyGrass (all runs before Iteration 11)
18+
"""
19+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.fixed_predator_density_env import (
20+
FixedPredatorDensityEnv,
21+
)
22+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.fixed_prey_density_env import FixedPreyDensityEnv
23+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.predpreygrass_rllib_env import PredPreyGrass
24+
25+
26+
def env_class_for(config):
27+
if config.get("predator_density_target") is not None:
28+
return FixedPredatorDensityEnv
29+
if "prey_density_floor" in config:
30+
return FixedPreyDensityEnv
31+
return PredPreyGrass
32+
33+
34+
def make_env(config):
35+
return env_class_for(config)(config)

‎predpreygrass/non_evolutionary/predator_sexual_reproduction/analyze_energy_sources.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@
3434
module_probs,
3535
policy_of,
3636
)
37+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analysis_env import env_class_for
3738
from predpreygrass.non_evolutionary.predator_sexual_reproduction.predpreygrass_rllib_env import PredPreyGrass
3839

3940
SEXES = ("predator_male", "predator_female")
4041

4142

42-
class InstrumentedEnv(PredPreyGrass):
43-
"""Adds per-agent gross energy-by-source bookkeeping; does not change any dynamics."""
43+
class InstrumentedMixin:
44+
"""Adds per-agent gross energy-by-source bookkeeping; does not change any dynamics. A mixin so the same
45+
instrumentation can sit on whichever env class a run was trained in (see analysis_env.py)."""
4446

4547
def _init_energy_log(self):
4648
self.energy_from_prey = defaultdict(float)
@@ -99,8 +101,23 @@ def _share_energy_with_offspring(self, agent, energy_gained):
99101
return result
100102

101103

104+
class InstrumentedEnv(InstrumentedMixin, PredPreyGrass):
105+
"""Instrumented base env (kept for callers that want exactly the base env)."""
106+
107+
108+
_INSTRUMENTED_CLASSES = {}
109+
110+
111+
def make_instrumented_env(config):
112+
"""Instrumented version of the env class this run was trained in (see analysis_env.env_class_for)."""
113+
base = env_class_for(config)
114+
if base not in _INSTRUMENTED_CLASSES:
115+
_INSTRUMENTED_CLASSES[base] = type(f"Instrumented{base.__name__}", (InstrumentedMixin, base), {})
116+
return _INSTRUMENTED_CLASSES[base](config)
117+
118+
102119
def run_episodes(env_config, modules, n_episodes, seed0, random_policy=False):
103-
env = InstrumentedEnv(env_config)
120+
env = make_instrumented_env(env_config)
104121
rng = np.random.default_rng(seed0)
105122
records = [] # one dict per predator agent life
106123
for ep in range(n_episodes):

‎predpreygrass/non_evolutionary/predator_sexual_reproduction/analyze_mate_contingency.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
policy_of,
7070
record,
7171
)
72-
from predpreygrass.non_evolutionary.predator_sexual_reproduction.predpreygrass_rllib_env import PredPreyGrass
72+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analysis_env import make_env
7373

7474
SEXES = ("predator_male", "predator_female")
7575
TARGETS = ("prey", "fruit")
@@ -95,7 +95,7 @@ def mate_bucket(env, agent, near_radius):
9595

9696

9797
def run_episodes(env_config, modules, n_episodes, seed0, near_radius):
98-
env = PredPreyGrass(env_config)
98+
env = make_env(env_config)
9999
moves = np.array([env.action_to_move_tuple[a] for a in range(env.num_actions)])
100100
offset = (env.predator_obs_range - 1) // 2
101101
sample_rng = np.random.default_rng(seed0)

‎predpreygrass/non_evolutionary/predator_sexual_reproduction/analyze_pair_fitness.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
import torch
4747
from scipy.stats import spearmanr
4848

49-
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_energy_sources import InstrumentedEnv
49+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_energy_sources import make_instrumented_env
5050
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_prey_approach_from_checkpoint import (
5151
POLICY_IDS,
5252
load_modules,
@@ -65,7 +65,7 @@ def _prey_share(env, agent):
6565

6666

6767
def run_episodes(env_config, modules, n_episodes, seed0):
68-
env = InstrumentedEnv(env_config)
68+
env = make_instrumented_env(env_config)
6969
rng = np.random.default_rng(seed0)
7070
pairs = [] # one dict per (reproducing pair, episode) -- NOT one per episode
7171
for ep in range(n_episodes):

‎predpreygrass/non_evolutionary/predator_sexual_reproduction/analyze_prey_approach_from_checkpoint.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import numpy as np
3232
import torch
3333

34-
from predpreygrass.non_evolutionary.predator_sexual_reproduction.predpreygrass_rllib_env import PredPreyGrass
34+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analysis_env import make_env
3535

3636
POLICY_IDS = ("predator_male_policy", "predator_female_policy", "prey_policy")
3737
SEXES = ("predator_male", "predator_female")
@@ -99,7 +99,7 @@ def record(stats, geometry, probs):
9999

100100

101101
def run_episodes(env_config, modules, mode, n_episodes, seed0):
102-
env = PredPreyGrass(env_config)
102+
env = make_env(env_config)
103103
moves = np.array([env.action_to_move_tuple[a] for a in range(env.num_actions)])
104104
offset = (env.predator_obs_range - 1) // 2
105105
sample_rng = np.random.default_rng(seed0)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""
2+
Provisioning test for the female mate-proximity association (RESULTS.md, Iteration 14).
3+
4+
Hypothesis under test: a female approaches food less when her recorded mate is nearby because he can donate part
5+
of a successful hunt to her (`_apply_male_gift`, only to his recorded mate within predator_gift_range), so a
6+
nearby, recently-successful mate means she is being fed and forages less urgently. This is a first, sharper
7+
test than a bare correlation of per-life gifts with approach bias (which would also pick up survival duration,
8+
location, the mate's own success, and so on). Two contrasts, females only, in the env each run was trained in:
9+
10+
(1) Gift timing. Females with a living mate within --near-radius are split by whether they RECEIVED a gift
11+
within the last --gift-window steps ("gift_recent") or not ("near_nogift"). If provisioning drives the
12+
association, near_nogift should look like "away" (far + dead + abandoned) and gift_recent should carry
13+
the drop. Reported: near_nogift-away, gift_recent-away, gift_recent-near_nogift.
14+
(2) Energy adjustment. The female's own energy (visible to the policy) is a natural mediator: a fed female is
15+
richer. The near-minus-away approach-bias difference is recomputed WITHIN bands of her own energy and
16+
averaged with weights equal to each band's pooled decision count (direct standardization). If energy carries
17+
the association, the adjusted difference shrinks toward zero relative to the crude one.
18+
19+
All intervals are 95% episode-cluster bootstrap percentiles (whole episodes resampled; every statistic is
20+
recomputed inside each replicate). What this can and cannot show: it is still observational. A shrunken adjusted
21+
difference or a near_nogift group that resembles "away" would be evidence CONSISTENT with an energy/provisioning
22+
explanation; it would not exclude other explanations that also track energy or gift timing, and a surviving
23+
association after adjustment would show the explanation is incomplete, not that it is wrong. Gift receipt is
24+
measured from the change in every predator's energy around the gift call (InstrumentedMixin), so it is exact
25+
whatever the rules; energy bands are fixed edges, not fitted.
26+
27+
Example:
28+
python -m predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_provisioning \
29+
--run CONTROL_s42=~/simulation_results/ray_results/PPO_FIXED_PREY_DENSITY_CONTROL_SEED42 --episodes 30
30+
"""
31+
import argparse
32+
import glob
33+
import json
34+
import os
35+
36+
import numpy as np
37+
import torch
38+
39+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_energy_sources import make_instrumented_env
40+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_mate_contingency import mate_bucket
41+
from predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_prey_approach_from_checkpoint import (
42+
POLICY_IDS,
43+
action_geometry,
44+
load_modules,
45+
module_probs,
46+
new_stats,
47+
policy_of,
48+
record,
49+
)
50+
51+
TARGETS = ("prey", "fruit")
52+
GROUPS = ("gift_recent", "near_nogift", "far", "dead", "abandoned") # virgin females are excluded
53+
ENERGY_EDGES = (4.0, 8.0, 12.0) # bands: <4, 4-8, 8-12, >=12 (predator_creation_energy_threshold is 12)
54+
N_BANDS = len(ENERGY_EDGES) + 1
55+
56+
57+
def energy_band(e):
58+
return int(np.searchsorted(ENERGY_EDGES, e, side="right"))
59+
60+
61+
def run_episodes(env_config, modules, n_episodes, seed0, near_radius, gift_window):
62+
env = make_instrumented_env(env_config)
63+
moves = np.array([env.action_to_move_tuple[a] for a in range(env.num_actions)])
64+
offset = (env.predator_obs_range - 1) // 2
65+
sample_rng = np.random.default_rng(seed0)
66+
per_episode = []
67+
for ep in range(n_episodes):
68+
observations, _ = env.reset(seed=seed0 + ep)
69+
active = list(observations.keys())
70+
stats = {(t, g, b): new_stats() for t in TARGETS for g in GROUPS for b in range(N_BANDS)}
71+
energy_sum = {g: [0.0, 0] for g in GROUPS}
72+
last_gift_step = {}
73+
seen_gifts = {}
74+
step = 0
75+
while True:
76+
targets = {
77+
"prey": np.array(list(env.prey_positions.values()), dtype=int).reshape(-1, 2),
78+
"fruit": np.array(
79+
[p for f, p in env.fruit_positions.items() if env.fruit_energies[f] > 0], dtype=int
80+
).reshape(-1, 2),
81+
}
82+
actions = {}
83+
for pid in POLICY_IDS:
84+
agents = [a for a in active if policy_of(a) == pid]
85+
if not agents:
86+
continue
87+
rows = module_probs(modules[pid], [observations[a] for a in agents])
88+
for i, agent in enumerate(agents):
89+
probs = rows[i]
90+
if pid == "predator_female_policy":
91+
bucket = mate_bucket(env, agent, near_radius)
92+
if bucket == "near":
93+
since = step - last_gift_step.get(agent, -10**9)
94+
group = "gift_recent" if since <= gift_window else "near_nogift"
95+
elif bucket in ("far", "dead", "abandoned"):
96+
group = bucket
97+
else:
98+
group = None # virgin
99+
if group is not None:
100+
energy = env.agent_energies[agent]
101+
band = energy_band(energy)
102+
energy_sum[group][0] += energy
103+
energy_sum[group][1] += 1
104+
pos = np.array(env.agent_positions[agent], dtype=int)
105+
for t in TARGETS:
106+
geo = action_geometry(pos, targets[t], moves, env.grid_size, offset)
107+
if geo is not None:
108+
record(stats[(t, group, band)], geo, probs)
109+
actions[agent] = int(sample_rng.choice(env.num_actions, p=probs))
110+
observations, _, terminations, truncations, _ = env.step(actions)
111+
step += 1
112+
# Gift receipts observed during THIS step become visible to the NEXT decision.
113+
for a, total in list(env.gift_received.items()):
114+
if total > seen_gifts.get(a, 0.0) + 1e-12:
115+
last_gift_step[a] = step
116+
seen_gifts[a] = total
117+
active = [a for a in observations if not terminations.get(a, False) and not truncations.get(a, False)]
118+
if terminations.get("__all__") or truncations.get("__all__"):
119+
break
120+
per_episode.append((stats, energy_sum))
121+
return per_episode
122+
123+
124+
def _arrays(per_episode):
125+
"""num/den arrays of shape (episodes, groups, bands) per target."""
126+
E = len(per_episode)
127+
out = {}
128+
for t in TARGETS:
129+
num = np.zeros((E, len(GROUPS), N_BANDS))
130+
den = np.zeros((E, len(GROUPS), N_BANDS))
131+
for e, (stats, _) in enumerate(per_episode):
132+
for gi, g in enumerate(GROUPS):
133+
for b in range(N_BANDS):
134+
s = stats[(t, g, b)]
135+
num[e, gi, b], den[e, gi, b] = s["bias"], s["n"]
136+
out[t] = (num, den)
137+
return out
138+
139+
140+
GI = {g: i for i, g in enumerate(GROUPS)}
141+
NEAR = [GI["gift_recent"], GI["near_nogift"]]
142+
AWAY = [GI["far"], GI["dead"], GI["abandoned"]]
143+
144+
145+
def _ratio(num, den, groups):
146+
n = num[..., groups, :].sum(axis=-2).sum(axis=-1)
147+
d = den[..., groups, :].sum(axis=-2).sum(axis=-1)
148+
with np.errstate(invalid="ignore", divide="ignore"):
149+
return np.where(d > 0, n / d, np.nan)
150+
151+
152+
def _adjusted(num, den):
153+
"""Energy-band-standardized near-minus-away. Works on (..., groups, bands) totals."""
154+
nn, nd = num[..., NEAR, :].sum(axis=-2), den[..., NEAR, :].sum(axis=-2)
155+
an, ad = num[..., AWAY, :].sum(axis=-2), den[..., AWAY, :].sum(axis=-2)
156+
with np.errstate(invalid="ignore", divide="ignore"):
157+
diff = nn / nd - an / ad
158+
valid = (nd > 0) & (ad > 0)
159+
w = np.where(valid, nd + ad, 0.0)
160+
diff = np.where(valid, diff, 0.0)
161+
tot = w.sum(axis=-1)
162+
with np.errstate(invalid="ignore", divide="ignore"):
163+
return np.where(tot > 0, (w * diff).sum(axis=-1) / tot, np.nan)
164+
165+
166+
def _stats_from_totals(num, den):
167+
"""num/den shape (..., groups, bands) -> dict of named statistics, each shape (...)."""
168+
g = lambda name: [GI[name]]
169+
r = lambda groups: _ratio(num, den, groups)
170+
return {
171+
"crude near-away": r(NEAR) - r(AWAY),
172+
"energy-adjusted near-away": _adjusted(num, den),
173+
"near_nogift-away": r(g("near_nogift")) - r(AWAY),
174+
"gift_recent-away": r(g("gift_recent")) - r(AWAY),
175+
"gift_recent-near_nogift": r(g("gift_recent")) - r(g("near_nogift")),
176+
}
177+
178+
179+
def summarize(label, per_episode, rng, n_boot=2000):
180+
lines = []
181+
E = len(per_episode)
182+
en = {g: (sum(ep[1][g][0] for ep in per_episode), sum(ep[1][g][1] for ep in per_episode)) for g in GROUPS}
183+
lines.append(
184+
f"{label}: female decisions by group (n, mean own energy): "
185+
+ ", ".join(f"{g} n={n} e={(s / n if n else float('nan')):.1f}" for g, (s, n) in en.items())
186+
)
187+
arrs = _arrays(per_episode)
188+
for t in TARGETS:
189+
num, den = arrs[t]
190+
point = _stats_from_totals(num.sum(axis=0), den.sum(axis=0))
191+
idx = rng.integers(0, E, size=(n_boot, E))
192+
boot = _stats_from_totals(num[idx].sum(axis=1), den[idx].sum(axis=1))
193+
parts = []
194+
for name, val in point.items():
195+
b = boot[name][~np.isnan(boot[name])]
196+
if len(b) >= 100 and not np.isnan(val):
197+
lo, hi = np.percentile(b, [2.5, 97.5])
198+
parts.append(f"{name}={val:+.3f} [{lo:+.3f},{hi:+.3f}]")
199+
else:
200+
parts.append(f"{name}=n/a")
201+
lines.append(f" {t:5}: " + " | ".join(parts))
202+
return lines
203+
204+
205+
def main():
206+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
207+
parser.add_argument("--run", action="append", default=[], help="LABEL=path to a ray_results experiment dir")
208+
parser.add_argument("--checkpoint", type=int, default=29)
209+
parser.add_argument("--episodes", type=int, default=30)
210+
parser.add_argument("--seed", type=int, default=4000)
211+
parser.add_argument("--near-radius", type=int, default=3, help="also the gift range in this module's defaults")
212+
parser.add_argument("--gift-window", type=int, default=10, help="steps after a received gift counted as 'recent'")
213+
args = parser.parse_args()
214+
torch.set_num_threads(2)
215+
rng = np.random.default_rng(0)
216+
for spec in args.run:
217+
label, path = spec.split("=", 1)
218+
path = os.path.expanduser(path)
219+
config = json.load(open(os.path.join(path, "run_config.json")))["config_env"]
220+
trial = sorted(glob.glob(os.path.join(path, "PPO_*/")))[0]
221+
modules = load_modules(os.path.join(trial, f"checkpoint_{args.checkpoint:06d}"))
222+
per_episode = run_episodes(config, modules, args.episodes, args.seed, args.near_radius, args.gift_window)
223+
for line in summarize(f"{label} ckpt{args.checkpoint}", per_episode, rng):
224+
print(line, flush=True)
225+
print(flush=True)
226+
227+
228+
if __name__ == "__main__":
229+
main()

‎predpreygrass/non_evolutionary/predator_sexual_reproduction/analyze_shared_states.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
and the paired male-minus-female difference with an episode-level bootstrap interval (states of one episode are
1515
resampled together). Intervals cover the states of the bank for ONE checkpoint, not training-seed variation.
1616
17+
Environment note: the state bank is DELIBERATELY built on the plain base env (`PredPreyGrass`) from --bank-config's
18+
`config_env`, with a uniform-random policy, even for runs trained with a prey floor or a predator density target. The
19+
bank is meant to be one fixed, run-independent set of counterfactual states that every policy is queried on, not the
20+
on-policy ecology of any run; building it with each run's own env would give each run a different bank and change what
21+
is being compared. (The other analysis scripts, which roll policies out, use the env each run was trained in; see
22+
analysis_env.py.) All runs in one invocation share the bank, so their comparison is like-for-like.
23+
1724
Example:
1825
python -m predpreygrass.non_evolutionary.predator_sexual_reproduction.analyze_shared_states \
1926
--run K05_s42=~/simulation_results/ray_results/PPO_PREDATOR_SEXUAL_REPRODUCTION_PROP_K05_MB1024_SEED42 \

0 commit comments

Comments
 (0)