Buckets:

illusion-brain/brain-domains / scripts /exp101_condition.py
redmingwei's picture
download
raw
16.1 kB
#!/usr/bin/env python3
"""exp101_condition.py — ②③ 条件生成线的**第一个里程碑**:**脑侧条件的可辨识性**(THINGS-EEG)。
## 这一轮要回答什么(**不训任何生成模型**)
②③ 是「脑 → 图像」的**条件生成**(脑侧编码器 + 图像侧生成先验;见 `MODEL_FORM.md` §7.3.2 的路线 ②/③)。
⚠️ **在烧 GPU 训扩散之前,先判一件事**:**我们的脑侧条件到底能不能认出「是哪张图」?**
⇒ ⇒ 若连「在 50 张候选里挑出正确的那张」都做不到 ⇒ ⇒ **条件生成这条路现在不该开**
⇒ ⇒ **这一轮零训练**(用**已训好并已验证**的 exp93b 对比学习 encoder,冻结)。
## 口径(**与文献表同口径,以便对标**)
`MODEL_FORM.md` §7.3.3:THINGS-EEG = **1654 概念训练 / 200 测试概念 × 10 被试**;
文献的 200-way 零样本检索是「**依赖被试(intra-subject)+ 试次平均**」口径
⇒ ⇒ 本轮**两个口径都报**:**单试次**(现实设定)与**试次平均**(文献口径)。
## 预登记判据(**写死在这里,事后不改**)
| # | 判据 | 阈值 |
|---|---|---|
| **C0** | **条件自检**:200-way 单试次 top-1 中位 **≥ 3×随机**(1/200 ⇒ ≥0.015)且 **> 0**(对照 exp93b 的 **0.0589** ⇒ 这是**参照**,不是新发现)| 不过 ⇒ **全实验作废**(条件根本没学到东西 ⇒ 后面的检索数字无意义)|
| **C1**(主判据)| **50-way 条件可辨识**:逐被试 50-way top-1 ⇒ **显著高于随机**(1/50 = 2%)| **≥8 被试、p ≤ 0.05、中位 > 2%** |
| **C2** | **2-way 条件可辨识**:逐被试 2-way top-1 ⇒ **显著高于随机**(50%)| **≥8 被试、p ≤ 0.05、中位 > 50%** |
| **C3**(只报)| ① 200-way(单试次 / 试次平均)② 50-way / 2-way 的**试次平均**版 ③ **与 ATM(NeurIPS 2024)的并排参照**:他们报 2-way **83.1%**、50-way top-1 **28.4%**、FID 24.6 ⚠️ **他们的是「生成图」的指标、我们的是「条件」的指标 ⇒ 协议不同 ⇒ 只作量级参照,不是同协议对标** ④ 每概念召回(只报)| — |
### 决策规则(**预登记**)
- **C1 过** ⇒ **②③ 值得开**(下一步才是训生成模型:条件已能认出图像 ⇒ 条件是有信息的)。
- **C1 不过** ⇒ ⇒ **先别训生成模型** ⇒ ⇒ 要先改**脑侧**(更强的 encoder / 换码本锚 / 换数据),
⇒ ⇒ 否则就是拿一个认不出图的信号去条件化生成 ⇒ ⇒ **白烧 GPU**。
## 用法
```powershell
python -u research/brain_domains/exp/exp101_condition.py --self-test
# 云端
hf jobs run "hf.co/spaces/redmingwei/IllusionBrain" \
python -u /domains/scripts/exp101_condition.py --eval-subjects 1 2 3 4 5 6 7 8 9 10 \
-v hf://buckets/illusion-brain/training-data:/training:ro \
-v hf://buckets/illusion-brain/brain-domains:/domains \
--flavor a10g-large --timeout 60m -d --name t101-cond
```
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import traceback
from pathlib import Path
import numpy as np
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "8")
EXP_DIR = Path(__file__).resolve().parent
for _p in (str(EXP_DIR), str(EXP_DIR.parent)):
if _p not in sys.path:
sys.path.insert(0, _p)
import exp93b_contrastive as E93B # noqa: E402
SEED = 0
CHANCE_200 = 1.0 / 200.0
CHANCE_50 = 1.0 / 50.0
CHANCE_2 = 0.5
MIN_SUBJECTS = 8
ALPHA = 0.05
# 外部参照(**C 类:别人发表的系统/数字 ⇒ 只作量级参照,不当我们的结果**)
# ATM (NeurIPS 2024, 64-ch EEG / THINGS):2-way 83.1% / 50-way top-1 28.4% / FID 24.6
# ⚠️ 那是「**生成图**」的指标;我们这里量的是「**条件**」的指标 ⇒ 协议不同
ATM_REF = {"two_way_pct": 83.1, "fifty_way_top1_pct": 28.4, "fid": 24.6}
def log(*a):
print(*a, flush=True)
def med_up(xs):
s = sorted(float(x) for x in xs)
return s[len(s) // 2] if s else float("nan")
def sign_test_nonzero(diffs):
from math import comb
d = [float(x) for x in diffs if x == x]
n = len(d)
pos = sum(1 for x in d if x > 0)
neg = sum(1 for x in d if x < 0)
zero = n - pos - neg
k = min(pos, neg)
p = min(1.0, 2.0 * sum(comb(n, i) for i in range(k + 1)) / (2.0 ** n)) if n else 1.0
return n, pos, neg, p, zero
def paired_test(diffs, name, out):
n, pos, neg, p, zero = sign_test_nonzero(diffs)
out[name] = {"n_eff": n, "n_pos": pos, "n_neg": neg, "p_sign": p,
"n_zero": zero, "median": med_up(diffs)}
return out[name]
# ---------------------------------------------------------------------------
# 检索(条件 z ↔ 图像特征,余弦)
# ---------------------------------------------------------------------------
def make_candidate_structure(n_img, n_groups=4, seed=SEED):
"""50 组划分与 2-way 负样本:**按图像身份固定** ⇒ 跨被试一致、可比。"""
rng = np.random.default_rng(seed)
perm = rng.permutation(n_img)
group = np.zeros(n_img, dtype=np.int64)
for gi in range(n_groups):
group[perm[gi::n_groups]] = gi
neg = np.empty(n_img, dtype=np.int64)
for i in range(n_img):
cand = rng.choice(np.delete(np.arange(n_img), i), size=1, replace=False)
neg[i] = int(cand[0])
return group, neg
def retrieval_metrics(Z, Vc, group, neg):
"""Z : (n_img, n_rep, D) 条件;Vc : (n_img, D) 图像特征。"""
n_img, n_rep, D = Z.shape
flat = Z.reshape(-1, D).astype(np.float64)
fn = flat / np.maximum(np.linalg.norm(flat, axis=1, keepdims=True), 1e-12)
cn = Vc.astype(np.float64)
cn = cn / np.maximum(np.linalg.norm(cn, axis=1, keepdims=True), 1e-12)
sim = fn @ cn.T
true = np.repeat(np.arange(n_img), n_rep)
# 200-way(参照)
acc200 = float((sim.argmax(axis=1) == true).mean())
# 50-way:只在同组的 50 个候选里比
mask = np.zeros_like(sim, dtype=bool)
for gi in np.unique(group):
cols = np.flatnonzero(group == gi)
rows = np.flatnonzero(group[true] == gi)
mask[np.ix_(rows, cols)] = True
sim50 = np.where(mask, sim, -np.inf)
acc50 = float((sim50.argmax(axis=1) == true).mean())
# 2-way:每行只比 [正, 负]
ng = neg[true]
s_true = sim[np.arange(len(true)), true]
s_neg = sim[np.arange(len(true)), ng]
acc2 = float((s_true > s_neg).mean())
# 试次平均版(文献口径)
avg = Z.astype(np.float64).mean(axis=1)
an = avg / np.maximum(np.linalg.norm(avg, axis=1, keepdims=True), 1e-12)
sim_avg = an @ cn.T
t = np.arange(n_img)
acc200_avg = float((sim_avg.argmax(axis=1) == t).mean())
mask_a = np.zeros_like(sim_avg, dtype=bool)
for gi in np.unique(group):
cols = np.flatnonzero(group == gi)
rows = np.flatnonzero(group == gi)
mask_a[np.ix_(rows, cols)] = True
acc50_avg = float((np.where(mask_a, sim_avg, -np.inf).argmax(axis=1) == t).mean())
s_true_a = sim_avg[t, t]
s_neg_a = sim_avg[t, neg]
acc2_avg = float((s_true_a > s_neg_a).mean())
return {"acc200": acc200, "acc50": acc50, "acc2": acc2,
"acc200_avg": acc200_avg, "acc50_avg": acc50_avg, "acc2_avg": acc2_avg}
def eval_subject(model, sid, IMG_test, group, neg, args, device):
import torch
eeg = E93B.load_test_subject(sid)
n_img, n_rep, n_ch, T = eeg.shape
flat = eeg.reshape(n_img * n_rep, n_ch, T)
Z = E93B.encode_all(model, flat, device, args.batch).reshape(n_img, n_rep, -1)
rec = {"subject": f"sub-{sid:02d}"}
rec.update(retrieval_metrics(Z, IMG_test, group, neg))
del eeg, Z
return rec
# ---------------------------------------------------------------------------
# 判决(**预登记**)
# ---------------------------------------------------------------------------
def decide(records, min_subjects=MIN_SUBJECTS, alpha=ALPHA):
out = {}
ok = [r for r in records if "error" not in r]
if not ok:
return {"verdict": "INSUFFICIENT", "why": "没有可用被试"}
# ---- C0 条件自检(200-way 单试次)----
a200 = [r["acc200"] for r in ok if "acc200" in r]
c0 = {"acc200_median": med_up(a200),
"acc200_above_chance3": bool(med_up(a200) >= 3 * CHANCE_200),
"acc200_positive": bool(med_up(a200) > 0),
"exp93b_reference": 0.0589}
out["C0"] = c0
out["C0_pass"] = bool(c0["acc200_above_chance3"] and c0["acc200_positive"])
if not out["C0_pass"]:
return {"verdict": "CONDITION_SELFCHECK_FAIL", "C0": c0,
"why": "条件连 200-way 都不显著高于随机 ⇒ 条件没学到东西 ⇒ 后面的检索数字无意义 ⇒ 停"}
# ---- C1(主判据)50-way ----
c1 = {}
diffs = [r["acc50"] - CHANCE_50 for r in ok if "acc50" in r]
if diffs:
v = paired_test(diffs, "acc50_minus_chance", c1)
v["pass"] = bool(v["n_eff"] >= min_subjects and v["p_sign"] <= alpha
and (v.get("median") or 0) > 0)
out["C1"] = c1
out["C1_pass"] = bool(c1) and all(v.get("pass") for v in c1.values())
# ---- C2 2-way ----
c2 = {}
diffs2 = [r["acc2"] - CHANCE_2 for r in ok if "acc2" in r]
if diffs2:
v = paired_test(diffs2, "acc2_minus_chance", c2)
v["pass"] = bool(v["n_eff"] >= min_subjects and v["p_sign"] <= alpha
and (v.get("median") or 0) > 0)
out["C2"] = c2
out["C2_pass"] = bool(c2) and all(v.get("pass") for v in c2.values())
# ---- C3 只报 ----
out["C3_report"] = {
"acc200_median": med_up([r["acc200"] for r in ok if "acc200" in r]),
"acc50_median": med_up([r["acc50"] for r in ok if "acc50" in r]),
"acc2_median": med_up([r["acc2"] for r in ok if "acc2" in r]),
"acc200_avg_median": med_up([r["acc200_avg"] for r in ok if "acc200_avg" in r]),
"acc50_avg_median": med_up([r["acc50_avg"] for r in ok if "acc50_avg" in r]),
"acc2_avg_median": med_up([r["acc2_avg"] for r in ok if "acc2_avg" in r]),
"atm_reference": ATM_REF,
"atm_caveat": "ATM 报的是**生成图**的指标;我们这里量的是**条件**的指标 ⇒ 协议不同 ⇒ 只作量级参照",
}
if out["C1_pass"]:
out["verdict"] = "CONDITION_USABLE"
out["why"] = ("条件在 50 张候选里显著认出正确图像 ⇒ **②③ 值得开**"
"(下一步才是训生成模型)")
else:
out["verdict"] = "CONDITION_TOO_WEAK"
out["why"] = ("条件在 50 张候选里认不出正确图像 ⇒ ⇒ **先别训生成模型**"
"(白烧 GPU)⇒ ⇒ 要先改脑侧:更强的 encoder / 换码本锚 / 换数据")
return out
# ---------------------------------------------------------------------------
def _fake_records(n=10, acc50=0.30, acc2=0.80, acc200=0.059):
recs = []
for i in range(n):
recs.append({"subject": f"sub-{i:02d}", "acc200": acc200 + 0.0001 * i,
"acc50": acc50 + 0.001 * i, "acc2": acc2 + 0.001 * i,
"acc200_avg": 0.23, "acc50_avg": 0.5, "acc2_avg": 0.9})
return recs
def self_test():
ok, bad = 0, []
def chk(name, cond):
nonlocal ok
if cond:
ok += 1
else:
bad.append(name)
# ① 候选结构:按图像身份固定 ⇒ 跨被试一致
g, n = make_candidate_structure(200)
chk("候选结构:每组 50 张、共 4 组", sorted(np.bincount(g).tolist()) == [50, 50, 50, 50])
chk("候选结构:负样本不是它自己", bool((n != np.arange(200)).all()))
# ② 检索:完美的条件 ⇒ 各口径都满分;随机条件 ⇒ 机会水平
rng = np.random.default_rng(0)
Vc = rng.normal(size=(200, 32))
Z_perfect = Vc[:, None, :] + 0.01 * rng.normal(size=(200, 4, 32))
m = retrieval_metrics(Z_perfect, Vc, g, n)
chk("检索:完美条件 ⇒ 200-way/50-way/2-way 都接近满分",
m["acc200"] > 0.95 and m["acc50"] > 0.95 and m["acc2"] > 0.95)
Z_rand = rng.normal(size=(200, 4, 32))
m2 = retrieval_metrics(Z_rand, Vc, g, n)
chk("检索:随机条件 ⇒ 50-way ≈ 2%、2-way ≈ 50%",
abs(m2["acc50"] - 0.02) < 0.05 and abs(m2["acc2"] - 0.5) < 0.12)
# ③ decide() 的判决分支
v = decide(_fake_records(10, acc50=0.30, acc2=0.80))
chk("decide:条件可用 ⇒ CONDITION_USABLE",
v["verdict"] == "CONDITION_USABLE" and v["C1_pass"] and v["C2_pass"])
# ⚠️ 用**明显低于机会**的档位(否则 +0.001*i 的抖动会让它变成显著的)
v = decide(_fake_records(10, acc50=0.00, acc2=0.40))
chk("decide:条件太弱 ⇒ CONDITION_TOO_WEAK(**先别训生成模型**)",
v["verdict"] == "CONDITION_TOO_WEAK" and not v["C1_pass"])
v = decide(_fake_records(10, acc200=0.001))
chk("decide:200-way 都在随机水平 ⇒ CONDITION_SELFCHECK_FAIL(停)",
v["verdict"] == "CONDITION_SELFCHECK_FAIL")
v = decide(_fake_records(5, acc50=0.30, acc2=0.80))
chk("decide:被试不够(5 < 8)⇒ C1 不过",
not v.get("C1_pass", False))
print(f"\n{'✓ 全部通过' if not bad else '✗ 有失败'}:{ok} 项检查"
f"{',失败:' + ', '.join(bad) if bad else ''}")
return 0 if not bad else 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--eval-subjects", type=int, nargs="+", default=list(range(1, 11)))
ap.add_argument("--batch", type=int, default=256)
ap.add_argument("--ckpt", default="/domains/runs/illusionbrain-align/exp93b/encoder_best.pt")
ap.add_argument("--out-dir", default="/domains/runs/illusionbrain-align/exp101")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
return self_test()
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
ck = torch.load(args.ckpt, map_location="cpu", weights_only=False)
model = E93B.build_model(n_ch=ck["n_ch"], n_times=ck["n_times"], out_dim=ck["out_dim"])
model.load_state_dict(ck["state_dict"])
model = model.to(device).eval()
log(f"[条件] exp93b encoder_best.pt(epoch={ck.get('epoch')},**冻结**)")
fte = torch.load(E93B.FEAT_TEST, map_location="cpu", weights_only=False)
IMG_test = fte["img_features"].numpy().astype(np.float64)
group, neg = make_candidate_structure(len(IMG_test))
log(f"[候选] {len(IMG_test)} 张未见图 ⇒ 4 组 × 50;2-way 负样本固定")
per = []
for sid in args.eval_subjects:
try:
rec = eval_subject(model, sid, IMG_test, group, neg, args, device)
per.append(rec)
log(f" ✓ {rec['subject']}: 200-way {rec['acc200']:.4f}|50-way {rec['acc50']:.4f}"
f"|2-way {rec['acc2']:.4f}(试次平均:{rec['acc200_avg']:.4f} / "
f"{rec['acc50_avg']:.4f} / {rec['acc2_avg']:.4f})")
except Exception as e:
rec = {"subject": f"sub-{sid:02d}", "error": f"{type(e).__name__}: {e}"[:300],
"trace": traceback.format_exc()[-600:]}
log(f" ✗ {rec['error']}")
per.append(rec)
verdict = decide(per)
payload = {"config": vars(args), "atm_reference": ATM_REF, "per_subject": per,
"verdict": verdict}
(out_dir / "exp101_condition.json").write_text(
json.dumps(payload, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
for r in per:
print("===REC===" + json.dumps(r, ensure_ascii=False, default=str), flush=True)
print("===VERDICT===" + json.dumps(verdict, ensure_ascii=False, default=str), flush=True)
log(f"\n=== 判决:{verdict['verdict']} ===")
log(f" {verdict.get('why', '')}")
log(f"结果写入 {out_dir}/exp101_condition.json")
return 0
if __name__ == "__main__":
sys.exit(main())

Xet Storage Details

Size:
16.1 kB
·
Xet hash:
e0ab2df44590b0f4ca90e86feecc2bfd3784d4fd6e6a3ddaf54fdf4aaf9147f4

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.