基于DCT频谱与Transformer的算式图像跨模态分析及词袋故障排查
1. 从"文字算式"到"频谱画布":这个项目到底在折腾什么
第一次看到"文字算式到字体化图像"这个说法,很多人会以为是OCR或者公式识别。其实不是。这个项目的核心思路是反过来的:把一段用文字写出来的算式,比如3x + 2y = 12,先转成一张"字体化"的灰度图像,再对这张图像做频域分析,用DCT(离散余弦变换)把图像变成一块"频谱画布",最后拿一个小型Transformer模型去学习"算式文本"和"频谱画布"之间的对应关系。
说白了,这是一条跨模态的对照实验链路:文本侧是符号序列,图像侧是频谱矩阵,中间靠位置编码和词袋(Bag of Words)做桥接。关键词里出现的位置编码、词袋、Transformer、DCT、交叉熵,基本就是这条链路的五个关键节点。
为什么值得做这件事?因为大多数人在学Transformer的时候,都是拿标准数据集跑分类或者翻译,跑通了也不知道模型到底学到了什么。而这个项目把输入换成了自己可控的"文字算式",输出换成了自己生成的"频谱图像",整个数据分布是你自己造的,模型学没学到东西、哪里出了问题,一眼就能看出来。它特别适合两类人:一是想真正搞懂Transformer内部机制、不想只当调包侠的开发者;二是需要做文档图像分析、公式检索、字体特征提取这类任务,想找一个轻量级切入点的工程人员。
我这次要分享的,不只是"怎么跑通",而是一次真实的词袋故障排查过程——模型训练loss不降、准确率卡在随机水平,最后定位到是词袋构建阶段出了问题。这个坑很典型,很多人第一次做文本+图像对照任务都会踩。
2. 频谱画布是怎么造出来的:DCT这一步不能想当然
2.1 为什么选DCT而不是FFT
把字体化图像转成频域,第一反应通常是FFT(快速傅里叶变换)。但我在这个项目里用的是DCT,原因有三个,都是实际踩出来的:
- DCT的输出是实数。FFT输出复数,你得额外处理实部和虚部,要么取模要么拆通道,多一层麻烦。DCT直接给你实数矩阵,喂给神经网络更省事。
- DCT的能量集中性更好。对于字体图像这种以低频轮廓为主的内容,DCT能把绝大部分能量压缩到左上角,这意味着你可以只保留前若干系数就还原出主要结构,天然适合做降维。
- 边界连续性。DCT隐含了偶对称延拓,图像边缘不会像FFT那样产生明显的高频泄漏,字体笔画的边缘在频谱上更"干净"。
代码上,from scipy.fftpack import dct是最常用的入口。注意scipy.fftpack.dct和scipy.fft.dct是两个不同模块,前者是旧接口,后者是新接口,参数命名有差异。我建议用scipy.fft.dct,因为fftpack在新版本里已经标记为legacy。
import numpy as np from scipy.fft import dct def image_to_spectrum(gray_img, keep_ratio=0.25): # gray_img: 2D array, float in [0,1] # 先做2D DCT,等价于先对行做DCT再对列做DCT spec = dct(dct(gray_img, axis=0, norm='ortho'), axis=1, norm='ortho') # 只保留左上角低频块 h, w = spec.shape kh, kw = int(h * keep_ratio), int(w * keep_ratio) mask = np.zeros_like(spec) mask[:kh, :kw] = 1.0 return spec * mask这里有个细节:norm='ortho'一定要加。不加的话DCT是正交变换的缩放版本,不同尺寸图像得到的系数尺度不一致,后面做归一化会很痛苦。加了ortho之后,变换是保能量的,频谱矩阵的数值范围更稳定。
2.2 字体化图像的生成:别小看渲染这一步
"字体化图像"不是随便拿PIL画个字就完事。字体、字号、抗锯齿、边距、二值化阈值,每一个都会影响最终的频谱分布。我试过三种渲染方案:
| 方案 | 工具 | 优点 | 缺点 |
|---|---|---|---|
| PIL ImageDraw | Pillow | 轻量、快 | 字体度量不精确,居中困难 |
| matplotlib text | matplotlib | 排版方便 | 依赖重,输出带坐标轴需裁剪 |
| freetype-py | FreeType | 字形度量精确 | 上手成本高 |
最后我选了Pillow,但做了两件事来保证一致性:一是固定画布尺寸为128x128,二是用textbbox先测量文字包围盒,再计算居中偏移。抗锯齿用默认的L模式(8位灰度),不要用1位二值图,因为二值化会引入大量高频噪声,DCT之后频谱会变得很"脏"。
提示:如果你的算式文本长度差异很大,建议统一padding到固定长度再渲染,否则不同样本的字体大小不一致,频谱分布会引入额外的方差,模型很难学。
2.3 频谱画布的归一化:log变换是必须的
DCT系数的动态范围极大,左上角低频系数可能是几百,右下角高频系数可能只有0.01。直接送进网络,梯度会被大值主导。标准做法是做log压缩:
def normalize_spectrum(spec): spec_abs = np.abs(spec) spec_log = np.log1p(spec_abs) # 逐样本标准化 mu, sigma = spec_log.mean(), spec_log.std() + 1e-6 return (spec_log - mu) / sigmalog1p而不是log,是因为频谱里可能有接近0的值,log(0)会炸。这个细节看起来小,但我在第一次跑的时候就是因为用了np.log,出现了NaN,训练直接崩了。
3. 位置编码与词袋:两条完全不同的"顺序观"
3.1 位置编码解决的是"顺序丢失"问题
Transformer的自注意力机制本身是置换不变的——你把输入序列打乱,输出只是跟着打乱,模型感知不到顺序。所以必须显式注入位置信息。标准做法是正弦位置编码:
def sinusoidal_pe(seq_len, d_model): pe = np.zeros((seq_len, d_model)) pos = np.arange(seq_len)[:, None] div = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model)) pe[:, 0::2] = np.sin(pos * div) pe[:, 1::2] = np.cos(pos * div) return pe这个设计的好处是:不同位置之间的相对关系可以通过线性变换表达,模型容易学到"相邻"和"远离"的概念。但要注意,正弦编码是固定的,不参与训练。如果你用的是可学习位置编码(nn.Embedding),那参数量会随序列长度线性增长,短序列任务里两者差别不大,长序列任务里正弦编码更省参数。
3.2 词袋是另一套逻辑:它主动丢弃顺序
词袋模型(Bag of Words)的核心假设是:文本的语义由词频决定,词序不重要。这跟位置编码的假设是直接冲突的。一个说"顺序很重要,我要编码它",另一个说"顺序不重要,我只要词频"。
那为什么这个项目里两个都用?因为它们是作用在不同模态上的:
- 文本侧用词袋,把算式文本转成固定维度的词频向量,作为对照的"标签"或"条件"。
- 图像侧(频谱画布)用位置编码,因为频谱矩阵展平之后是有空间顺序的,需要保留。
这个设计本身没问题,但坑就出在词袋的构建上——这也是我后面要重点讲的故障。
3.3 词袋构建的三个致命细节
第一,词表必须固定。训练集构建的词表,验证集和测试集必须复用同一个。我见过有人每个batch重新fit一次CountVectorizer,结果每个batch的向量维度都不一样,模型根本没法训。
第二,tokenization策略要统一。算式文本3x + 2y = 12,你是按空格切,还是按字符切,还是用正则把数字和符号分开?不同策略得到的词表完全不同。我建议对算式类文本用字符级+符号级混合切分:数字连续串作为一个token,运算符和变量各作为一个token。
第三,未知词(OOV)的处理。如果测试时出现了训练词表里没有的token,你是丢弃、映射到<UNK>,还是动态扩展词表?动态扩展会导致维度变化,绝对不能用。正确做法是预留<UNK>,所有OOV映射过去。
4. 一次词袋故障的完整排查链路
4.1 现象:loss不降,准确率卡在随机水平
训练日志大概是这样:
Epoch 1: loss=2.302, acc=0.101 Epoch 5: loss=2.298, acc=0.098 Epoch 10: loss=2.301, acc=0.102loss几乎不动,准确率在10%左右——而我的分类类别正好是10类。这就是典型的"模型什么都没学到"。
4.2 第一步排查:数据本身有没有问题
我先检查了输入数据。把几个样本的频谱画布可视化出来,发现图像是有区分度的,不同算式的频谱确实长得不一样。文本侧的词袋向量打印出来,也能看到非零值。数据不是全零,也不是全相同。
这一步排除了"数据加载错误"和"标签错位"。
4.3 第二步排查:模型能不能过拟合一个小数据集
这是我最推荐的一招:拿10个样本,让模型去背。如果模型连10个样本都过拟合不了,那问题一定在模型或数据管道,不在数据分布。
结果:10个样本,训练100轮,loss还是不动。这就说明问题不在数据量,而在更底层的地方。
4.4 第三步排查:梯度去哪了
我打印了每一层的梯度范数,发现词袋输入那一层的梯度全是0。这就锁定了问题范围:词袋向量没有对loss产生任何贡献。
继续往下查,我把词袋向量单独拿出来看,发现了一个诡异的现象:所有样本的词袋向量几乎一模一样。打印出来是这样的:
sample 1: [0.0, 0.0, 0.0, 0.0, 0.0, ...] sample 2: [0.0, 0.0, 0.0, 0.0, 0.0, ...]全是零。词袋向量是空的。
4.5 根因:CountVectorizer的vocabulary参数被静默忽略
回到词袋构建代码,我是这么写的:
from sklearn.feature_extraction.text import CountVectorizer vec = CountVectorizer(vocabulary=train_vocab) X = vec.fit_transform(texts)问题出在:CountVectorizer在传入vocabulary参数时,fit阶段会直接用你给的词表,但如果你的train_vocab是一个list而不是dict,sklearn在某些版本里会静默处理成空词表,或者抛一个不明显的warning。我当时传的是一个从训练集统计出来的list,结果fit_transform返回的矩阵全是0。
正确做法是传一个{token: index}的dict:
train_vocab = {tok: i for i, tok in enumerate(sorted(all_tokens))} vec = CountVectorizer(vocabulary=train_vocab, tokenizer=lambda x: x.split()) X = vec.transform(texts) # 注意用transform而不是fit_transform而且构建好之后,一定要做一次断言检查:
assert X.shape[1] == len(train_vocab), "词表维度不匹配" assert X.nnz > 0, "词袋矩阵全零,检查tokenization"这两行断言,能帮你省掉至少半天debug时间。
4.6 修复后的验证
改完之后重新训练,第一个epoch loss就从2.3降到了1.8,第10个epoch准确率到了0.65。梯度范数检查也正常了,词袋输入层的梯度不再是0。
这个坑的教训是:sklearn的API在参数类型不对时,不一定会报错,可能只是静默给你一个错误结果。凡是涉及词表、维度、索引的地方,都要加断言。
5. 交叉熵之外:这个任务该怎么选损失函数
5.1 分类任务用交叉熵没错,但要注意label smoothing
如果最终任务是"给定频谱画布,预测算式属于哪一类",那交叉熵是标准选择。但我在实验里发现,直接用硬标签的交叉熵,模型容易过拟合到训练集里的高频类别。加一点label smoothing(比如0.1)能明显改善泛化:
import torch.nn.functional as F loss = F.cross_entropy(logits, targets, label_smoothing=0.1)label smoothing的本质是告诉模型"别太自信",把目标分布从one-hot软化。对于类别边界模糊的任务(比如不同算式频谱长得很像),这个技巧特别有用。
5.2 如果要做回归:MSE和频谱距离
如果你不是做分类,而是想让模型直接预测频谱系数(回归任务),那交叉熵就不适用了。这时候可以考虑:
- MSE:简单直接,但对高频系数不敏感,因为高频系数数值小。
- 加权MSE:给低频系数更高权重,因为低频承载主要结构信息。
- 频谱距离:直接比较预测频谱和真实频谱的DCT域距离,物理意义更明确。
我个人的经验是,如果频谱画布是中间表示而不是最终目标,用分类+交叉熵更稳;如果频谱本身就是你要预测的东西,那加权MSE更合适。
5.3 一个容易忽略的点:温度系数
如果你用的是对比学习或者带softmax的相似度任务,温度系数(temperature)对结果影响极大。温度太低,分布太尖锐,梯度消失;温度太高,分布太平,区分度不够。我一般从0.07开始试,这是对比学习里的常用默认值。
6. 小模型跑频谱任务的工程经验
6.1 模型规模:别一上来就上大模型
这个项目的定位是"小模型",所以d_model我设的是64,层数2层,注意力头4个。总参数量大概几十万。这个规模在128x128的频谱输入上完全够用。我试过把d_model加到256,效果没有明显提升,但训练时间翻了3倍。
小模型的优势是:训练快、容易调试、过拟合风险低。对于这种自造数据的对照实验,小模型反而更容易看出机制层面的问题。
6.2 频谱输入的patch化处理
频谱画布是2D的,直接展平成一维序列会丢失空间结构。我的做法是切成patch,每个patch展平后作为一个token,再叠加2D位置编码。这跟ViT的思路一致:
def patchify(spec, patch_size=16): # spec: (H, W) h, w = spec.shape patches = spec.reshape(h // patch_size, patch_size, w // patch_size, patch_size) patches = patches.transpose(0, 2, 1, 3).reshape(-1, patch_size * patch_size) return patches # (num_patches, patch_dim)patch_size的选择有讲究:太小,序列太长,注意力计算量大;太大,空间分辨率不够。128x128的图,patch_size=16得到64个patch,这个长度对小模型很友好。
6.3 训练稳定性:warmup和梯度裁剪
Transformer类模型训练初期不稳定,尤其是学习率设大了容易发散。我固定用两个技巧:
- warmup:前10%的step线性升温,之后余弦衰减。
- 梯度裁剪:
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)。
这两个加起来,基本能保证训练曲线平滑。如果还是抖,先把学习率降到1e-4再试。
6.4 一个反直觉的发现:词袋维度不是越高越好
我一开始把词表设得很大,把所有出现过的token都收进去,维度到了2000多。结果模型在小数据集上严重过拟合。后来把词表压缩到只保留高频token(出现次数>=3),维度降到200左右,效果反而更好。
原因是:词袋向量是稀疏的,高维稀疏输入对小型模型来说是负担,大部分维度都是0,梯度信号很弱。压缩词表相当于做了一次特征选择。
7. 这套机制还能怎么扩展
把"文字算式→字体化图像→频谱画布→小Transformer"这条链路跑通之后,我发现它的可扩展性比想象中强。
第一个方向是换模态。文字算式可以换成化学式、乐谱、电路符号,只要是"符号序列+可视化渲染"的组合,都能套这套流程。频谱画布也不一定用DCT,换成小波变换(wavelet)或者Gabor滤波,能得到不同特性的频域表示。
第二个方向是换任务。现在是分类,可以改成检索(给定频谱找最相似的算式)、生成(给定频谱生成算式文本)、或者异常检测(找出频谱异常的样本)。任务一变,损失函数和评估指标跟着变,但底层的数据管道和模型骨架可以复用。
第三个方向是机制对照。这个项目最有价值的地方,其实是它提供了一个可控的实验平台。你可以固定其他变量,单独改一个机制——比如把正弦位置编码换成可学习编码,把词袋换成TF-IDF,把交叉熵换成focal loss——然后观察频谱画布上的模型行为怎么变。这种对照实验,比在标准数据集上刷点有意义得多。
我自己在扩展实验里试过把词袋换成字符级n-gram,发现对于短算式文本,n-gram(n=2或3)比纯词袋更能捕捉"3x"和"x3"这种顺序敏感的模式。这也侧面印证了:词袋丢顺序是有代价的,只是在当前任务里这个代价被频谱侧的强特征掩盖了。
最后分享一个实操小技巧:每次改完数据管道,先跑一个"10样本过拟合测试",确认模型能背下来,再去跑全量训练。这个习惯帮我省了无数次无效训练。词袋故障那次,如果我一开始就做这个测试,能早两个小时定位到问题。