16 KiB
需要重训才能解决的问题
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.01。DIST_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,GinkaMaskGIT 的 z_dist_len 设 0 或去掉这个输入,dataset.py 不再算 distance_field。
方案 2:换一个真正有区分度的空间特征。
数据集里现成就有没被用的(见问题 G):roomCount、highDegBranchCount、val(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 + 16。dist_quantizer 根本不在统计里。
而且合并统计会掩盖问题:PPL 31.1/64 看着健康,拆开是 37.2% / 96.1% / 37.3%。
改什么
① 把 dist_quantizer 的命中也统计进去。
② 分开打印每个码本,而不是合并。 合并的 PPL 没有诊断价值。
③ 加一条离线诊断脚本,直接从 .pth 读 ema_cluster_size 和 codebook.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 只读了 map 和 outerWall。
| 字段 | 内容 | 取值种类 |
|---|---|---|
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),那部分要靠上面这些。