Spaces:
Paused
Paused
Download sensorflow/seqeval/controller.py from whassan/SensorflowStudio: direct link, hf CLI and curl.
- Browser
- Download file 25.7 kB
-
https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/sensorflow/seqeval/controller.py
- Command line
-
hf download hf://spaces/whassan/SensorflowStudio/sensorflow/seqeval/controller.py
-
curl -L -o controller.py https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/sensorflow/seqeval/controller.py
25.7 kB
| """Regression-controller state machine: sanity -> screening -> sequential | |
| confirmation -> targeted escalation, with CI/CD-style gate outcomes. | |
| Outcomes (three-outcome by construction, never "no news is good news"): | |
| REGRESSION -> gate "block": evidence + affected strata attached. | |
| PASS -> gate "allow": overall AND every pre-registered | |
| safety primary reached the equivalence-style claim | |
| (drop confidently smaller than the margin). | |
| INSUFFICIENT_EVIDENCE -> gate "expand_or_report": budget exhausted before | |
| either claim; reported with explicit | |
| "not proven equivalent" language. | |
| `evaluate_regression(...)` at the bottom is the clean entry point the safety | |
| Regression Gate (sensorflow/safety/) can call; the REST surface lives in | |
| api.py. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import os | |
| import threading | |
| import uuid | |
| from typing import Dict, List, Optional | |
| import numpy as np | |
| from sensorflow.megaeval import population as pop_mod | |
| from sensorflow.seqeval import attribution as attribution_mod | |
| from sensorflow.seqeval import hierarchy as hierarchy_mod | |
| from sensorflow.seqeval import ledger as ledger_mod | |
| from sensorflow.seqeval import paired as paired_mod | |
| from sensorflow.seqeval import planner as planner_mod | |
| from sensorflow.seqeval.sequential import (DECISION_INSUFFICIENT, DECISION_PASS, | |
| DECISION_REGRESSION, approx_mde) | |
| DEFAULT_POLICY: Dict = { | |
| "metric": "recall", | |
| "delta_margin": 0.005, # practical-significance margin, absolute points | |
| "alpha": 0.05, # family alpha (regression direction) | |
| "alpha_pass": 0.05, # per-required-node equivalence alpha | |
| "alpha_shares": dict(hierarchy_mod.DEFAULT_ALPHA_SHARES), | |
| "condition_dim": "lighting", | |
| "safety_primaries": ["pedestrian|night", "cyclist|night"], | |
| "target_n": 24_000, | |
| "min_per_stratum": 200, | |
| "safety_floor": 2500, | |
| "stage_fractions": [0.15, 0.40, 1.0], # screening, confirmation, full plan | |
| "batches_per_stage": 3, | |
| "screening_e_threshold": 1.5, | |
| "stop_on_regression": True, | |
| "escalation": {"enabled": True, "max_extra_per_stratum": 4000, | |
| "batch_objects": 500}, | |
| "plan_seed": 12345, | |
| } | |
| GATE_FOR_DECISION = { | |
| DECISION_REGRESSION: "block", | |
| DECISION_PASS: "allow", | |
| DECISION_INSUFFICIENT: "expand_or_report", | |
| } | |
| INSUFFICIENT_LANGUAGE = ("Budget exhausted before either claim could be made: the " | |
| "candidate is NOT PROVEN EQUIVALENT to the baseline within " | |
| "the practical margin, and no regression beyond the margin " | |
| "was confirmed. Do not treat this as a pass.") | |
| def merged_policy(overrides: Optional[Dict] = None) -> Dict: | |
| policy = json.loads(json.dumps(DEFAULT_POLICY)) | |
| for k, v in (overrides or {}).items(): | |
| if isinstance(v, dict) and isinstance(policy.get(k), dict): | |
| policy[k].update(v) | |
| else: | |
| policy[k] = v | |
| return policy | |
| class SequentialRegressionRun: | |
| def __init__(self, population_id: str, baseline: paired_mod.SimulatedModel, | |
| candidate: paired_mod.SimulatedModel, policy: Optional[Dict] = None, | |
| seed: Optional[int] = None): | |
| self.run_id = f"seq-{uuid.uuid4().hex[:10]}" | |
| self.population_id = population_id | |
| self.baseline = baseline | |
| self.candidate = candidate | |
| self.policy = merged_policy(policy) | |
| if seed is None: | |
| seed = int(hashlib.sha256( | |
| f"{population_id}|{baseline.fingerprint()}|{candidate.fingerprint()}" | |
| .encode()).hexdigest()[:8], 16) | |
| self.seed = int(seed) | |
| self.status = "created" # created|running|done|failed | |
| self.stage = "pending" # sanity|screening|sequential|escalation|done | |
| self.decision = DECISION_INSUFFICIENT | |
| self.stopping_reason: Optional[str] = None | |
| self.error: Optional[str] = None | |
| self.created_at = ledger_mod.now_iso() | |
| self.finished_at: Optional[str] = None | |
| self.samples_used = 0 | |
| self.escalation_used = 0 | |
| self.sanity: Dict = {} | |
| self.plan: Optional[Dict] = None | |
| self.hier: Optional[hierarchy_mod.HierarchyController] = None | |
| self.attribution: Optional[Dict] = None | |
| self.ledger = ledger_mod.EvidenceLedger(self.run_id) | |
| self.message: Optional[str] = None | |
| # ---------------------------------------------------------------- state | |
| def gate(self) -> str: | |
| return GATE_FOR_DECISION[self.decision] | |
| def to_dict(self, include_trajectories: bool = True) -> Dict: | |
| nodes = [] | |
| trajectories = {} | |
| if self.hier is not None: | |
| for key, node in self.hier.nodes.items(): | |
| if node.test.n_objects == 0: | |
| continue | |
| snap = node.test.snapshot() | |
| snap.update({"node": key, "level": node.level, | |
| "safety_primary": node.safety_primary, | |
| "suspect": node.suspect, | |
| "alpha_allocated": round(self.hier.alpha_for(node), 6), | |
| "e_threshold_regression": round(1.0 / self.hier.alpha_for(node), 2), | |
| "e_threshold_pass": round(1.0 / self.hier.alpha_pass, 2)}) | |
| nodes.append(snap) | |
| if include_trajectories: | |
| trajectories[key] = { | |
| "points": node.test.trajectory, | |
| "boundaries": { | |
| "log_e_regression": round(float(np.log(1.0 / self.hier.alpha_for(node))), 4), | |
| "log_e_pass": round(float(np.log(1.0 / self.hier.alpha_pass)), 4), | |
| "delta_margin": -self.policy["delta_margin"], | |
| }, | |
| } | |
| plan_summary = None | |
| if self.plan is not None: | |
| plan_summary = {k: v for k, v in self.plan.items() if k != "_arrays"} | |
| out = { | |
| "run_id": self.run_id, | |
| "population_id": self.population_id, | |
| "baseline": self.baseline.to_dict(), | |
| "candidate": self.candidate.to_dict(), | |
| "status": self.status, | |
| "stage": self.stage, | |
| "decision": self.decision, | |
| "gate": self.gate(), | |
| "message": self.message, | |
| "stopping_reason": self.stopping_reason, | |
| "error": self.error, | |
| "seed": self.seed, | |
| "policy": self.policy, | |
| "sanity": self.sanity, | |
| "budget": { | |
| "planned_total": plan_summary["total_allocated"] if plan_summary else None, | |
| "samples_used": self.samples_used, | |
| "escalation_used": self.escalation_used, | |
| "full_population": plan_summary["population_objects"] if plan_summary else None, | |
| "fraction_of_population": (round(self.samples_used / plan_summary["population_objects"], 5) | |
| if plan_summary else None), | |
| }, | |
| "plan": plan_summary, | |
| "nodes": sorted(nodes, key=lambda r: (r["level"], r["node"])), | |
| "created_at": self.created_at, | |
| "finished_at": self.finished_at, | |
| } | |
| if include_trajectories: | |
| out["trajectories"] = trajectories | |
| return out | |
| class SeqevalStore: | |
| def __init__(self): | |
| self.lock = threading.RLock() | |
| self.runs: Dict[str, SequentialRegressionRun] = {} | |
| self._persisted: Dict[str, Dict] = {} | |
| self._load_persisted() | |
| def _load_persisted(self) -> None: | |
| base = os.path.join(ledger_mod.seq_root(), "runs") | |
| if not os.path.isdir(base): | |
| return | |
| for rid in sorted(os.listdir(base)): | |
| path = os.path.join(base, rid, "run.json") | |
| if not os.path.exists(path): | |
| continue | |
| try: | |
| with open(path) as f: | |
| self._persisted[rid] = json.load(f) | |
| except Exception: | |
| continue | |
| def create_run(self, population_id: str, baseline: Dict, candidate: Dict, | |
| policy: Optional[Dict] = None, seed: Optional[int] = None | |
| ) -> SequentialRegressionRun: | |
| if pop_mod.load_meta(population_id) is None: | |
| raise KeyError(f"Unknown population {population_id}") | |
| run = SequentialRegressionRun( | |
| population_id, | |
| paired_mod.SimulatedModel(baseline.get("model_version", "baseline"), | |
| baseline.get("effects")), | |
| paired_mod.SimulatedModel(candidate.get("model_version", "candidate"), | |
| candidate.get("effects")), | |
| policy=policy, seed=seed) | |
| with self.lock: | |
| self.runs[run.run_id] = run | |
| return run | |
| def get_state(self, run_id: str, include_trajectories: bool = True) -> Optional[Dict]: | |
| with self.lock: | |
| run = self.runs.get(run_id) | |
| if run is not None: | |
| return run.to_dict(include_trajectories=include_trajectories) | |
| state = self._persisted.get(run_id) | |
| if state is not None and not include_trajectories: | |
| state = {k: v for k, v in state.items() if k != "trajectories"} | |
| return state | |
| def list_states(self) -> List[Dict]: | |
| seen = set() | |
| out = [] | |
| with self.lock: | |
| for run in self.runs.values(): | |
| seen.add(run.run_id) | |
| out.append(run.to_dict(include_trajectories=False)) | |
| for rid, state in self._persisted.items(): | |
| if rid not in seen: | |
| out.append({k: v for k, v in state.items() if k != "trajectories"}) | |
| out.sort(key=lambda s: s.get("created_at") or "", reverse=True) | |
| return out | |
| def save_run(self, run: SequentialRegressionRun) -> None: | |
| d = ledger_mod.run_dir(run.run_id) | |
| os.makedirs(d, exist_ok=True) | |
| with open(os.path.join(d, "run.json"), "w") as f: | |
| json.dump(run.to_dict(include_trajectories=True), f) | |
| def start_async(self, run: SequentialRegressionRun) -> None: | |
| threading.Thread(target=self.execute_sync, args=(run,), daemon=True).start() | |
| # ---------------------------------------------------------------- engine | |
| def execute_sync(self, run: SequentialRegressionRun) -> None: | |
| try: | |
| run.status = "running" | |
| meta = pop_mod.load_meta(run.population_id) | |
| policy = run.policy | |
| # ---- stage: sanity + frozen plan ---------------------------- | |
| run.stage = "sanity" | |
| plan = planner_mod.build_plan( | |
| meta, target_n=policy["target_n"], seed=policy["plan_seed"], | |
| condition_dim=policy["condition_dim"], | |
| safety_primaries=policy["safety_primaries"], | |
| min_per_stratum=policy["min_per_stratum"], | |
| safety_floor=policy["safety_floor"]) | |
| run.plan = plan | |
| frame = paired_mod.load_frame(meta) | |
| all_ids = np.concatenate([plan["_arrays"][f"s{s}_ids"] | |
| for s in sorted(int(k) for k in plan["strata"])]) | |
| smoke = all_ids[:min(500, all_ids.size)] | |
| outcomes = paired_mod.paired_outcomes(meta, run.baseline, run.candidate, | |
| smoke, policy["condition_dim"]) | |
| b_rate = float(outcomes["baseline"].mean()) | |
| c_rate = float(outcomes["candidate"].mean()) | |
| discordance = float(np.mean(outcomes["baseline"] != outcomes["candidate"])) | |
| run.sanity = { | |
| "plan_hash": plan["plan_hash"], | |
| "dataset_fingerprint": plan["dataset_fingerprint"], | |
| "baseline_fingerprint": run.baseline.fingerprint(), | |
| "candidate_fingerprint": run.candidate.fingerprint(), | |
| "smoke_n": int(smoke.size), | |
| "smoke_baseline_rate": round(b_rate, 4), | |
| "smoke_candidate_rate": round(c_rate, 4), | |
| "smoke_discordance": round(discordance, 4), | |
| "ok": bool(0.2 < b_rate <= 1.0 and 0.2 < c_rate <= 1.0 | |
| and discordance < 0.5), | |
| } | |
| if not run.sanity["ok"]: | |
| run.decision = DECISION_INSUFFICIENT | |
| run.stopping_reason = "sanity_failed" | |
| run.message = ("Sanity stage failed (degenerate outcome rates or " | |
| "excessive discordance); evaluation aborted. " | |
| + INSUFFICIENT_LANGUAGE) | |
| self._finalize(run) | |
| return | |
| # ---- build hierarchy + per-stratum cluster iterators --------- | |
| n_cond = len(pop_mod.DIMENSIONS[policy["condition_dim"]]) | |
| hier = hierarchy_mod.HierarchyController( | |
| plan["strata"], n_cond=n_cond, delta=policy["delta_margin"], | |
| alpha=policy["alpha"], alpha_pass=policy["alpha_pass"], | |
| alpha_shares=policy["alpha_shares"], | |
| band_names=planner_mod.BAND_NAMES) | |
| run.hier = hier | |
| clusters = {int(s): planner_mod.stratum_clusters(plan, int(s)) | |
| for s in plan["strata"]} | |
| reserves = {int(s): planner_mod.stratum_clusters(plan, int(s), reserve=True) | |
| for s in plan["strata"]} | |
| pointers = {s: 0 for s in clusters} | |
| taken_objects = {s: 0 for s in clusters} | |
| weights = {int(s): float(v["weight"]) for s, v in plan["strata"].items()} | |
| key_to_sid = {f"stratum:{v['key']}": int(s) for s, v in plan["strata"].items()} | |
| bands = np.digitize(frame["difficulty"], | |
| np.asarray(planner_mod.DIFFICULTY_BANDS)).astype(np.int8) | |
| def make_batch(chunks: List) -> Optional[Dict[str, np.ndarray]]: | |
| """chunks: list of (stratum_id, [cluster object_id arrays]).""" | |
| sids, ids = [], [] | |
| for s, cl in chunks: | |
| for arr in cl: | |
| ids.append(arr) | |
| sids.append(np.full(arr.size, s, dtype=np.int64)) | |
| if not ids: | |
| return None | |
| oid = np.concatenate(ids) | |
| sid = np.concatenate(sids) | |
| oc = paired_mod.paired_outcomes(meta, run.baseline, run.candidate, | |
| oid, policy["condition_dim"]) | |
| return { | |
| "object_id": oid, | |
| "container_id": frame["container_id"][oid], | |
| "class": frame["class"][oid].astype(np.int64), | |
| "cond": frame[policy["condition_dim"]][oid].astype(np.int64), | |
| "band": bands[oid].astype(np.int64), | |
| "weight": np.asarray([weights[s] for s in sid], dtype=np.float64), | |
| "d": oc["d"], "b": oc["baseline"], "c": oc["candidate"], | |
| } | |
| def process(chunks: List) -> None: | |
| batch = make_batch(chunks) | |
| if batch is None: | |
| return | |
| hier.update_with_batch(batch) | |
| hier.evaluate(policy["screening_e_threshold"]) | |
| hier.record_trajectories() | |
| run.samples_used += int(batch["d"].size) | |
| def take_until(s: int, target: int) -> List[np.ndarray]: | |
| out = [] | |
| while pointers[s] < len(clusters[s]) and taken_objects[s] < target: | |
| arr = clusters[s][pointers[s]] | |
| out.append(arr) | |
| taken_objects[s] += arr.size | |
| pointers[s] += 1 | |
| return out | |
| stopped = False | |
| # ---- stages: screening then sequential confirmation ---------- | |
| for stage_idx, frac in enumerate(policy["stage_fractions"]): | |
| run.stage = "screening" if stage_idx == 0 else "sequential" | |
| for sub in range(policy["batches_per_stage"]): | |
| prev_frac = (policy["stage_fractions"][stage_idx - 1] | |
| if stage_idx > 0 else 0.0) | |
| eff_frac = prev_frac + (frac - prev_frac) * (sub + 1) / policy["batches_per_stage"] | |
| chunks = [] | |
| for s in clusters: | |
| target = int(np.ceil(eff_frac * plan["strata"][str(s)]["allocated"])) | |
| cl = take_until(s, target) | |
| if cl: | |
| chunks.append((s, cl)) | |
| process(chunks) | |
| if policy["stop_on_regression"] and hier.any_regression(): | |
| run.stopping_reason = "regression_confirmed" | |
| stopped = True | |
| break | |
| if hier.required_pass() and not hier.undecided_keys(): | |
| run.stopping_reason = "all_passed" | |
| stopped = True | |
| break | |
| if stopped: | |
| break | |
| # ---- targeted escalation ------------------------------------- | |
| esc = policy["escalation"] | |
| if not stopped and esc.get("enabled", True): | |
| run.stage = "escalation" | |
| for key in list(hier.undecided_keys()): | |
| node = hier.nodes.get(key) | |
| if node is None or node.level != 3: | |
| continue | |
| if not (node.suspect or node.safety_primary): | |
| continue | |
| s = key_to_sid.get(key) | |
| if s is None: | |
| continue | |
| pool = reserves.get(s, []) | |
| ptr, extra = 0, 0 | |
| while (ptr < len(pool) and extra < esc["max_extra_per_stratum"] | |
| and node.test.decision == DECISION_INSUFFICIENT): | |
| chunk, size = [], 0 | |
| while ptr < len(pool) and size < esc["batch_objects"]: | |
| chunk.append(pool[ptr]) | |
| size += pool[ptr].size | |
| ptr += 1 | |
| if not chunk: | |
| break | |
| process([(s, chunk)]) | |
| extra += size | |
| run.escalation_used += size | |
| if policy["stop_on_regression"] and hier.any_regression(): | |
| run.stopping_reason = "regression_confirmed" | |
| stopped = True | |
| break | |
| if stopped: | |
| break | |
| # ---- finalize ------------------------------------------------- | |
| if run.stopping_reason is None: | |
| run.stopping_reason = ("escalation_exhausted" | |
| if esc.get("enabled", True) else "budget_exhausted") | |
| if hier.any_regression(): | |
| run.decision = DECISION_REGRESSION | |
| run.message = "Anytime-valid regression confirmed; see attribution." | |
| elif hier.required_pass(): | |
| run.decision = DECISION_PASS | |
| run.message = ("Overall and all pre-registered safety primaries proved " | |
| "equivalent within the practical margin.") | |
| else: | |
| run.decision = DECISION_INSUFFICIENT | |
| run.message = INSUFFICIENT_LANGUAGE | |
| run.attribution = attribution_mod.build_attribution(hier) | |
| self._write_ledger(run) | |
| self._finalize(run) | |
| except Exception as e: # pragma: no cover - defensive | |
| run.status = "failed" | |
| run.error = str(e) | |
| run.finished_at = ledger_mod.now_iso() | |
| try: | |
| self.save_run(run) | |
| except Exception: | |
| pass | |
| # ---------------------------------------------------------------- output | |
| def _write_ledger(self, run: SequentialRegressionRun) -> None: | |
| hier = run.hier | |
| for node in hier.nodes.values(): | |
| t = node.test | |
| if t.n_objects == 0: | |
| continue | |
| clustering = node.clustering() | |
| nu = (t.n01 + t.n10) / t.n_objects if t.n_objects else 0.0 | |
| est = t.delta_estimate() or 0.0 | |
| var_d = max(nu - est ** 2, 1e-6) | |
| base_rate = t.sum_baseline / t.n_objects | |
| cand_rate = t.sum_candidate / t.n_objects | |
| lo, hi = t.delta_interval() | |
| alpha_alloc = hier.alpha_for(node) | |
| run.ledger.append({ | |
| "run_id": run.run_id, | |
| "baseline_model": run.baseline.to_dict(), | |
| "candidate_model": run.candidate.to_dict(), | |
| "dataset_version": run.population_id, | |
| "seed": run.seed, | |
| "stratum": node.key, | |
| "level": node.level, | |
| "metric": run.policy["metric"], | |
| "baseline_value": round(base_rate, 6), | |
| "candidate_value": round(cand_rate, 6), | |
| "abs_delta": round(cand_rate - base_rate, 6), | |
| "rel_delta": (round((cand_rate - base_rate) / base_rate, 6) | |
| if base_rate else None), | |
| "delta_ci_low": round(lo, 6), | |
| "delta_ci_high": round(hi, 6), | |
| "mde_abs": approx_mde(var_d, clustering["n_effective"], | |
| run.policy["delta_margin"], alpha_alloc), | |
| "n": t.n_objects, | |
| "n_effective": clustering["n_effective"], | |
| "n_clusters": clustering["n_clusters"], | |
| "design_effect": clustering["design_effect"], | |
| "confidence_level": 1.0 - run.policy["alpha"], | |
| "e_value": round(t.e_reg.e_value, 6), | |
| "p_analogue": round(t.e_reg.p_analogue(), 8), | |
| "bayes_p_regression": t.bayes_p_regression(), | |
| "decision": t.decision, | |
| "test_method": ledger_mod.TEST_METHOD, | |
| "multiple_testing_method": hierarchy_mod.MULTIPLE_TESTING_METHOD, | |
| "practical_margin": run.policy["delta_margin"], | |
| "alpha_allocated": round(alpha_alloc, 6), | |
| "stopping_reason": run.stopping_reason, | |
| "timestamp": ledger_mod.now_iso(), | |
| }) | |
| run.ledger.write_lineage({ | |
| "run_id": run.run_id, | |
| "dataset_version": run.population_id, | |
| "dataset_fingerprint": run.sanity.get("dataset_fingerprint"), | |
| "plan_id": run.plan["plan_id"], | |
| "plan_hash": run.plan["plan_hash"], | |
| "seed": run.seed, | |
| "statistical_config": run.policy, | |
| "baseline_model": run.baseline.to_dict(), | |
| "candidate_model": run.candidate.to_dict(), | |
| }) | |
| def _finalize(self, run: SequentialRegressionRun) -> None: | |
| run.status = "done" | |
| run.stage = "done" | |
| run.finished_at = ledger_mod.now_iso() | |
| self.save_run(run) | |
| # best-effort integration with platform accounting + audit trail | |
| try: | |
| from sensorflow.evaluation.process_units import ProcessMeter | |
| from sensorflow.evaluation.records import get_store as get_legacy_store | |
| legacy = get_legacy_store() | |
| ProcessMeter(legacy, run.run_id).record("regression_tracking", | |
| run.samples_used) | |
| legacy.audit("seqeval_decision", "seqeval_run", run.run_id, | |
| detail=f"{run.decision} ({run.stopping_reason}); " | |
| f"samples={run.samples_used}") | |
| legacy.save() | |
| except Exception: | |
| pass | |
| _STORE: Optional[SeqevalStore] = None | |
| _STORE_LOCK = threading.Lock() | |
| def get_seqeval_store() -> SeqevalStore: | |
| global _STORE | |
| with _STORE_LOCK: | |
| if _STORE is None: | |
| _STORE = SeqevalStore() | |
| return _STORE | |
| def reset_seqeval_store() -> None: | |
| global _STORE | |
| with _STORE_LOCK: | |
| _STORE = None | |
| def evaluate_regression(population_id: str, baseline: Dict, candidate: Dict, | |
| policy: Optional[Dict] = None, seed: Optional[int] = None | |
| ) -> Dict: | |
| """Clean entry point for the safety Regression Gate. | |
| baseline/candidate: {"model_version": str, "effects": {stratum: pp_delta}}. | |
| Returns a gate verdict with the evidence needed to block or allow: | |
| {gate, decision, stopping_reason, samples_used, affected_strata, | |
| regression_map, run_id, message}. | |
| """ | |
| store = get_seqeval_store() | |
| run = store.create_run(population_id, baseline, candidate, policy=policy, seed=seed) | |
| store.execute_sync(run) | |
| att = run.attribution or {"regression_map": [], "affected_strata": []} | |
| return { | |
| "run_id": run.run_id, | |
| "gate": run.gate(), | |
| "decision": run.decision, | |
| "message": run.message, | |
| "stopping_reason": run.stopping_reason, | |
| "samples_used": run.samples_used, | |
| "planned_total": run.plan["total_allocated"] if run.plan else None, | |
| "full_population": run.plan["population_objects"] if run.plan else None, | |
| "affected_strata": att["affected_strata"], | |
| "regression_map": att["regression_map"], | |
| } | |