Buckets:

redmingwei's picture
download
raw
44.6 kB
#!/usr/bin/env python3
r"""exp102_gen.py — exp102(②③ 脑→图像条件生成)的**判据脚本**:生成 + 评测 + 判决。
## 判据正本
`FINDINGS_TIER0_RSA.md` **§四十四**(§四十四·三 的表格 = 判据,§四十四·五 = 实现约定,
§四十四·六 = 出结果前的修订留痕)。**本脚本实现它,一字不改**。
| # | 判据 | 阈值 |
|---|---|---|
| C0 | 管线自检:训练损失下降(末 10% epoch 均值 < 首 10%)**且** 生成的图不是常数(图间成对距离中位 > 0)| 不过 ⇒ 全实验作废 |
| C1(主判据)| 逐被试 2-way(生成图↔真图 vs 生成图↔另一个候选)显著高于**地板**(**跨被试 + 跨试次**错配)| ≥8 被试、p ≤ 0.05、中位 > 0(配对符号检验)|
| C1b(只报)| 被试特异性:地板换成「另一个被试、**同一概念**的 EEG」(C1 原口径)| 只报 |
| C2 | 50-way top-1 显著高于地板(地板口径同 C1)| ≥8 被试、p ≤ 0.05、中位 > 0 |
| C3(只报)| ① 传递效率 ② CLIP 相似度 ③ ATM 并排(C 类参照)④ 每概念召回 | — |
## 三个关键设计(都在 §四十四·五 里定死了)
1. **地板生成零额外算力**:被试 s 的目标 i 的地板图 = `own(s+1, (i+37)%200)`
(donor 被试、另一个概念)⇒ 它**就是**被试 s+1 自己那批生成图 ⇒ **不用多生成一张**。
2. **不做 CFG**(guidance scale = 1.0)⇒ 不需要 null 条件分支 ⇒ 算力减半。
3. **同一个 CLIP 同时编「生成图」与「真图」** ⇒ 两边同空间;条件侧 2-way 也用同一个
CLIP 重算 ⇒ **传递效率自洽**;exp101 的已发表数字只作**并列参照**。
## 三个 step
- `--step generate`(GPU,可分片):test EEG → 冻结 encoder → 条件 → SD1.5 出图 →
CLIP 编码 → 落盘(生成特征 + test 条件 + 真图特征 + 样例 PNG + C0 的图像间距离)。
- `--step judge`(CPU):读各被试产物 ⇒ **纯函数**判决 ⇒ 逐被试原始记录 + judge_log。
- `--self-test`:合成数据覆盖「通过 / 不通过 / 方向对但检验力不足 / 中位 ≤ 0」四种情形。
## 用法
```powershell
# 本地(自测)
python research\brain_domains\exp\exp102_gen.py --self-test
# 云端
python -u /domains/scripts/exp102_gen.py --step generate --subjects 1 2 3 4 5 \
-v hf://buckets/illusion-brain/training-data:/training:ro \
-v hf://buckets/illusion-brain/brain-domains:/domains --flavor a10g-large --timeout 60m
python -u /domains/scripts/exp102_gen.py --step judge --out-dir /domains/runs/.../exp102
```
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from pathlib import Path
import numpy as np
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1")
EXP_DIR = Path(__file__).resolve().parent
if str(EXP_DIR) not in sys.path:
sys.path.insert(0, str(EXP_DIR))
import exp101_condition as E101 # noqa: E402 (med_up / sign_test_nonzero / 候选集)
# ===========================================================================
# §四十四·五 定死的口径
# ===========================================================================
TEST_SUBJECTS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
TEST_4D = Path("/domains/omnibrain4d/eeg")
IMAGE_SET = Path("/training/eeg/things-eeg/Image_set")
FEAT_TEST = "/training/eeg/eeg-image-decode/ViT-H-14_features_test.pt"
ENCODER_CKPT = "/domains/runs/illusionbrain-align/exp93b/encoder_best.pt"
DEFAULT_OUT = Path("/domains/runs/illusionbrain-align/exp102")
RES = 512 # §四十四·五:512×512
DDIM_STEPS = 30 # §四十四·五:DDIM 30 步
GUIDANCE = 1.0 # §四十四·五:guidance scale = 1.0(不做 CFG)
SEED_BASE = 20261027 # 生成种子基准(§四十四·五:种子由 (donor 被试, 概念号) 决定)
# 地板:donor 被试 = s+1(环),概念偏移 = +37
FLOOR_DONOR_OFFSET = 1
FLOOR_CONCEPT_OFFSET = 37
MIN_SUBJECTS = 8 # §四十四·三
ALPHA = 0.05 # §四十四·三
CHANCE_2 = 0.5
CHANCE_50 = 1.0 / 50.0
# C 类参照(别人发表的系统 ⇒ 只作量级参照,不是同协议对标)
ATM_REF = {"two_way_pct": 83.1, "fifty_way_top1_pct": 28.4, "fid": 24.6}
# exp101(我们的条件侧)已发表数字 ⇒ 并列参照
EXP101_REF = {"two_way_pct_single_trial": 78.09, "two_way_pct_trial_avg": 92.00,
"fifty_way_pct_single_trial": 14.83, "fifty_way_pct_trial_avg": 42.00,
"two_hundred_way_pct_single_trial": 5.89}
# CLIP(**必须 1024 维** ⇒ 冻结 encoder 的输出维度才接得上)
CLIP_CANDIDATES = ("laion/CLIP-ViT-H-14-laion2B-s32B-b79K",)
def log(*a):
print(*a, flush=True)
# ===========================================================================
# 工具
# ===========================================================================
def l2norm(x, axis=-1):
n = np.linalg.norm(x, axis=axis, keepdims=True)
return x / np.maximum(n, 1e-12)
def gen_seed(donor_subject, concept):
"""§四十四·五:种子由 (donor 被试, 概念号) 决定 ⇒ own 与地板共用同一套种子分布。"""
return int((SEED_BASE + int(donor_subject) * 100003 + int(concept) * 7919) % (2 ** 31 - 1))
def floor_index(concept, n_img=200):
"""地板图在 donor 那批里的行号(跨试次错配:概念 ≠ 当前目标)。"""
return int((int(concept) + FLOOR_CONCEPT_OFFSET) % n_img)
def donor_subject(subject, n_subjects=len(TEST_SUBJECTS)):
"""C1b / 地板用的 donor 被试(环)。"""
i = TEST_SUBJECTS.index(subject)
return TEST_SUBJECTS[(i + FLOOR_DONOR_OFFSET) % n_subjects]
def two_way_acc(gen_feat, true_feat, neg):
"""逐行 2-way:生成图 ↔ 真图 vs 生成图 ↔ 负例。gen_feat/true_feat 已 L2 归一。"""
sim = gen_feat @ true_feat.T
t = np.arange(len(gen_feat))
return float((sim[t, t] > sim[t, neg]).mean())
def fifty_way_acc(gen_feat, true_feat, group):
"""50-way top-1:只在**同组**的 50 个候选里比。
⚠️⚠️ **2026-10-28 修一个真 bug**(云端 judge 第一次真跑时炸出来的):
原第 141 行 `rows = np.flatnonzero(group == np.arange(len(group))[group == gi])`
⇒ `group`(200)与右边(组大小 50)**广播不上** ⇒ ValueError。
⇒ 而且**自测没覆盖 50-way 这条路径** ⇒ 22/22 自测全绿却带着这个 bug 上云。
⇒ 正确含义:生成图第 i 行对应真图第 i 行 ⇒ 「第 i 行该在哪些候选里比」
⇒ 就是 `group == gi` 的那些下标(rows 与 cols 相同)。
"""
sim = gen_feat @ true_feat.T
mask = np.zeros_like(sim, dtype=bool)
for gi in np.unique(group):
cols = np.flatnonzero(group == gi)
rows = cols # ⇒ 行 i 对应真图 i ⇒ 同组候选 = 同组下标
mask[np.ix_(rows, cols)] = True
simm = np.where(mask, sim, -np.inf)
return float((simm.argmax(axis=1) == np.arange(len(sim))).mean())
def build_candidate_structure(n_img=200):
"""沿用 exp101 的候选集(同一 seed ⇒ 可比)。"""
group, neg = E101.make_candidate_structure(n_img, n_groups=4, seed=0)
return np.asarray(group), np.asarray(neg)
# ===========================================================================
# 判决(**纯函数**:输入逐被试原始记录 ⇒ 输出判决)
# ===========================================================================
def judge(records, min_subjects=MIN_SUBJECTS, alpha=ALPHA):
"""records: [{subject, own2, floor2, own50, floor50, cross_subject2, clip_sim,
img_pair_dist, cond2_same_clip}, ...]"""
out = {}
ok = [r for r in records if r.get("ok", True)]
if not ok:
return {"verdict": "INSUFFICIENT", "why": "没有可用被试"}
# ---- C0 管线自检 ----
loss = ok[0].get("loss_summary") or {}
dists = [r.get("img_pair_dist") for r in ok if r.get("img_pair_dist") is not None]
med_dist = E101.med_up(dists) if dists else float("nan")
c0 = {"loss_decreased": bool(loss.get("decreased")),
"loss_first10": loss.get("first10_mean"),
"loss_last10": loss.get("last10_mean"),
"img_pair_dist_median": med_dist,
"images_not_constant": bool(dists and med_dist > 0)}
out["C0"] = c0
out["C0_pass"] = bool(c0["loss_decreased"] and c0["images_not_constant"])
if not out["C0_pass"]:
out["verdict"] = "C0_FAIL"
out["why"] = ("训练损失没降" if not c0["loss_decreased"] else "") + \
("生成的图是常数" if not c0["images_not_constant"] else "")
return out
# ---- C1(主判据):own2 − floor2 ----
# ⚠️⚠️ **2026-10-28 修**:原来用「键是否存在」过滤 ⇒ 被试的 `floor2` 为 **None**
# (它的地板来自被剔除的 donor)时 ⇒ `own2 - None` ⇒ **TypeError**。
# ⇒ 改成按**值是否为 None**过滤(并由 judge step 把这种被试标成 `ok=False`)。
c1 = {}
diffs = [r["own2"] - r["floor2"] for r in ok
if r.get("own2") is not None and r.get("floor2") is not None]
if diffs:
v = E101.paired_test(diffs, "two_way_minus_floor", 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(x.get("pass") for x in c1.values())
# ---- C1b(只报):被试特异性 = own2(s) − own2(donor 同概念) ----
c1b = {}
diffs_b = [r["own2"] - r["cross_subject2"] for r in ok
if r.get("own2") is not None and r.get("cross_subject2") is not None]
if diffs_b:
E101.paired_test(diffs_b, "two_way_minus_cross_subject", c1b)
out["C1b_report"] = c1b
out["C1b_mean_diff"] = float(np.mean(diffs_b)) if diffs_b else None
# ---- C2:50-way ----
c2 = {}
diffs2 = [r["own50"] - r["floor50"] for r in ok
if r.get("own50") is not None and r.get("floor50") is not None]
if diffs2:
v = E101.paired_test(diffs2, "fifty_way_minus_floor", 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(x.get("pass") for x in c2.values())
# ---- C3(只报)----
own2_med = E101.med_up([r["own2"] for r in ok if r.get("own2") is not None])
floor2_med = E101.med_up([r["floor2"] for r in ok if r.get("floor2") is not None])
cond2_med = E101.med_up([r["cond2_same_clip"] for r in ok
if r.get("cond2_same_clip") is not None])
eff = (own2_med / cond2_med) if (cond2_med and cond2_med > 0) else None
out["C3_report"] = {
"transfer_efficiency": eff,
"transfer_efficiency_def": "生成的 2-way 中位 ÷ 条件的 2-way 中位(**同一个 CLIP** 下的口径)",
"generated_2way_median": own2_med,
"condition_2way_median_same_clip": cond2_med,
"floor_2way_median": floor2_med,
"clip_similarity_mean": float(np.mean([r["clip_sim"] for r in ok if "clip_sim" in r]))
if any("clip_sim" in r for r in ok) else None,
"per_concept_recall_median": float(np.median(
[r["per_concept_recall"] for r in ok if r.get("per_concept_recall") is not None]))
if any(r.get("per_concept_recall") is not None for r in ok) else None,
"atm_reference_C_class": ATM_REF,
"exp101_condition_reference": EXP101_REF,
}
# ---- 总判决 ----
if out["C1_pass"] and out["C2_pass"]:
out["verdict"] = "CONDITION_DRIVES_GENERATION"
elif out["C1_pass"]:
out["verdict"] = "CONDITION_DRIVES_GENERATION_50WAY_WEAK"
else:
med = c1.get("two_way_minus_floor", {}).get("median")
if med is not None and med > 0:
out["verdict"] = "DIRECTION_RIGHT_UNDERPOWERED"
else:
out["verdict"] = "CONDITION_DOES_NOT_DRIVE_GENERATION"
out["interpretation"] = (
"生成 2-way > 地板但不显著 ⇒ 方向对、检验力不足" if (med or 0) > 0
else "生成 2-way ≤ 地板 ⇒ 条件驱动不了生成")
return out
# ===========================================================================
# 生成 step
# ===========================================================================
def find_test_image_paths():
"""发现 test split 的 200 张图(**自发现 schema,不写死路径**)。
⇒ 目录结构:`Image_set/test_images/<概念号_概念名>/<图名>.jpg`,每个概念目录
恰好 1 张 ⇒ 按概念号排序即与特征行序一致(与 exp93b 的行号约定一致)。
"""
root = IMAGE_SET / "test_images"
if not root.exists():
root = IMAGE_SET
per_concept = {}
for p in sorted(root.rglob("*.jpg")) + sorted(root.rglob("*.png")):
try:
con = int(p.parent.name.split("_")[0])
except Exception:
continue
per_concept.setdefault(con, []).append(p)
paths = []
for con in sorted(per_concept):
paths.append(sorted(per_concept[con])[0])
return paths
def load_clip(device):
"""加载 ViT-H-14(**1024 维**)。失败就报错,不静默换模型。"""
import torch
from transformers import CLIPModel, CLIPProcessor
import exp102_train as T
T.ensure_deps()
last = None
for repo in CLIP_CANDIDATES:
try:
model = CLIPModel.from_pretrained(repo, cache_dir="/domains/hf_cache")
proc = CLIPProcessor.from_pretrained(repo, cache_dir="/domains/hf_cache")
model = model.to(device).eval()
log(f"[CLIP] ✓ {repo}")
return model, proc
except Exception as e: # noqa: BLE001
last = e
log(f"[CLIP] ⚠ {repo} 不可用:{type(e).__name__}: {str(e)[:140]}")
raise RuntimeError(f"CLIP 取不到(候选全失败):{last}")
def clip_encode(model, proc, images, device, batch=16, expect_dim=1024):
"""用 **同一个** CLIP 编码器编「生成图」与「真图」⇒ 两边同空间。
⚠️ transformers 5.x 里 `get_image_features()` 返回的是 **BaseModelOutputWithPooling**
(不是张量)⇒ 要取**投影后**的 `image_embeds`(ViT-H-14 的投影维 = 1024
⇒ 正好接得上 exp93b 冻结 encoder 的 1024 维输出)⇒
⇒ 维度必须断言:若拿到的是 1280 维的 hidden state(不是投影)⇒ 立刻报错,
否则「生成侧 1280 维 vs 条件侧 1024 维」会让 2-way 比较**静默失效**。
"""
import torch
feats = []
with torch.no_grad():
for i in range(0, len(images), batch):
chunk = images[i:i + batch]
inp = proc(images=chunk, return_tensors="pt").to(device)
out = model.get_image_features(**inp)
if not torch.is_tensor(out):
emb = getattr(out, "image_embeds", None)
if emb is None:
pooler = getattr(out, "pooler_output", None)
emb = pooler if torch.is_tensor(pooler) else out[0]
out = emb
if out.shape[-1] != expect_dim:
raise RuntimeError(
f"CLIP 特征维度 {out.shape[-1]} ≠ 期望 {expect_dim}"
f"(拿到的是 hidden state 而不是投影后的 image_embeds?"
f"⇒ 会让 2-way 比较静默失效 ⇒ 停)")
feats.append(out.float().cpu().numpy())
return l2norm(np.concatenate(feats, axis=0).astype(np.float64))
def step_generate(args):
import torch
import exp102_train as T
T.ensure_deps() # ⚠️ 必须在 `from diffusers import ...` **之前**(镜像里没有 diffusion 全家桶)
from diffusers import DDIMScheduler
from PIL import Image
import exp93b_contrastive as E93B
device = "cuda" if torch.cuda.is_available() else "cpu"
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
log(f"[生成] 设备 {device}|分片 {args.shard}/{args.n_shards}|被试 {args.subjects}")
# ---- 冻结的脑侧 encoder(exp93b)----
ck = torch.load(args.encoder_ckpt, map_location="cpu", weights_only=False)
encoder = E93B.build_model(n_ch=ck["n_ch"], n_times=ck["n_times"], out_dim=ck["out_dim"])
encoder.load_state_dict(ck["model"] if "model" in ck else ck["state_dict"])
encoder = encoder.to(device).eval()
for p in encoder.parameters():
p.requires_grad_(False)
log("[encoder] exp93b encoder_best.pt 已冻结加载")
# ---- adapter ----
adapter = T.build_adapter().to(device)
ck2 = torch.load(args.adapter, map_location="cpu", weights_only=False)
adapter.load_state_dict(ck2["state_dict"] if "state_dict" in ck2 else ck2)
adapter = adapter.eval()
for p in adapter.parameters():
p.requires_grad_(False)
log(f"[adapter] {args.adapter} 已加载")
# ---- SD1.5(**权重用 fp32** + autocast bf16 ⇒ 与训练脚本同一套,避免 fp16/fp32 混用崩)----
unet = T.load_sd_component("unet", dtype=torch.float32, device=device).eval()
vae = T.load_sd_component("vae", dtype=torch.float32, device=device).eval()
tok = T.load_sd_component("tokenizer")
txt = T.load_sd_component("text_encoder", dtype=torch.float32, device=device).eval()
sched = DDIMScheduler.from_pretrained(
"stable-diffusion-v1-5/stable-diffusion-v1-5", subfolder="scheduler",
cache_dir="/domains/hf_cache")
sched.set_timesteps(DDIM_STEPS, device=device)
log(f"[SD] UNet/VAE/text-encoder 全冻结|DDIM {DDIM_STEPS} 步|guidance {GUIDANCE}")
# ---- 文本 embedding(固定提示词)----
with torch.no_grad():
ids = tok([T.PROMPT], padding="max_length", max_length=77, truncation=True,
return_tensors="pt").input_ids.to(device)
text_emb = txt(ids)[0] # (1, 77, 768) fp32
clip_model, clip_proc = load_clip(device)
# ---- 真图(test split):路径自发现 + CLIP 编码 ----
true_paths = find_test_image_paths()
log(f"[真图] 自发现 {len(true_paths)} 张 test 图")
if len(true_paths) != 200:
# ⚠️ 宁可失败,也不要静默换真图特征空间(生成侧用本地 CLIP,
# 真图侧若用另一套特征 ⇒ 两个空间不可比 ⇒ 判决无效)。
raise RuntimeError(
f"test split 的图自发现得到 {len(true_paths)} 张(期望 200)⇒ "
f"不能用缓存特征当真图侧(特征空间会不一致)⇒ 先查 Image_set 布局")
imgs = [Image.open(p).convert("RGB").resize((RES, RES), Image.BICUBIC)
for p in true_paths]
true_clip = clip_encode(clip_model, clip_proc, imgs, device)
np.save(out_dir / "true_clip.npy", true_clip)
with open(out_dir / "true_clip_paths.json", "w", encoding="utf-8") as f:
json.dump([str(p) for p in true_paths], f, ensure_ascii=False, indent=1)
# ---- 逐被试:条件 → 生成 → CLIP ----
n_img = 200
lo = n_img * args.shard // args.n_shards
hi = n_img * (args.shard + 1) // args.n_shards
log(f"[生成] 概念区间 [{lo}, {hi})")
# 被剔除的被试(对齐闸门没过)⇒ 续跑时要**继承**(否则第二次跑会丢掉剔除记录)
excl_path = out_dir / "gen_excluded_subjects.json"
excluded = []
if excl_path.exists():
try:
excluded = json.loads(excl_path.read_text(encoding="utf-8"))
log(f"[剔除] 沿用已有的剔除记录:{[e.get('subject') for e in excluded]}")
except Exception as e: # noqa: BLE001
log(f"[剔除] 读已有剔除记录失败(按空处理):{type(e).__name__}: {e}")
for sid in args.subjects:
fp = out_dir / f"gen_clip_sub{sid:02d}.pt"
if fp.exists() and not args.overwrite:
log(f"[跳过] sub-{sid:02d} 已有产物")
continue
# 条件:test EEG(取 rep 0 ⇒ 每被试每概念 1 个试次)
eeg = np.load(TEST_4D / f"sub-{sid:02d}.npz")["eeg"]
log(f"[sub-{sid:02d}] test EEG {eeg.shape}")
flat = np.asarray(eeg[:n_img, 0], dtype=np.float32)
with torch.no_grad():
z = E93B.encode_all(encoder, flat, device, args.batch)
cond = z.astype(np.float32)
torch.save({"cond": torch.from_numpy(cond)}, out_dir / f"test_cond_sub{sid:02d}.pt")
feats = []
pix = []
samples = []
for i in range(lo, hi):
g = torch.Generator(device="cpu").manual_seed(gen_seed(sid, i))
lat = torch.randn((1, 4, RES // 8, RES // 8), generator=g,
dtype=torch.float32).to(device) * sched.init_noise_sigma
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
c = torch.from_numpy(cond[i:i + 1]).to(device, torch.float32)
tokens = adapter(c) # (1, 16, 768) fp32
enc = torch.cat([text_emb, tokens], dim=1) # (1, 77+16, 768)
for t in sched.timesteps:
inp = sched.scale_model_input(lat, t)
eps = unet(inp, t, encoder_hidden_states=enc).sample
lat = sched.step(eps.float(), t, lat).prev_sample
img = vae.decode(lat / vae.config.scaling_factor).sample
arr = ((img[0].float().permute(1, 2, 0).cpu().numpy() + 1) * 127.5)
arr = np.clip(arr, 0, 255).astype(np.uint8)
pil = Image.fromarray(arr)
if len(samples) < 8:
samples.append(pil.copy())
feats.append(clip_encode(clip_model, clip_proc, [pil], device)[0])
if len(pix) < 16:
pix.append(arr[::16, ::16, :].astype(np.float32))
if (i - lo) % 25 == 0:
log(f" [sub-{sid:02d}] {i}/{hi}")
# 样例拼图(**给人看的**:生成图 + 对应真图,各 8 张)
try:
if samples:
grid = Image.new("RGB", (256 * 4, 256 * 4), (255, 255, 255))
for j, sm in enumerate(samples[:8]):
grid.paste(sm.resize((256, 256)), (256 * (j % 4), 256 * (j // 4)))
for j, sm in enumerate(samples[:8]):
ti = lo + j
if ti < len(true_paths):
grid.paste(Image.open(true_paths[ti]).convert("RGB").resize((256, 256)),
(256 * (j % 4), 256 * 2 + 256 * (j // 4)))
grid.save(out_dir / f"gen_samples_sub{sid:02d}.jpg", quality=88)
except Exception as e: # noqa: BLE001
log(f"[样例] 生成图拼图失败(不影响判决):{e}")
feats = l2norm(np.stack(feats, axis=0))
# ---- **对齐闸门**:真图顺序若与 EEG 行序对不上 ⇒ 该被试的 2-way/50-way 全无意义 ----
# 判据:冻结 encoder 的 200-way top-1 必须显著高于随机(1/200 = 0.005)
# ⇒ 参照 exp101 的同口径数字 0.0589(管线零漂移)。
#
# ⚠️⚠️ **2026-10-27 修订(出结果前改 ⇒ 正当修订,须留痕,见 §四十四·六)**:
# 原实现是 `raise RuntimeError` ⇒ **一个被试不过 ⇒ 整场中止**。
# 实测:sub-05 的 rep-0 条件 200-way = 0.0000(其余 9 个被试 0.020~0.075)
# ⇒ 诊断(`exp102_diag_align.py` / `exp102_diag_align2.py`)证明:
# ① `meta.parquet` 的行号→概念号映射对 10/10 被试都是**恒等** ⇒ 不是行序错;
# ② sub-05 的 rep 20/40/60 分别 0.035/0.050/0.030、**80 试次平均 0.1200**
# ⇒ **sub-05 的 EEG 数据是好的,坏的只是 rep 0 这一试次**;
# ③ 换 rep / 试次平均都能救回 ⇒ 数据可用,只是 rep 0 不可用。
# ⇒ 而 §四十四·三 的 C1/C2 只要求「**≥8 个被试**」⇒ 预登记本来就预留了
# 「个别被试不可用」的情形 ⇒ ⇒ 闸门失败 = **该被试不可用**(不是「整场作废」)。
# ⇒ 改成:**剔除该被试 + 写进 `gen_excluded_subjects.json` + 继续跑**。
# ⚠️ 剔除判据 = **预登记的数据质量闸门**,与判决结果无关(不看生成结果)⇒ 不是挑人。
g_all, neg_all = build_candidate_structure(200)
cz = l2norm(cond.astype(np.float64))
sim = cz @ true_clip[:len(cz)].T
acc200 = float((sim.argmax(axis=1) == np.arange(len(cz))).mean())
acc2 = two_way_acc(cz, true_clip[:len(cz)], neg_all[:len(cz)])
log(f"[对齐] sub-{sid:02d}: 200-way top-1={acc200:.4f}(随机 0.0050|"
f"exp101 参照 0.0589)|条件侧 2-way={acc2:.4f}")
if acc200 < 0.01:
excl = {"subject": sid, "align_acc200": acc200, "align_acc2": acc2,
"reason": "对齐闸门未过(200-way < 0.01)⇒ 该被试的条件与真图对不上 "
"⇒ 其 2-way/50-way 无意义 ⇒ **剔除该被试**(判据 = 预登记的"
"数据质量闸门,与判决结果无关)"}
log(f"!! {excl['reason']}")
excluded.append(excl)
with open(out_dir / "gen_excluded_subjects.json", "w", encoding="utf-8") as f:
json.dump(excluded, f, ensure_ascii=False, indent=1)
continue # ⇒ 换下一个被试(不中止整场)
torch.save({"clip": torch.from_numpy(feats.astype(np.float32)),
"lo": lo, "hi": hi, "subject": sid,
"align_acc200": acc200, "align_acc2": acc2},
fp)
# C0 的「图不是常数」:前 16 张的像素两两距离中位
if len(pix) >= 2:
p = np.stack(pix, axis=0).reshape(len(pix), -1)
d = [float(np.linalg.norm(p[i] - p[j]))
for i in range(len(p)) for j in range(i + 1, len(p))]
torch.save({"img_pair_dist_median": float(np.median(d))},
out_dir / f"gen_dist_sub{sid:02d}.pt")
# 样例图(给人看的,8 张拼一张)
try:
from PIL import Image as I2
grid = I2.new("RGB", (256 * 4, 256 * 2))
for j in range(8):
grid.paste(Image.open(true_paths[lo + j]).convert("RGB").resize((256, 256)),
(256 * (j % 4), 256 * (j // 4)))
grid.save(out_dir / f"true_samples_sub{sid:02d}.jpg", quality=88)
except Exception as e: # noqa: BLE001
log(f"[样例] 真图拼图失败(不影响判决):{e}")
log(f"[生成] 完成 ⇒ {out_dir}")
return 0
# ===========================================================================
# judge step
# ===========================================================================
def step_judge(args):
import torch
out_dir = Path(args.out_dir)
records = []
group, neg = build_candidate_structure(200)
true_clip = None
tp = out_dir / "true_clip.npy"
if tp.exists():
true_clip = l2norm(np.load(tp).astype(np.float64))
else:
# ⚠️ **不许**在这里退回缓存的 ViT-H-14 特征:生成侧用的是本地 CLIP,
# 两套特征若不同源 ⇒ 2-way 的两个比较项不在同一空间 ⇒ 判决无效。
log("[真图] ✗ 缺 true_clip.npy ⇒ 生成 step 没跑成功(或真图自发现失败)"
"⇒ 不用缓存特征顶替(特征空间会不一致)⇒ 本轮不判")
return 1
# 条件侧 2-way(**同一个 CLIP** 下的口径 ⇒ 传递效率自洽)
cond2_cache = out_dir / "cond2_same_clip.json"
cond2_all = {}
for sid in TEST_SUBJECTS:
cp = out_dir / f"test_cond_sub{sid:02d}.pt"
if not cp.exists():
continue
c = torch.load(cp, map_location="cpu", weights_only=False)["cond"].numpy().astype(np.float64)
cond2_all[sid] = two_way_acc(l2norm(c), true_clip, neg)
loss_summary = {}
# ⚠️⚠️ **2026-10-28 修一个真 bug**:损失曲线在**训练输出目录**(`exp102/train_curve.json`),
# 而生成产物在 `exp102/gen2/` ⇒ 只在 `out_dir` 里找 ⇒ **找不到** ⇒
# ⇒ C0 的「损失下降」被判成 false ⇒ 判决错成 **C0_FAIL**(其实 C0 是过的:0.080645 → 0.075969)。
# ⇒ 现在**两级都找**(先 out_dir,再 out_dir.parent)。
curve = None
for cand in (out_dir / "train_curve.json", out_dir.parent / "train_curve.json"):
if cand.exists():
curve = cand
break
if curve is None:
log(f"[C0] ⚠ 找不到 train_curve.json(试过 {out_dir / 'train_curve.json'} 与 "
f"{out_dir.parent / 'train_curve.json'})⇒ C0 的「损失下降」将判 false")
else:
log(f"[C0] 损失曲线:{curve}")
try:
import exp102_train as T
# ⚠️⚠️ **2026-10-28 修第三处 bug**:`loss_curve_summary(curve)` 要的是
# **epoch 列表**(`curve` 里每项是 {"mean_loss": ...}),
# 而这里传的是**整个 JSON 字典** ⇒ 迭代字典得到的是**键(字符串)**
# ⇒ `e.get(...)` ⇒ "'str' object has no attribute 'get'" ⇒ 损失摘要读不出来
# ⇒ C0 的「损失下降」判 false ⇒ 判决错成 C0_FAIL。
loss_summary = T.loss_curve_summary(
json.loads(curve.read_text(encoding="utf-8")).get("epochs", []))
except Exception as e: # noqa: BLE001
log(f"[C0] 损失曲线读不出:{type(e).__name__}: {e}")
gen_feats = {}
for sid in TEST_SUBJECTS:
fp = out_dir / f"gen_clip_sub{sid:02d}.pt"
if not fp.exists():
log(f"[judge] ⚠ 缺 sub-{sid:02d} 的生成特征 ⇒ 跳过")
continue
obj = torch.load(fp, map_location="cpu", weights_only=False)
g = l2norm(obj["clip"].numpy().astype(np.float64))
gen_feats[f"sub-{sid:02d}"] = g
lo = int(obj.get("lo", 0))
n = len(g)
# 地板:donor 被试 = s+1,概念 = (i+37)%200 ⇒ **零额外生成**
d = donor_subject(sid)
dp = out_dir / f"gen_clip_sub{d:02d}.pt"
floor_feat = None
if dp.exists():
dobj = torch.load(dp, map_location="cpu", weights_only=False)
dfeat = l2norm(dobj["clip"].numpy().astype(np.float64))
floor_feat = np.stack([dfeat[floor_index(i)] for i in range(n)], axis=0)
own2 = two_way_acc(g, true_clip[:n], neg[:n])
own50 = fifty_way_acc(g, true_clip[:n], group[:n])
rec = {"subject": f"sub-{sid:02d}", "ok": True, "n_images": n,
"shard_lo": lo,
"own2": own2, "own50": own50,
"floor2": two_way_acc(floor_feat, true_clip[:n], neg[:n]) if floor_feat is not None else None,
"floor50": fifty_way_acc(floor_feat, true_clip[:n], group[:n]) if floor_feat is not None else None,
"cross_subject2": None, # 下面用 donor 的 own2 覆盖(C1b 的地板口径)
"cond2_same_clip": cond2_all.get(sid),
"clip_sim": float(np.mean(np.sum(g * true_clip[:n], axis=1))),
"loss_summary": loss_summary}
if floor_feat is None:
# 它的地板 = donor(=s+1,被试环)自己那批生成图 ⇒ donor 被剔除 ⇒ **地板不存在**
# ⇒ ⇒ 这个被试**不能进配对统计**(own2 仍如实报,只是不参与 C1/C2 的配对)
rec["ok"] = False
rec["ok_reason"] = (f"地板缺失:地板来自 donor sub-{d:02d},而它被剔除"
f"(对齐闸门未过)⇒ 它的 2-way/50-way 没有地板可比")
log(f"[judge] ⚠ sub-{sid:02d} 没有地板(donor sub-{d:02d} 被剔除)"
f"⇒ 不进配对统计(own2 仍报)")
dp_obj = out_dir / f"gen_dist_sub{sid:02d}.pt"
if dp_obj.exists():
rec["img_pair_dist"] = torch.load(dp_obj, map_location="cpu",
weights_only=False)["img_pair_dist_median"]
records.append(rec)
# C1b 的「跨被试同概念」地板 = donor 被试**自己的** 2-way(同一概念配对)
by_sub = {r["subject"]: r for r in records}
for r in records:
sid = int(r["subject"].split("-")[1])
d = donor_subject(sid)
dr = by_sub.get(f"sub-{d:02d}")
r["cross_subject2"] = dr["own2"] if dr else None
r["cross_subject_subject"] = f"sub-{d:02d}"
# 每概念召回(只报):**每个被试自己的**生成图对每个概念,
# 「更靠近真图而不是负例」的比例 ⇒ 再对概念取中位。
recall = None
if gen_feats:
subs = sorted(gen_feats.keys())
n_min = min(len(gen_feats[s]) for s in subs)
per_concept = []
for i in range(n_min):
hit = 0
for s in subs:
g = gen_feats[s]
s_true = g[i] @ true_clip[i]
s_neg = g[i] @ true_clip[neg[i]]
hit += int(s_true > s_neg)
per_concept.append(hit / len(subs))
if per_concept:
recall = float(np.median(per_concept))
for r in records:
r["per_concept_recall"] = recall
verdict = judge(records)
# 被剔除的被试(对齐闸门没过)⇒ **必须写进判决**(不许悄悄少一个被试)
excluded = []
excl_path = out_dir / "gen_excluded_subjects.json"
if excl_path.exists():
try:
excluded = json.loads(excl_path.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
log(f"[judge] 剔除记录读不出:{type(e).__name__}: {e}")
used_subjects = [r["subject"] for r in records]
excl_subjects = [e.get("subject") for e in excluded]
out = {"verdict": verdict, "records": records,
"n_subjects_used": len(used_subjects),
"subjects_used": used_subjects,
"subjects_excluded": excl_subjects,
"exclusion_reason": excluded,
"atm_reference_C_class": ATM_REF,
"exp101_condition_reference": EXP101_REF}
if excl_subjects:
log(f"[judge] ⚠ 有被试被剔除:{excl_subjects}"
f"(判据 = 预登记的对齐闸门;与判决结果无关)"
f"⇒ 统计单元 = {len(used_subjects)} 个(§四十四·三 要求 ≥ {MIN_SUBJECTS})")
# 落盘:逐被试原始记录(**离线可重算**)+ 判决
res_dir = Path(args.results_dir)
res_dir.mkdir(parents=True, exist_ok=True)
if records:
cols = ["subject", "n_images", "own2", "floor2", "own50", "floor50",
"cross_subject2", "cross_subject_subject", "cond2_same_clip",
"clip_sim", "img_pair_dist", "per_concept_recall"]
with open(res_dir / "per_subject.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
w.writeheader()
for r in records:
w.writerow(r)
(res_dir / "verdict.json").write_text(
json.dumps(out, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
# C0 的「损失下降」是**整轮量**(不在逐被试表里)⇒ 单独落盘 ⇒ 离线重算要读它
(res_dir / "loss_summary.json").write_text(
json.dumps(loss_summary, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
lines = []
lines.append("=== exp102 判据(§四十四)===")
lines.append(f"判决: {verdict.get('verdict')}")
lines.append(f"C0: {json.dumps(verdict.get('C0'), ensure_ascii=False)}")
lines.append(f"C1: {json.dumps(verdict.get('C1'), ensure_ascii=False)}")
lines.append(f"C1b: {json.dumps(verdict.get('C1b_report'), ensure_ascii=False)}")
lines.append(f"C2: {json.dumps(verdict.get('C2'), ensure_ascii=False)}")
lines.append(f"C3: {json.dumps(verdict.get('C3_report'), ensure_ascii=False)}")
lines.append("")
lines.append("逐被试:")
for r in records:
lines.append(
f" {r['subject']}: own2={r['own2']:.4f} floor2={r['floor2']} "
f"own50={r['own50']:.4f} floor50={r['floor50']} "
f"cross2={r['cross_subject2']} cond2={r['cond2_same_clip']} "
f"clip_sim={r['clip_sim']:.4f}")
(res_dir / "judge_log.txt").write_text("\n".join(lines), encoding="utf-8")
for ln in lines:
log(ln)
log(f"结果写入 {res_dir}")
return 0
# ===========================================================================
# 自测
# ===========================================================================
def _fake_records(n=10, own=0.80, floor=0.52, own50=0.30, floor50=0.02,
cross=0.79, cond=0.78, dist=50.0, loss_ok=True):
recs = []
for i in range(n):
recs.append({"subject": f"sub-{i+1:02d}", "ok": True, "n_images": 200,
"own2": own, "floor2": floor, "own50": own50, "floor50": floor50,
"cross_subject2": cross, "cond2_same_clip": cond,
"clip_sim": 0.2, "img_pair_dist": dist,
"per_concept_recall": 0.8,
"loss_summary": {"decreased": loss_ok, "first10_mean": 1.0,
"last10_mean": 0.5 if loss_ok else 1.2}})
return recs
def self_test():
fail = 0
def chk(name, cond):
nonlocal fail
if cond:
print(f"✓ {name}")
else:
print(f"✗ {name}")
fail += 1
# ① 判决:通过的情形
v = judge(_fake_records())
chk("C0 通过(损失降 + 图不是常数)", v.get("C0_pass") is True)
chk("C1 通过(own2 显著高于地板)", v.get("C1_pass") is True)
chk("C2 通过(own50 显著高于地板)", v.get("C2_pass") is True)
chk("总判决 = CONDITION_DRIVES_GENERATION",
v.get("verdict") == "CONDITION_DRIVES_GENERATION")
chk("传递效率 ≈ 0.80/0.78", abs((v["C3_report"]["transfer_efficiency"] or 0) - 0.80 / 0.78) < 1e-6)
# ② 判决:生成 ≤ 地板 ⇒ 「条件驱动不了生成」
v2 = judge(_fake_records(own=0.50, floor=0.52))
chk("own2 ≤ 地板 ⇒ C1 不过",
v2.get("C1_pass") is False)
chk("own2 ≤ 地板 ⇒ 判决 = CONDITION_DOES_NOT_DRIVE_GENERATION",
v2.get("verdict") == "CONDITION_DOES_NOT_DRIVE_GENERATION")
# ③ 判决:方向对但检验力不足(差为正、但被试数不够)
v3 = judge(_fake_records(n=5, own=0.80, floor=0.52))
chk("被试数 < 8 ⇒ C1 不过", v3.get("C1_pass") is False)
chk("被试数 < 8 ⇒ 判决 = DIRECTION_RIGHT_UNDERPOWERED",
v3.get("verdict") == "DIRECTION_RIGHT_UNDERPOWERED")
# ④ C0 不过 ⇒ 全实验作废
v4 = judge(_fake_records(loss_ok=False))
chk("损失没降 ⇒ C0 不过", v4.get("C0_pass") is False)
chk("损失没降 ⇒ 判决 = C0_FAIL", v4.get("verdict") == "C0_FAIL")
v5 = judge(_fake_records(dist=0.0))
chk("图是常数 ⇒ C0 不过", v5.get("C0_pass") is False)
# ⑤ 统计口径:中位 = 上中位(sorted[n//2])
chk("上中位口径", E101.med_up([1.0, 2.0, 3.0, 4.0]) == 3.0)
n, pos, neg, p, zero = E101.sign_test_nonzero([0.1] * 10)
chk("符号检验:10/10 正 ⇒ p ≤ 0.002(双侧精确)",
abs(p - 2.0 / 1024.0) < 1e-12 and pos == 10 and zero == 0)
# ⑥ 地板口径:跨被试 + 跨试次(概念必须错开)
chk("地板概念确实错开(+37 mod 200)", floor_index(0) == 37 and floor_index(190) == 27)
chk("donor 被试是环(sub-10 → sub-01)",
donor_subject(10) == 1 and donor_subject(1) == 2)
# ⑦ 种子:own 与地板共用同一套分布(同 (donor, 概念) ⇒ 同种子)
chk("种子由 (donor, 概念) 决定 ⇒ 可复现",
gen_seed(3, 17) == gen_seed(3, 17) and gen_seed(3, 17) != gen_seed(4, 17))
# ⑧ 候选集沿用 exp101(同一 seed ⇒ 可比)
g1, n1 = build_candidate_structure(200)
chk("候选集 = 200 张、4 组、每组 50",
len(g1) == 200 and len(set(g1.tolist())) == 4)
chk("2-way 负例不自指", all(n1[i] != i for i in range(200)))
# ⑨ 2-way / 50-way 的计算:造可手算的数据
t = np.eye(4) # 真图 = 单位基
negs = np.array([1, 2, 3, 0])
a_ok = two_way_acc(np.eye(4), t, negs)
chk("2-way 手算一致(4/4:生成图与真图逐一对应)", abs(a_ok - 1.0) < 1e-9)
g_bad = np.array([[0, 1, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]])
a_bad = two_way_acc(g_bad, t, negs)
chk("2-way 手算一致(3/4:第一行的生成图指向了别的候选)",
abs(a_bad - 0.75) < 1e-9)
# ⑨-bis 50-way:**必须在同组内比**(2026-10-28 补)
# ⚠️ 云端 judge 第一次真跑时炸在这条路径上(行索引算式广播不上 ⇒ ValueError),
# 而**自测当时 22/22 全绿** ⇒ ⇒ 说明 50-way 这条路径**当时根本没被自测覆盖**
# ⇒ ⇒ 这类「只在云端真跑才走到」的路径必须补手算用例。
g50, _ = build_candidate_structure(200)
eye = np.eye(200)
chk("50-way:生成图 = 真图 ⇒ 1.0",
abs(fifty_way_acc(eye, eye, g50) - 1.0) < 1e-9)
half = eye.copy()
for gi in np.unique(g50):
cols = np.flatnonzero(g50 == gi)
for c in cols[25:]: # 组内后一半的行 ⇒ 指向组内第一列 ⇒ 错
half[c] = 0.0
half[c, cols[0]] = 1.0
chk("50-way:组内一半的行错位 ⇒ 0.5",
abs(fifty_way_acc(half, eye, g50) - 0.5) < 1e-9)
oog = eye.copy()
oog[0, 100] = 5.0 # 第 0 行在**组外**放了一个更大的值
chk("50-way:组外的更大值不能干扰(只在同组内比)",
abs(fifty_way_acc(oog, eye, g50) - 1.0) < 1e-9)
# ⑩ **无地板**的被试(它的 donor 被剔除 ⇒ 地板不存在)⇒ 不进配对统计,且**不许崩**
# (2026-10-28:donor 被剔除 ⇒ floor2=None ⇒ 原来 `own2 - None` 会 TypeError)
recs10 = _fake_records(n=9)
recs10[0]["floor2"] = None
recs10[0]["floor50"] = None
recs10[0]["cross_subject2"] = None
recs10[0]["ok"] = False
recs10[0]["ok_reason"] = "地板缺失(donor 被剔除)"
v10 = judge(recs10)
chk("无地板的被试不进配对统计(n_eff = 8 而不是 9)",
v10.get("C1", {}).get("two_way_minus_floor", {}).get("n_eff") == 8)
chk("无地板的被试 ⇒ 判决照样给出(不崩)",
v10.get("verdict") == "CONDITION_DRIVES_GENERATION")
print(f"\n{'全部通过' if fail == 0 else f'{fail} 项失败'}")
return 1 if fail else 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--step", choices=["generate", "judge"], default="generate")
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--out-dir", default=str(DEFAULT_OUT))
ap.add_argument("--results-dir", default="/domains/runs/illusionbrain-align/exp102/results")
ap.add_argument("--subjects", type=int, nargs="+", default=list(TEST_SUBJECTS))
ap.add_argument("--shard", type=int, default=0)
ap.add_argument("--n-shards", type=int, default=1)
ap.add_argument("--batch", type=int, default=16)
ap.add_argument("--adapter", default=str(DEFAULT_OUT / "adapter_final.pt"))
ap.add_argument("--encoder-ckpt", default=ENCODER_CKPT)
ap.add_argument("--overwrite", action="store_true")
args = ap.parse_args()
if args.self_test:
return self_test()
if args.step == "generate":
return step_generate(args)
return step_judge(args)
if __name__ == "__main__":
sys.exit(main())

Xet Storage Details

Size:
44.6 kB
·
Xet hash:
e0d8cb9b53abe01bdeca55b1730eceb675ff0a1d63d7db21c31c58ed0aca6f79

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