ginka-generator/需要重训的问题.md

16 KiB
Raw Permalink Blame History

需要重训才能解决的问题

01-sampler-fix.patch 只改推理采样。下面这些是训练侧的问题,改完必须重跑训练才能生效。

证据来源:checkpoint sep-320.pth(epoch 320)、数据集 6731 张、服务器 RTX 5090 实测。 代码基准:GitHub 103b02c(服务器上的本地版本另有 LAMBDA_ADJ/LAMBDA_PATCH 归零的未提交改动)。

按「值不值得做」排序。


A. 距离场整条通路是死的 —— 1069 万参数在空转

现在的表现

dist_encoder + dist_quantizer 从头到尾没起过任何作用。z_dist 对所有输入都是同一个常数向量

实测证据

① 码本只剩 1 个码字

dist_quantizer  K=16
  perplexity      = 1.00 / 16     利用率 6.2%
  ema_cluster_size 里只有 14 号非零,占比 100.0%
  16 个码字的范数: [0,0,0,0,0,0,0,0,0,0,0,0,0,0, 7.696, 0]
                                                 ↑ 只有这一个活着

其余 15 个码字范数是 0 —— 被 EMA 拉到了原点。

② 编码器输出本身就没有区分度

拿 500 张互不相同的地图(496/500 距离场各不相同)过一遍编码器:

dist_encoder vq1(对照) 差距
z_e 每维在样本间的标准差 0.007046 0.656193 93 倍
不同样本 z_e 之间的距离(中位) 0.01475 2.87652 195 倍
z_e 自身的范数 7.6967(标准差 0.0002)

500 张不同地图编出来的 z_e 几乎完全相同 —— 相对变化只有 0.2%

③ commit_loss 已经趋零

commit_loss = 0.000059
z_e 到最近码字的距离 : 0.0567
z_e 到次近码字的距离 : 7.6967    ← 其余码字都在原点,永远选不中

根本原因(两层)

第一层:距离场这个特征在这个数据集上本来就没信息。

距离 = 0   51.78%   (墙)
距离 = 1   44.78%   (紧挨着墙的空地)
距离 = 2    3.37%
距离 = 3    0.07%
距离 = 4    0.00%

96.6% 的格子距离值不是 0 就是 1。 每张图的最大距离均值只有 2.01,不同取值种类均值 3.01DIST_VOCAB 设了 13(0~12),实际只用到 0~4。

原因是真实地图墙密度 51.6% —— 墙无处不在,任何空地几乎都紧挨着墙。距离场退化成了"墙 / 非墙"的二值图,跟原图的墙层几乎是同一个东西,提供不了"离墙多远"的层次信息。

这个特征的设计假设(地图有大片开阔区域)在这个数据集上不成立。

第二层:EMA 死锁,不可逆。

所有 z_e 落到同一个码字
      ↓
其余码字的 ema_cluster_size 每步 × 0.99 衰减
      ↓
normalized_weight = ema_weight / cluster_size → 0,码字被拉到原点
      ↓
原点离任何 z_e 都是 7.70,永远不可能再被选中
      ↓
commit_loss ≈ 0,梯度趋零,编码器停止学习

commit_loss 这一项本身就在奖励塌缩 —— 塌缩之后 loss 完美,系统再也不会离开这个状态。

空转的规模

模块 参数量 体积
dist_encoder 10,689,600 40.8 MiB
dist_quantizer 2,064

对比 mg1 本身是 3492 万。每个 batch 白跑一次 6 层 Transformer 的前向 + 反向。

而且 commit 3cd38b8 feat: 距离场输入 之后又把它从 d_model=256 / 3层 加到 384 / 6层L_DIST 从 4 加到 8 —— 加的全是空转的量

改什么

方案 1(最省事):整条删掉。

z_dist 是常数,经过 z_dist_proj 再进 cond_proj(线性层)—— 常数输入到线性层等价于一个偏置项。所以删掉之后模型行为完全不变,纯粹省资源。

需要动的地方:build_model 里去掉 dist_encoder/dist_quantizer,GinkaMaskGITz_dist_len 设 0 或去掉这个输入,dataset.py 不再算 distance_field

方案 2:换一个真正有区分度的空间特征。

数据集里现成就有没被用的(见问题 G):roomCounthighDegBranchCountval(16 维,6098 种取值)。

方案 3:保留距离场,但必须同时做问题 B 的防塌缩。 不做防塌缩,换什么特征都可能重蹈覆辙。

为什么要重训

删掉/替换条件通道会改变模型的输入结构,权重不兼容。

预期效果

  • 方案 1:生成质量完全不变(可实测验证),训练每步省一次 1069 万参数的前向反向。这一条把握度 100%,因为 z_dist 已经证明是常数。
  • 方案 2/3:能不能改善生成质量未知,取决于新特征是否真的携带 stage1 需要的信息。

B. 码本 EMA 死锁正在慢慢吃掉 quantizer1

现在的表现

quantizer1 是 stage1 唯一活着的条件通道(z_dist 已死),但它只用了 37.2% 的码本容量。

实测证据

码本 负责 perplexity 利用率 存活码字
quantizer1 stage1 墙 11.89 / 32 37.2% 16 / 32
quantizer2 stage2 门/怪/入口 15.38 / 16 96.1% 16 / 16
quantizer3 stage3 资源 5.98 / 16 37.3% 6 / 16
dist_quantizer 距离场 1.00 / 16 6.2% 1 / 16

quantizer1 死掉的 16 个码字范数为 0,和 A 是同一个 EMA 死锁机制,只是还没吃完。

有效信息量:24 码字 × log₂(11.89) ≈ 86 bit,设计上限是 24 × log₂32 = 120 bit,用掉 71%

这条和症状的对应关系

阶段 条件通道状态 表现
stage1 墙 z1 用掉 71% + z_dist 完全死掉
stage2 门/怪/入口 z2 96.1% 健康
stage3 资源 z3 37.3% 好(资源本来不需要结构)

唯一条件通道健康的阶段,正好是效果好的那个。

根本原因

VectorQuantizer 只有 EMA 更新,没有任何防塌缩机制:

VQ_GAMMA = 0.0 # entropy loss 权重(当前未启用)      ← train_seperated.py:45

VQDecodeHead(vqvae/model.py:33)—— 那个用 z 还原原图、逼 z 必须编码地图内容的重建头 —— train_seperated.py 里从头到尾没被 import 过

被删掉的 train_full.sh 里"阶段 0 重建预训练 + 阶段 1 冻结 VQ 热身"正是为了防这个,那套流程随脚本一起失效了。

改什么

① 死码重启(dead-code restart) —— 最直接。在 VectorQuantizer.ema_update 里加:

# cluster_size 长期低于阈值的码字,重新初始化到某个真实 z_e 附近
dead = self.ema_cluster_size < DEAD_THRESHOLD          # 比如 0.5
if dead.any():
    src = z_flat[torch.randint(0, z_flat.size(0), (int(dead.sum()),))]
    self.codebook.weight.data[dead] = src
    self.ema_weight[dead] = src
    self.ema_cluster_size[dead] = 1.0

VQ_GAMMA 从 0 调回 0.1,启用熵损失,鼓励码字使用均匀。

③ 或者恢复 train_full.sh 的两段式课程:先用 VQDecodeHead 做重建预训练,再冻结 VQ 训 MaskGIT。

为什么要重训

码本状态是训练出来的,已经死掉的码字在现有 checkpoint 上救不回来(范数为 0,离任何 z_e 都是 7.7)。

预期效果

quantizer1 利用率 37% → 期望 70%+,z1 的信息量从 86 bit 往 120 bit 靠。

但对生成质量的实际改善幅度未知 —— 更多的 z 容量能不能变成更好的墙,取决于问题 E(条件注入方式)是不是瓶颈。这一条我没有实验依据。


C. 码本监控缺了一半

现在的表现

dist_quantizer 塌缩了 320 个 epoch,训练日志一个字都没提。

实测证据

E: 334 | ... | PPL: 31.1291 | Usage: 0.6094 (39/64)

64 = VQ_K1 + VQ_K2 + VQ_K3 = 32 + 16 + 16dist_quantizer 根本不在统计里。

而且合并统计会掩盖问题:PPL 31.1/64 看着健康,拆开是 37.2% / 96.1% / 37.3%。

改什么

① 把 dist_quantizer 的命中也统计进去。

② 分开打印每个码本,而不是合并。 合并的 PPL 没有诊断价值。

③ 加一条离线诊断脚本,直接从 .pthema_cluster_sizecodebook.weight,不用跑前向:

ck = torch.load("result/seperated/sep-XXX.pth", map_location="cpu")
for n in ["quantizer1","quantizer2","quantizer3","dist_quantizer"]:
    cs = ck[n]["ema_cluster_size"]; p = cs/cs.sum()
    ppl = float(torch.exp(-(p*torch.log(p.clamp_min(1e-10))).sum()))
    print(f"{n}  ppl={ppl:.2f}/{cs.numel()}  alive={int((cs>1).sum())}")

(11-码本权重.pth 就是这么导出来的,46 KB,可以直接跑这段验证。)

为什么要重训

监控本身不用重训 —— 这一条现在就能加。但它是发现前两个问题的前提。


D. CE 在全部 169 格上算,一半是「抄写」

现在的表现

日志里的 CE 数值有很大一部分是"把可见 token 原样抄出来"的准确率,不反映生成能力。

实测证据

def cross_entropy_loss(logits, target):
    return F.cross_entropy(logits.permute(0, 2, 1), target)   # 全部 169 格

没有 ignore_index,没有按掩码位置筛选。标准 MaskGIT 只在被掩位置算 loss。

dataset.py 的掩码分布模拟 2 万次:mg1 的输入平均有 51.6% 的格子是可见的。也就是说约一半的 loss 是"看见 1 就输出 1"的恒等映射任务。

注水比例在三个 stage 之间还不一样 —— mg1 可见格最多,注水最严重。

改什么

def cross_entropy_loss(logits, target, inp=None):
    if inp is None:
        return F.cross_entropy(logits.permute(0, 2, 1), target)
    t = target.clone()
    t[inp != MASK_TOKEN] = -100          # 只在掩码位算
    return F.cross_entropy(logits.permute(0, 2, 1), t, ignore_index=-100)

adjacency_loss / patch_loss 同理(虽然它们的权重现在是 0)。

为什么要重训

改的是训练目标。

预期效果

梯度不再被恒等映射任务稀释,而且日志里的 CE 终于能反映真实的生成能力

对生成质量的改善幅度未知。但即使质量不变,这一条也值得做 —— 它让后续所有实验的指标变得可信。现在没有任何一个数字能反映生成质量。


E. loss 里没有任何连通性约束

现在的表现

真实数据里 6731 张地图 100% 地板连通(无一例外)—— 这是最强的规律。而模型从来没被要求学它。

修复采样器之后单次生成的可用率是 75%,剩下 25% 靠拒绝采样兜。

实测证据

服务器上的实际配置:

LAMBDA_ADJ1/2/3   = 0
LAMBDA_PATCH1/2/3 = 0
loss = CE1 + CE2 + CE3 + commit

只剩逐格交叉熵。 而 CE 对"墙挪一格把房间封死"和"墙挪一格无所谓"的惩罚完全一样。

(顺带:原来的 patch_loss 数值上就等于 CE —— 归一化高斯核卷积后再求全局均值 ≈ 直接求均值。我在真实训练中实测过:PAT 0.2354 vs CE 0.2351,差 0.13%。所以那个"邻域结构损失"从来没引入过任何结构信息,把权重设成 0 是对的。)

改什么

可微的连通性损失很难做。三个方向:

① 软连通性代理:对 floor 概率图做若干轮软膨胀,统计最大连通块的期望占比,作为 loss。

② 保持现状,靠推理侧兜底 —— 就是 01-sampler-fix.patch 里的拒绝采样 + repair_connectivity已经能做到 100%,而且不用重训。

③ 用数据集里现成的拓扑字段做监督(见问题 G)。

为什么要重训

①③ 改训练目标。②不用重训。

预期效果

建议先不做。 推理侧兜底已经解决了这个问题(可用率 100%),投入产出比不高。等其他问题解决之后再看单次可用率能不能自己提上去。


F. 条件通过全局 AdaLN 注入,传不了空间信息(推测,无实验证据)

现在的表现

从零生成时,169 个位置的输入完全相同(全是 MASK),只有位置编码不同。要区分出哪一格是墙,全靠一个全局向量。

代码位置

ginka/maskGIT/model.py:80-81:

cond_seq = torch.cat([z_proj, zd_proj, e_struct, e_remain], dim=1)
c = self.cond_proj(cond_seq.reshape(cond_seq.size(0), -1))   # [B, d_model]

所有条件 token 被 flatten 成一个 d_model 维向量,再通过 AdaLN 注入每一层。

AdaLN 只能做全局的 scale / shift。也就是说 z1 那 86 bit 只能以"全局风格"的形式影响输出,没有任何机制说"第 42 格应该是墙"

改什么

改成 cross-attention:z 作为 memory,地图 token 作为 query。这样每个位置能各自去 z 里取自己需要的信息。

设计文档 vqvae-maskgit-design.md 里写的就是 cross-attention,代码里实现成了 flatten + AdaLN。

为什么要重训

改模型结构,权重完全不兼容。

把握度

这一条是推测。 有理论依据(全局向量确实无法编码空间位置),但我没有实验证据证明它是主要瓶颈,也不知道改完能改善多少。

建议放到最后 —— 工程量最大(改结构 + 完整重训),而且前面几条做完之后瓶颈可能已经转移。


G. 数据集里三个字段完全没用上

实测证据

ginka-dataset.json 每条记录的字段:

map, size, val, symmetry, outerWall, roomCount, highDegBranchCount

dataset.py 只读了 mapouterWall

字段 内容 取值种类
val 16 维浮点向量 6098 种(6731 张里)
roomCount 房间数 17 种
highDegBranchCount 高度分支数 35 种
symmetry 三位对称性 5 种(dataset.py 自己重算了一遍)

这些是数据侧已经算好的拓扑信息 —— 恰恰是"墙该怎么排"的直接监督信号,现在全被丢掉了。

改什么

roomCount / highDegBranchCount 加进 struct_inject(现在只有对称性 3 bit + outerWall 1 bit),或者作为额外的条件 token。val 那 16 维如果是拓扑特征向量,可以直接当条件。

这也可能是问题 A 的替代品 —— 比距离场有信息量得多(6098 种取值 vs 距离场的 3 种)。

为什么要重训

改条件输入。

预期效果

未知,但这是所有方案里信息量最明确的一个。 值得优先于 F 尝试。


H. 两个小问题

H-1 子集 3 是空操作,20% 的数据是重复的

def apply_subset3(self, raw):
    out = self.apply_subset2(raw)
    out[0][out[0] == self.ENTRANCE] = self.MASK_ID   # out[0] 是 inp1
    return out

inp1 来自 target1,而 create_degreaded(dataset.py:212)已经把 ENTRANCE(5) 降级成 0 了:

self.degrade_tile(target1, [DOOR, SPECIAL_DOOR, RESOURCE, MONSTER, ENTRANCE])

inp1 的取值只可能是 {0, 1, 7},里面根本没有 5 已验证 (inp1 == 5).sum() == 0

后果:设计文档里的"子集 D:入口条件生成"实际不存在,SUBSET_WEIGHTS = (0.5, 0.3, 0.2) 实际等价于 (0.5, 0.5)

改法:要么补上真正的入口掩码逻辑(在 target1 里保留 ENTRANCE),要么删掉这个子集、把权重改成 (0.5, 0.5) 让配置反映实际。

H-2 数据集有 5.23% 重复

6731 张里唯一的只有 6379 张,重复 352 张。

不算严重,但去重是免费的。


建议顺序

顺序 做什么 要重训 把握
0 加码本监控(问题 C) 确定 —— 一行的事,而且是发现前两个问题的前提
1 删掉距离场通路(问题 A 方案 1) (但只为省资源) 确定 —— 已证明 z_dist 是常数,删掉行为不变
2 CE 只在掩码位算(问题 D) 中 —— 质量改善未知,但让所有指标变可信
3 加死码重启 + VQ_GAMMA=0.1(问题 B) 中 —— 码本利用率必涨,质量改善未知
4 用上 roomCount / val(问题 G) 中 —— 信息量最明确的一条
5 修子集 3(问题 H-1) 低,但顺手
6 条件注入改 cross-attention(问题 F) 未验证,工程量最大,建议最后
连通性损失(问题 E) 建议不做 —— 推理侧兜底已达 100%

1~3 可以一起改、跑一次训练。 4 单独一次好做对比。


一句话总结

stage1 的两条条件通道,一条(z_dist)完全死了、一条(z1)只用了 37%,而 stage2 的那条 96% 健康 —— 这和「墙差、怪好」的症状完全对应。

采样器修复能把可用率做到 100%,但生成的墙比真实地图碎(墙连通块 8.2 vs 真实 5.7),那部分要靠上面这些。