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

446 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 需要重训才能解决的问题
`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 更新,**没有任何防塌缩机制**:
```python
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` 里加:
```python
# 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`,不用跑前向:
```python
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 原样抄出来"的准确率,不反映生成能力。
### 实测证据
```python
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 可见格最多,注水最严重。
### 改什么
```python
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% 靠拒绝采样兜。
### 实测证据
服务器上的实际配置:
```python
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`:
```python
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% 的数据是重复的
```python
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` 了:
```python
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),那部分要靠上面这些。