-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathevaluate.py
More file actions
1922 lines (1650 loc) · 81 KB
/
Copy pathevaluate.py
File metadata and controls
1922 lines (1650 loc) · 81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Unified evaluation harness for SafeCampus RL agents.
Evaluates all agents on sinusoidal risk (in-distribution, 30 seeds) or
on the real-world CSV risk trajectory (10 seeds, env stochasticity only).
Agents (5-agent consolidated pipeline): Myopic, Double DQN,
PPO Discrete, PPO Continuous, Upper Bound
Outputs (per eval mode, in evaluation_results_{sinusoidal,data}/):
summary.csv — mean ± std + bootstrap CI + monotonicity + X*/Y*/F
safety_optimal_thresholds.csv — X*, Y*, F (optimal-threshold safety)
safety_optimal_thresholds_perturbation.csv — X*/Y*/F under each perturbation level
fig1_reward_vs_omega.png — mean reward with bootstrap 95% CI bands
fig2_optimality_gap.png — % of upper bound (primary metric)
fig3_monotonicity.png — Spearman rho_mean by agent and omega
fig4_trajectory_omega*.png — week-by-week trajectory per omega
fig5_safety_optimal_thresholds.png — optimal-threshold safety table (incl. F)
fig_safety_frontier.png — X*, Y*, F vs omega
fig_safety_perturbation.png — F vs sensing-noise level (robustness)
policy_grids/ — per-agent and cross-agent policy plots
"""
import os
import json
import argparse
import colorsys
import warnings
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.colors import ListedColormap, LinearSegmentedColormap
from scipy.stats import spearmanr, binom
import torch
import torch.nn as nn
from config import (
TOTAL_STUDENTS, MAX_WEEKS, NUM_ACTION_LEVELS, OMEGA_VALUES,
GLOBAL_SEED, EVAL_SEEDS_SINUSOIDAL, EVAL_SEEDS_DATA,
REAL_DATA_FILE, FIXED_ALPHA, FIXED_BETA,
SAFETY_Z, SAFETY_PERTURBATION_LEVELS, TRAIN_RISK_TYPE
)
from campus_gym.envs import ClassroomGymEnv
from optimal_dp_policy import UpperBoundSolver
# ============================================================
# 0. CONFIGURATION
# ============================================================
POLICY_GRID_POINTS = 50
# Default eval mode (overridable via CLI)
EVAL_RISK_TYPE = 'sinusoidal' # primary evaluation; 'data' = real-world CSV
def _output_dir(risk_type):
return f"evaluation_results_{risk_type}"
def _get_eval_seeds(risk_type: str):
"""Return the correct seed list for the given risk type."""
if risk_type == 'sinusoidal':
return EVAL_SEEDS_SINUSOIDAL
elif risk_type == 'data':
return EVAL_SEEDS_DATA
else:
raise ValueError(f"Unknown risk_type: {risk_type!r}")
# Artifact directories and filenames per agent. `dir_base` is suffixed with
# `_{eval_risk_type}` at load time so sinusoidal and data runs are isolated
# (and match what each training script writes when run with --eval-risk-type).
# 5-agent consolidated pipeline (REVISION R1). Retired agents' result folders are
# kept on disk but are no longer loaded or plotted. "PPO Continuous" is the Beta
# policy from ppo_continuous_new.py (display name only; dir base unchanged).
ARTIFACTS = {
"Double DQN": {"dir_base": "online_ddqn_results_tuned", "fmt": "policy_ddqn_online_omega_{omega}.pth", "type": "dqn"},
"PPO Discrete": {"dir_base": "ppo_discrete_results_tuned", "fmt": "ppo_policy_optimal_omega_{omega}.pth", "type": "ppo_discrete"},
"PPO Continuous": {"dir_base": "ppo_continuous_new_results_tuned", "fmt": "ppo_policy_optimal_omega_{omega}.pth", "type": "ppo_continuous_beta"},
"Myopic": {"dir_base": None, "fmt": None, "type": "myopic"},
"Critical Capacity": {"dir_base": None, "fmt": None, "type": "critical_capacity"},
"Upper Bound": {"dir_base": None, "fmt": None, "type": "upper_bound"},
}
def _artifact_dir(agent_name, eval_risk_type):
"""Resolve the agent's per-risk-type artifact directory."""
base = ARTIFACTS[agent_name]["dir_base"]
if base is None:
return None
return f"{base}_{eval_risk_type}"
AGENT_ORDER = ["Myopic", "Double DQN", "PPO Discrete", "PPO Continuous",
"Critical Capacity", "Upper Bound"]
AGENT_COLORS = {
"Myopic": "#E41A1C",
"Double DQN": "#F781BF",
"PPO Discrete": "#377EB8",
"PPO Continuous": "#4DAF4A",
"Critical Capacity": "#FF7F00",
"Upper Bound": "#000000",
}
AGENT_MARKERS = {
"Myopic": "o", "Double DQN": "P",
"PPO Discrete": "^", "PPO Continuous": "*",
"Critical Capacity": "D", "Upper Bound": "h",
}
AGENT_LINESTYLES = {
"Myopic": "--", "Double DQN": "-.",
"PPO Discrete": "-", "PPO Continuous": "-",
"Critical Capacity": "--", "Upper Bound": "-",
}
device = torch.device("cpu")
plt.rcParams.update({
"font.size": 11,
"font.weight": "bold",
"axes.labelweight": "bold",
"axes.titleweight": "bold",
"lines.linewidth": 2.0,
"figure.dpi": 150,
})
# ============================================================
# 1. NETWORK DEFINITIONS
# ============================================================
class QNetwork(nn.Module):
"""Shared architecture for DQN, Online DQN, Double DQN."""
def __init__(self, input_dim=2, output_dim=3, hidden_dim=128):
super().__init__()
self.hidden_dim = hidden_dim
self.fc = nn.Sequential(
nn.Linear(input_dim, hidden_dim), nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
nn.Linear(hidden_dim, output_dim)
)
def forward(self, x):
return self.fc(x)
class ActorCriticDiscrete(nn.Module):
"""PPO-Discrete actor-critic (Softmax actor)."""
def __init__(self, state_dim=2, action_dim=3, hidden_dim=64):
super().__init__()
self.hidden_dim = hidden_dim
self.actor = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, action_dim), nn.Softmax(dim=-1)
)
self.critic = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, 1)
)
class ActorCriticContinuousBeta(nn.Module):
"""PPO-Continuous actor-critic with Beta distribution (ppo_continuous_new.py)."""
def __init__(self, state_dim=2, action_dim=1, hidden_dim=64):
super().__init__()
self.hidden_dim = hidden_dim
self.shared = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim), nn.Tanh(),
)
self.mean_head = nn.Linear(hidden_dim, action_dim)
self.conc_head = nn.Linear(hidden_dim, action_dim)
self.critic = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, 1)
)
def get_mean_action(self, state):
h = self.shared(state)
mu = torch.clamp(torch.sigmoid(self.mean_head(h)), 1e-3, 1 - 1e-3)
return mu
class ActorCriticContinuousNormal(nn.Module):
"""PPO-Continuous actor-critic with Normal distribution (ppo_continuous_actions.py).
The actor's final layer is Tanh in [-1, 1]; the mean action is shifted to [0, 1]
by `(mu_raw + 1) / 2` (matching the training-time `act` method)."""
def __init__(self, state_dim=2, action_dim=1, hidden_dim=64, init_std=0.5):
super().__init__()
self.hidden_dim = hidden_dim
self.log_std = nn.Parameter(torch.ones(1, action_dim) * float(np.log(init_std)))
self.actor = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, action_dim), nn.Tanh(),
)
self.critic = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim), nn.Tanh(),
nn.Linear(hidden_dim, 1)
)
def get_mean_action(self, state):
mu_raw = self.actor(state)
return (mu_raw + 1.0) / 2.0
# ============================================================
# 2. AGENT LOADING
# ============================================================
def _load_hidden_dim(artifact_dir, omega, default=128):
"""Read tuned hidden_dim from saved JSON, else use default."""
for fname in ("optimized_lrs.json", "optimized_lrs_online.json",
"optimized_lrs_ddqn_online.json"):
path = os.path.join(artifact_dir, fname)
if os.path.exists(path):
with open(path) as f:
data = json.load(f)
entry = data.get(str(omega)) or data.get(f"{omega:.1f}")
if entry and isinstance(entry, dict):
return int(entry.get("hidden_dim", default))
return default
def load_agent_policy(agent_name, omega, eval_risk_type):
"""
Load a saved agent artifact (from the eval-risk-type-specific dir) and
return a unified policy callable:
capacity = policy(infected: float, risk: float, week: int) -> float
capacity is in [0, TOTAL_STUDENTS].
Returns None if the artifact file is not found.
Returns "UPPER_BOUND_SENTINEL" for the (clairvoyant) Upper Bound agent.
"""
info = ARTIFACTS[agent_name]
agent_type = info["type"]
# ---- Myopic: greedy one-step lookahead ----
if agent_type == "myopic":
capacity_options = [0, 50, 100]
def myopic_policy(infected, risk, week, omega=omega):
best_cap, best_val = 0, -float("inf")
for cap in capacity_options:
pred = FIXED_ALPHA * infected * cap + FIXED_BETA * risk * cap ** 2
pred = min(pred, TOTAL_STUDENTS)
val = omega * cap - (1 - omega) * pred
if val > best_val:
best_val, best_cap = val, cap
return float(best_cap)
return myopic_policy
# ---- Critical Capacity: analytical DFE-enforcing threshold policy ----
# Sets u(t)=clip(u*(t),0,N) with u*(t) the positive root of R0=1, observing
# only c_risk(t). Independent of omega and of I(t); guarantees R0<1 every week.
if agent_type == "critical_capacity":
def cc_policy(infected, risk, week,
alpha=FIXED_ALPHA, beta=FIXED_BETA, N=TOTAL_STUDENTS):
if risk <= 1e-9:
return float(N) # no community transmission -> full room is DFE
disc = alpha ** 2 + 4.0 * beta * risk
u_star = (-alpha + np.sqrt(disc)) / (2.0 * beta * risk)
return float(np.clip(u_star, 0.0, N))
return cc_policy
# ---- Upper Bound: clairvoyant DP, built per (omega, seed) at eval time ----
if agent_type == "upper_bound":
return "UPPER_BOUND_SENTINEL"
# ---- File-based agents ----
artifact_dir = _artifact_dir(agent_name, eval_risk_type)
artifact_path = os.path.join(artifact_dir, info["fmt"].format(omega=omega))
if not os.path.exists(artifact_path):
warnings.warn(f"Artifact not found: {artifact_path}")
return None
# ---- Tabular Q ----
if agent_type == "q_table":
Q = np.load(artifact_path)
num_levels = Q.shape[0]
capacity_options = [0, 50, 100]
def q_policy(infected, risk, week,
Q=Q, nl=num_levels, cap=capacity_options):
i_idx = int(np.clip(infected / TOTAL_STUDENTS * (nl - 1), 0, nl - 1))
r_idx = int(np.clip(risk * (nl - 1), 0, nl - 1))
return float(cap[int(np.argmax(Q[i_idx, r_idx]))])
return q_policy
# ---- DQN variants ----
if agent_type == "dqn":
hidden_dim = _load_hidden_dim(artifact_dir, omega)
net = QNetwork(input_dim=2, output_dim=NUM_ACTION_LEVELS, hidden_dim=hidden_dim)
net.load_state_dict(torch.load(artifact_path, map_location=device))
net.eval()
cap = [0, 50, 100]
s_buf = torch.zeros(1, 2)
def dqn_policy(infected, risk, week, net=net, s_buf=s_buf, cap=cap):
s_buf[0, 0] = infected / TOTAL_STUDENTS
s_buf[0, 1] = risk
with torch.no_grad():
return float(cap[int(net(s_buf).argmax().item())])
return dqn_policy
# ---- PPO Discrete ----
if agent_type == "ppo_discrete":
hidden_dim = _load_hidden_dim(artifact_dir, omega, default=64)
ac = ActorCriticDiscrete(state_dim=2, action_dim=NUM_ACTION_LEVELS, hidden_dim=hidden_dim)
ac.load_state_dict(torch.load(artifact_path, map_location=device))
ac.eval()
cap = [0, 50, 100]
s_buf = torch.zeros(1, 2)
def ppo_d_policy(infected, risk, week, ac=ac, s_buf=s_buf, cap=cap):
s_buf[0, 0] = infected / TOTAL_STUDENTS
s_buf[0, 1] = risk
with torch.no_grad():
return float(cap[int(torch.argmax(ac.actor(s_buf)).item())])
return ppo_d_policy
# ---- PPO Continuous (Beta) — ppo_continuous_new.py architecture ----
if agent_type == "ppo_continuous_beta":
hidden_dim = _load_hidden_dim(artifact_dir, omega, default=64)
ac = ActorCriticContinuousBeta(state_dim=2, action_dim=1, hidden_dim=hidden_dim)
ac.load_state_dict(torch.load(artifact_path, map_location=device))
ac.eval()
s_buf = torch.zeros(1, 2)
def ppo_c_beta_policy(infected, risk, week, ac=ac, s_buf=s_buf):
s_buf[0, 0] = infected / TOTAL_STUDENTS
s_buf[0, 1] = risk
with torch.no_grad():
mu = float(ac.get_mean_action(s_buf).item())
return mu * TOTAL_STUDENTS
return ppo_c_beta_policy
# ---- PPO Continuous (Normal) — ppo_continuous_actions.py architecture ----
if agent_type == "ppo_continuous_normal":
hidden_dim = _load_hidden_dim(artifact_dir, omega, default=64)
ac = ActorCriticContinuousNormal(state_dim=2, action_dim=1, hidden_dim=hidden_dim)
ac.load_state_dict(torch.load(artifact_path, map_location=device))
ac.eval()
s_buf = torch.zeros(1, 2)
def ppo_c_normal_policy(infected, risk, week, ac=ac, s_buf=s_buf):
s_buf[0, 0] = infected / TOTAL_STUDENTS
s_buf[0, 1] = risk
with torch.no_grad():
mu = float(ac.get_mean_action(s_buf).item())
return mu * TOTAL_STUDENTS
return ppo_c_normal_policy
raise ValueError(f"Unknown agent type: {agent_type}")
# ============================================================
# 3. ENVIRONMENT FACTORY
# ============================================================
def make_eval_env(omega, seed, risk_type, perturbation_level=None):
"""Build a continuous-action eval env for the given risk type.
perturbation_level: None or "none" -> deterministic eval; one of
"low"/"moderate"/"high"/"severe" -> eval-perturbed mode, which injects
Gaussian sensing noise into the observed infection count (the true SIR
value is preserved in info["unperturbed_infected"])."""
perturbed = perturbation_level not in (None, "none")
mode = "eval-perturbed" if perturbed else "eval"
common = dict(
mode=mode,
use_discrete_state=False,
use_continuous_actions=True,
num_action_levels=NUM_ACTION_LEVELS,
total_students=TOTAL_STUDENTS,
max_weeks=MAX_WEEKS,
omega=omega,
seed=seed,
)
if perturbed:
common["perturbation_level"] = perturbation_level
if risk_type == 'data':
return ClassroomGymEnv(community_risk_data_file=REAL_DATA_FILE, **common)
else: # sinusoidal
return ClassroomGymEnv(eval_risk_type="sinusoidal", **common)
# ============================================================
# 4. ROLLOUT
# ============================================================
def rollout(policy_fn, omega, seed, risk_type, upper_bound_solver=None,
perturbation_level=None):
"""
Run one episode and return a metrics dict.
All policies return a capacity in [0, TOTAL_STUDENTS].
Under perturbation, the agent observes a noisy infection count, but the
metrics dict records BOTH the observed series ("infected") and the true
SIR series ("infected_true"). Safety guarantees are measured on the true
series; clean (perturbation=none) eval has the two series identical.
"""
env = make_eval_env(omega, seed, risk_type, perturbation_level=perturbation_level)
env.omega = omega
raw_state, _ = env.reset(seed=seed)
infected_list, infected_true_list = [], []
allowed_list, risk_list, reward_list = [], [], []
weeks = []
done = False
while not done:
infected = float(raw_state[0])
risk = float(raw_state[1])
week = int(env.current_week)
if upper_bound_solver is not None:
capacity = float(upper_bound_solver.get_optimal_capacity(week, infected, risk))
else:
capacity = float(policy_fn(infected, risk, week))
capacity = float(np.clip(capacity, 0.0, TOTAL_STUDENTS))
action = np.array([capacity / TOTAL_STUDENTS], dtype=np.float32)
next_raw, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated
weeks.append(week)
infected_list.append(info["infected_students"])
# True (unperturbed) outbreak size for safety; equals observed when clean.
infected_true_list.append(info.get("unperturbed_infected", info["infected_students"]))
allowed_list.append(info["allowed_students"])
risk_list.append(info["community_risk"])
reward_list.append(reward)
raw_state = next_raw
return {
"total_reward": sum(reward_list),
"peak_infected": max(infected_list),
"cumulative_infected": sum(infected_list),
"mean_infected": float(np.mean(infected_list)),
"mean_attendance": float(np.mean(allowed_list)),
"attendance_fraction": float(np.mean(allowed_list)) / TOTAL_STUDENTS,
"weeks": weeks,
"infected": infected_list,
"infected_true": infected_true_list,
"allowed": allowed_list,
"risk": risk_list,
"rewards": reward_list,
}
# ============================================================
# 5. UPPER BOUND SOLVER
# ============================================================
# ── DP discretisation ────────────────────────────────────────
# Granularity of the (I, r) grid used by UpperBoundSolver. Defaults match the
# env's natural resolution: infected counts are integers in {0,...,N}, so
# n_infected_bins=N+1=101 makes every count its own bin (zero infected-axis
# discretisation error); risk is continuous in [0,1], 101 bins -> snap error
# <=0.005. _optimize_capacity is vectorised so the finer grid is cheap.
UB_INFECTED_BINS = 101
UB_RISK_BINS = 101
def build_upper_bound_solver(omega, seed, risk_type,
n_infected_bins=UB_INFECTED_BINS,
n_risk_bins=UB_RISK_BINS):
"""Build and solve the DP upper bound for the given risk type / seed.
n_infected_bins, n_risk_bins control the (I, r) discretisation. Bumping
them yields a tighter ceiling at O(n_inf * n_risk * T) extra DP cells; the
inner capacity search is vectorised over candidates so the marginal cost
is small."""
env = make_eval_env(omega, seed, risk_type)
env.reset(seed=seed)
solver = UpperBoundSolver(env,
n_infected_bins=n_infected_bins,
n_risk_bins=n_risk_bins)
solver.solve(verbose=False)
return solver
# ============================================================
# 6. MONOTONICITY METRIC
# ============================================================
def compute_monotonicity(policy_fn, n_grid=40):
"""
Compute Spearman rank correlation between state variables and policy action.
Negative rho = policy reduces attendance as risk/infections increase (monotonic).
"""
infected_vals = np.linspace(0, TOTAL_STUDENTS, n_grid)
risk_vals = np.linspace(0, 1.0, n_grid)
I_flat, R_flat, A_flat = [], [], []
for inf in infected_vals:
for crisk in risk_vals:
action = policy_fn(inf, crisk)
I_flat.append(inf)
R_flat.append(crisk)
A_flat.append(action)
rho_I, _ = spearmanr(I_flat, A_flat)
rho_r, _ = spearmanr(R_flat, A_flat)
if np.isnan(rho_I):
rho_I = 0.0
if np.isnan(rho_r):
rho_r = 0.0
return {
"rho_infected": round(float(rho_I), 4),
"rho_risk": round(float(rho_r), 4),
"rho_mean": round(float((rho_I + rho_r) / 2), 4),
}
def _make_monotonicity_policy_fn(agent_name, policy, ub_solver_for_mono):
"""
Adapt a (capacity = policy(infected, risk, week)) callable to a
(action = fn(infected, risk)) callable suitable for monotonicity.
For Upper Bound, use the representative seed's solver and evaluate at t=0.
"""
if agent_name == "Upper Bound":
if ub_solver_for_mono is None:
return None
def fn(infected, risk):
return float(ub_solver_for_mono.get_optimal_capacity(0, infected, risk))
return fn
def fn(infected, risk):
return float(policy(infected, risk, 0))
return fn
# ============================================================
# 7. SAFETY EVALUATION (Optimal Threshold Search)
# ============================================================
def optimal_threshold_search(I, U, z):
"""
Binary-search the optimal infection threshold X* and attendance threshold Y*
for pooled per-week infection counts I and attendance counts U at safety
percentage z (a probabilistic bound: each constraint must hold ≥ z% of weeks):
X* = smallest integer x with (% of weeks I_t > x) <= (100 - z)
Y* = largest integer y with (% of weeks u_t >= y) >= z
X* is the tightest infection ceiling the policy respects; Y* the highest
attendance floor it delivers. Equivalent to the (1 - z/100) upper / (z/100)
lower empirical quantiles of the pooled trajectories.
"""
I = np.asarray(I, dtype=np.int64)
U = np.asarray(U, dtype=np.int64)
if I.size == 0 or U.size == 0:
return 0, 0
x_l, x_h = 0, int(I.max())
x_star = x_h
while x_l <= x_h:
x_m = (x_l + x_h) // 2
if 100.0 * np.mean(I > x_m) <= (100 - z):
x_star = x_m
x_h = x_m - 1
else:
x_l = x_m + 1
y_l, y_h = 0, int(U.max())
y_star = y_l
while y_l <= y_h:
y_m = (y_l + y_h) // 2
if 100.0 * np.mean(U >= y_m) >= z:
y_star = y_m
y_l = y_m + 1
else:
y_h = y_m - 1
return int(x_star), int(y_star)
def safety_from_trajectories(infected_pool, allowed_pool, omega, z=SAFETY_Z):
"""Compute the optimal-threshold safety record from pooled per-week series.
`infected_pool` should be the TRUE (unperturbed) infection counts."""
x_star, y_star = optimal_threshold_search(infected_pool, allowed_pool, z)
I_arr = np.asarray(infected_pool)
U_arr = np.asarray(allowed_pool)
pct_I_exceed = 100.0 * float(np.mean(I_arr > x_star)) if I_arr.size else 0.0
pct_A_atleast = 100.0 * float(np.mean(U_arr >= y_star)) if U_arr.size else 0.0
return {
"optimal_x": int(x_star),
"optimal_y": int(y_star),
"pct_I_gt_x": round(pct_I_exceed, 2),
"safety_I": bool(pct_I_exceed <= (100 - z)),
"pct_A_ge_y": round(pct_A_atleast, 2),
"safety_A": bool(pct_A_atleast >= z),
"F_score": round(omega * y_star - (1.0 - omega) * x_star, 4),
}
# ============================================================
# 8. SUMMARY HELPERS
# ============================================================
def _mean_std(vals, ndigits=4):
"""Mean and sample std (ddof=1) across seeds. std is NaN for n<2 (e.g. the
single-trajectory data eval), matching the reward summary convention."""
a = np.asarray(vals, dtype=float)
mu = round(float(a.mean()), ndigits)
sd = round(float(a.std(ddof=1)), ndigits) if a.size > 1 else float("nan")
return mu, sd
def bootstrap_ci(rewards, n_boot=10_000, alpha=0.05):
"""
Percentile bootstrap 95% CI for the mean. Returns (lower, upper).
Recommended by Patterson et al. (2024) as the default CI method for RL.
Deterministic (fixed rng seed) for reproducibility.
"""
arr = np.asarray(rewards, dtype=float)
if arr.size < 2:
return float("nan"), float("nan")
rng = np.random.default_rng(seed=0)
idx = rng.integers(0, arr.size, size=(n_boot, arr.size))
boot_means = arr[idx].mean(axis=1)
lo = float(np.percentile(boot_means, 100 * alpha / 2))
hi = float(np.percentile(boot_means, 100 * (1 - alpha / 2)))
return lo, hi
def summarise_rewards(rewards):
"""Return mean/std + analytic (Wald) and bootstrap 95% CIs from per-seed rewards.
The analytic CI (mean ± 1.96·s/√n) goes in the summary CSV so values are
deterministic and reproducible; the percentile bootstrap CI is reported in
figures (R5a). A single trajectory (n < 2, e.g. the deterministic real-data
eval) has no sampling distribution, so all spread metrics are NaN.
"""
n = len(rewards)
mu = float(np.mean(rewards))
if n < 2:
return {
"n_seeds": n,
"mean_reward": round(mu, 4),
"std_reward": float("nan"),
"ci95_lower": float("nan"),
"ci95_upper": float("nan"),
"ci95_bootstrap_lower": float("nan"),
"ci95_bootstrap_upper": float("nan"),
}
sigma = float(np.std(rewards, ddof=1))
se = sigma / np.sqrt(n)
boot_lo, boot_hi = bootstrap_ci(rewards)
return {
"n_seeds": n,
"mean_reward": round(mu, 4),
"std_reward": round(sigma, 4),
"ci95_lower": round(mu - 1.96 * se, 4),
"ci95_upper": round(mu + 1.96 * se, 4),
"ci95_bootstrap_lower": round(boot_lo, 4),
"ci95_bootstrap_upper": round(boot_hi, 4),
}
# ============================================================
# 9. MAIN EVALUATION LOOP
# ============================================================
def run_all(risk_type, output_dir):
"""
Evaluate all agents for all omega values under the given risk type.
Sinusoidal: multiple seeds (each draws an independent risk wave) -> mean ± std
and a 95% CI across seeds.
Real data: a single deterministic trajectory (fixed CSV risk, fixed initial
infected, deterministic dynamics). The seed is inert here, so we
run exactly one rollout and report the reward without std/CI.
"""
eval_seeds = _get_eval_seeds(risk_type)
if risk_type == 'data':
eval_seeds = eval_seeds[:1] # single deterministic trajectory; seed is inert
risk_label = f"real CSV ({REAL_DATA_FILE}) — single deterministic trajectory"
else:
risk_label = f"sinusoidal x{len(eval_seeds)} seeds"
print(f"Evaluation mode : {risk_label}")
# Build UB solvers — one per (omega, seed) combination
print("Building Upper Bound solvers...")
ub_solvers = {}
for omega in OMEGA_VALUES:
ub_solvers[omega] = {}
for seed in eval_seeds:
ub_solvers[omega][seed] = build_upper_bound_solver(omega, seed, risk_type)
print(f" UB solved: omega={omega} ({len(eval_seeds)} seed(s))")
# Per-omega representative UB solver for monotonicity / policy grids
rep_seed = eval_seeds[0]
records = []
safety_records = []
# Paired per-seed rewards for difference curves / tolerance intervals (R5b/R5c).
# Keyed per_seed_rewards[omega][agent] = {seed: total_reward} so agent–baseline
# pairs share the same seed index when resampling.
per_seed_rewards = {omega: {} for omega in OMEGA_VALUES}
print("\nEvaluating agents...")
for omega in OMEGA_VALUES:
print(f"\n=== omega={omega} ===")
for agent_name in AGENT_ORDER:
policy = load_agent_policy(agent_name, omega, risk_type)
if policy is None:
print(f" SKIP {agent_name} (artifact missing)")
continue
is_ub = (policy == "UPPER_BOUND_SENTINEL")
seed_results = []
seed_reward_map = {}
for seed in eval_seeds:
try:
m = rollout(
policy_fn=None if is_ub else policy,
omega=omega,
seed=seed,
risk_type=risk_type,
upper_bound_solver=ub_solvers[omega][seed] if is_ub else None,
)
seed_results.append(m)
seed_reward_map[seed] = m["total_reward"]
except Exception as e:
warnings.warn(f"Rollout failed {agent_name}/omega={omega}/seed={seed}: {e}")
if not seed_results:
continue
rewards_per_seed = [r["total_reward"] for r in seed_results]
per_seed_rewards[omega][agent_name] = seed_reward_map
stats = summarise_rewards(rewards_per_seed)
# Optimal-threshold safety: pool TRUE per-week infection / attendance
# across all seeds (clean eval => infected_true == observed).
# Critical Capacity is EXCLUDED from the empirical safety table: it
# enforces R0<1 by construction, so its thresholds are analytical, not
# reward-derived. Its summary safety columns are left as NaN.
is_cc = (ARTIFACTS[agent_name]["type"] == "critical_capacity")
if is_cc:
safety = {"optimal_x": float("nan"), "optimal_y": float("nan"),
"F_score": float("nan")}
else:
infected_pool = [v for r in seed_results for v in r["infected_true"]]
allowed_pool = [v for r in seed_results for v in r["allowed"]]
safety = safety_from_trajectories(infected_pool, allowed_pool, omega)
# Monotonicity: use representative UB solver for Upper Bound
mono_policy = _make_monotonicity_policy_fn(
agent_name, policy,
ub_solver_for_mono=ub_solvers[omega][rep_seed] if is_ub else None,
)
mono = compute_monotonicity(mono_policy) if mono_policy is not None \
else {"rho_infected": 0.0, "rho_risk": 0.0, "rho_mean": 0.0}
records.append({
"agent": agent_name,
"omega": omega,
"n_seeds": stats["n_seeds"],
"mean_reward": stats["mean_reward"],
"std_reward": stats["std_reward"],
"ci95_lower": stats["ci95_lower"],
"ci95_upper": stats["ci95_upper"],
"ci95_bootstrap_lower": stats["ci95_bootstrap_lower"],
"ci95_bootstrap_upper": stats["ci95_bootstrap_upper"],
"mean_peak_infected": _mean_std([r["peak_infected"] for r in seed_results])[0],
"std_peak_infected": _mean_std([r["peak_infected"] for r in seed_results])[1],
"mean_cumulative_infected": _mean_std([r["cumulative_infected"] for r in seed_results])[0],
"std_cumulative_infected": _mean_std([r["cumulative_infected"] for r in seed_results])[1],
"mean_infected": _mean_std([r["mean_infected"] for r in seed_results])[0],
"std_infected": _mean_std([r["mean_infected"] for r in seed_results])[1],
"mean_attendance": _mean_std([r["mean_attendance"] for r in seed_results])[0],
"std_attendance": _mean_std([r["mean_attendance"] for r in seed_results])[1],
"attendance_fraction": _mean_std([r["attendance_fraction"] for r in seed_results])[0],
"std_attendance_fraction": _mean_std([r["attendance_fraction"] for r in seed_results])[1],
"rho_infected": mono["rho_infected"],
"rho_risk": mono["rho_risk"],
"rho_mean": mono["rho_mean"],
"optimal_x": safety["optimal_x"],
"optimal_y": safety["optimal_y"],
"safety_F": safety["F_score"],
})
if not is_cc: # CC excluded from the empirical safety table
safety_records.append({
"agent": agent_name,
"omega": omega,
"z": SAFETY_Z,
"optimal_x": safety["optimal_x"],
"optimal_y": safety["optimal_y"],
"pct_I_gt_x": safety["pct_I_gt_x"],
"safety_I": safety["safety_I"],
"pct_A_ge_y": safety["pct_A_ge_y"],
"safety_A": safety["safety_A"],
"F_score": safety["F_score"],
})
if np.isnan(stats["std_reward"]):
reward_str = f"R={stats['mean_reward']:.2f} (single trajectory)"
else:
reward_str = (f"R={stats['mean_reward']:.2f}±{stats['std_reward']:.2f} "
f"[{stats['ci95_lower']:.2f},{stats['ci95_upper']:.2f}]")
print(f" {agent_name:22s} | {reward_str} | "
f"peak_I={records[-1]['mean_peak_infected']:.0f} | "
f"rho_mean={mono['rho_mean']:+.2f}")
df = pd.DataFrame(records)
# pct_of_upper_bound
ub_row = df[df["agent"] == "Upper Bound"].set_index("omega")["mean_reward"]
def pct_ub(row):
ub = ub_row.get(row["omega"])
if ub is not None and ub != 0:
return round(100.0 * row["mean_reward"] / ub, 2)
return np.nan
df["pct_of_upper_bound"] = df.apply(pct_ub, axis=1)
# Reorder summary CSV columns (R6 schema + optimal-threshold safety)
columns = [
"agent", "omega", "n_seeds",
"mean_reward", "std_reward",
"ci95_lower", "ci95_upper",
"ci95_bootstrap_lower", "ci95_bootstrap_upper",
"mean_peak_infected", "std_peak_infected",
"mean_cumulative_infected", "std_cumulative_infected",
"mean_infected", "std_infected",
"mean_attendance", "std_attendance",
"attendance_fraction", "std_attendance_fraction",
"pct_of_upper_bound",
"rho_infected", "rho_risk", "rho_mean",
"optimal_x", "optimal_y", "safety_F",
]
df = df[columns]
df.to_csv(os.path.join(output_dir, "summary.csv"), index=False)
print(f"\nSummary saved to {output_dir}/summary.csv")
df_safety = pd.DataFrame(safety_records)
df_safety.to_csv(os.path.join(output_dir, "safety_optimal_thresholds.csv"), index=False)
print(f"Safety (optimal thresholds) saved to {output_dir}/safety_optimal_thresholds.csv")
return df, df_safety, ub_solvers, per_seed_rewards
# ============================================================
# 10. FIGURE 2 — Optimality gap (primary metric)
# ============================================================
def plot_fig2_optimality_gap(df, output_dir):
"""Bar/line chart: % of Upper Bound per agent, per omega."""
print("Generating Figure 2: optimality gap (pct_of_upper_bound)...")
agents = [a for a in AGENT_ORDER if a in df["agent"].values and a != "Upper Bound"]
omegas = sorted(df["omega"].unique())
fig, ax = plt.subplots(figsize=(10, 6))
for ag in agents:
sub = df[df["agent"] == ag].sort_values("omega")
if sub.empty:
continue
ax.plot(sub["omega"], sub["pct_of_upper_bound"],
color=AGENT_COLORS.get(ag, "gray"),
marker=AGENT_MARKERS.get(ag, "o"),
linestyle=AGENT_LINESTYLES.get(ag, "-"),
label=ag, linewidth=2, markersize=8)
ax.axhline(100.0, color="black", linestyle="--", linewidth=1.5, alpha=0.7,
label="Upper Bound (100%)")
ax.set_xlabel("ω (attendance weight)")
ax.set_ylabel("% of Upper Bound reward")
ax.set_title("Optimality Gap — % of Upper Bound reward")
ax.grid(True, linestyle="--", alpha=0.4)
ax.legend(loc="best", fontsize=9, ncol=2, frameon=True)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "fig2_optimality_gap.png"),
dpi=300, bbox_inches="tight")
plt.close()
# ============================================================
# 11. FIGURE 1 — Reward vs omega with 95% CI bands
# ============================================================
def plot_fig1_reward_vs_omega(df, output_dir):
"""Mean reward per omega with shaded 95% CI bands per agent.
CI bands are drawn only when available (sinusoidal multi-seed eval);
the deterministic real-data eval has no CI, so only the reward line is shown."""
print("Generating Figure 1: reward vs omega with 95% CI bands...")
agents = [a for a in AGENT_ORDER if a in df["agent"].values]
has_ci = False
fig, ax = plt.subplots(figsize=(10, 6))
for ag in agents:
sub = df[df["agent"] == ag].sort_values("omega")
if sub.empty:
continue
omegas = sub["omega"].values
mean = sub["mean_reward"].values
ci_lo = sub["ci95_bootstrap_lower"].values
ci_hi = sub["ci95_bootstrap_upper"].values
color = AGENT_COLORS.get(ag, "gray")
ax.plot(omegas, mean,
color=color,
marker=AGENT_MARKERS.get(ag, "o"),
linestyle=AGENT_LINESTYLES.get(ag, "-"),
label=ag, linewidth=2, markersize=7)
if not np.all(np.isnan(ci_lo)):
ax.fill_between(omegas, ci_lo, ci_hi, color=color, alpha=0.2)
has_ci = True
ax.set_xlabel("ω (attendance weight)")
ax.set_ylabel("Mean total reward")
ax.set_title("Mean Reward vs ω (shaded = bootstrap 95% CI)" if has_ci
else "Reward vs ω (single deterministic trajectory)")
ax.grid(True, linestyle="--", alpha=0.4)
ax.legend(loc="best", fontsize=9, ncol=2, frameon=True)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "fig1_reward_vs_omega.png"),
dpi=300, bbox_inches="tight")
plt.close()
# ============================================================
# 12. FIGURE 3 — Monotonicity
# ============================================================
def plot_monotonicity(df, output_dir):
"""rho_mean vs omega per agent."""
print("Generating Figure 3: monotonicity (rho_mean) vs omega...")
agents = [a for a in AGENT_ORDER if a in df["agent"].values]
fig, ax = plt.subplots(figsize=(10, 6))
for ag in agents:
sub = df[df["agent"] == ag].sort_values("omega")
if sub.empty:
continue
ax.plot(sub["omega"], sub["rho_mean"],
color=AGENT_COLORS.get(ag, "gray"),
marker=AGENT_MARKERS.get(ag, "o"),
linestyle=AGENT_LINESTYLES.get(ag, "-"),
label=ag, linewidth=2, markersize=8)
ax.axhline(0.0, color="black", linestyle=":", linewidth=1.0, alpha=0.5)
ax.set_xlabel("ω (attendance weight)")
ax.set_ylabel("rho_mean (Spearman, avg of infected & risk)")
ax.set_title("Policy Monotonicity — negative ρ = capacity ↓ as risk/infected ↑")
ax.grid(True, linestyle="--", alpha=0.4)
ax.legend(loc="best", fontsize=9, ncol=2, frameon=True)
plt.tight_layout()
plt.savefig(os.path.join(output_dir, "fig3_monotonicity.png"),
dpi=300, bbox_inches="tight")
plt.close()
# ============================================================
# 13. FIGURE 5 — Safety: optimal-threshold table + frontier
# ============================================================
def plot_safety_threshold_table(df_safety, output_dir, risk_type, z=SAFETY_Z):
"""Render the optimal-threshold safety table as a publication figure.
Rows grouped by ω (alternating shade); best F per ω highlighted green
(higher F = better infection/attendance trade-off at the policy's own ω)."""
print("Generating Figure 5: optimal-threshold safety table...")
omegas = sorted(df_safety["omega"].unique())
yn = {True: "Yes", False: "No"}
col_labels = ["Policy", "ω", "X*", "Y*", "I>X* (%)",
"Safe(I)", "u≥Y* (%)", "Safe(A)", "F"]
cell_text, row_colors, best_F_rows = [], [], []
shades = ["#eef3f8", "#ffffff"]
ri = 0
for gi, om in enumerate(omegas):
sub = df_safety[df_safety["omega"] == om]
ordered = [a for a in AGENT_ORDER if a in sub["agent"].values]
f_vals = {a: float(sub[sub["agent"] == a].iloc[0]["F_score"]) for a in ordered}
best = max(f_vals, key=f_vals.get) if f_vals else None
for a in ordered:
r = sub[sub["agent"] == a].iloc[0]
cell_text.append([
a, f"{om}", f"{int(r['optimal_x'])}", f"{int(r['optimal_y'])}",
f"{r['pct_I_gt_x']:.1f}", yn[bool(r['safety_I'])],