mirror of
https://github.com/unanmed/ginka-generator.git
synced 2026-08-14 18:12:28 +08:00
446 lines
16 KiB
Markdown
446 lines
16 KiB
Markdown
# 需要重训才能解决的问题
|
||
|
||
`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),那部分要靠上面这些。
|