简介:生成对抗网络在时间序列预测中的应用正受到关注,这份资源面向具备 Python 与深度学习基础、希望尝试用 GAN 生成蔬菜销量“菜票号码”的开发者,也可作为序列预测与传统统计模型之外的拓展方案。压缩包共 7 个文件,含 6 个 Python 脚本和 1 个 xlsx 原始数据文件,整体仅 67KB,脚本覆盖数据加载、生成器与判别器实现、损失函数定义、训练与测试等完整环节;轻量结构便于快速阅读和直接运行。已有 159 人学习。借助该代码,可了解将连续销量数据离散化为菜票号码、通过对抗训练拟合复杂数据分布并输出预测结果的过程,也能参考超参数设置、损失设计以及 MSE、MAE、R² 等评估方式,在此基础上调整结构或迁移至其他时序预测任务,是兼顾入门学习与二次开发的实用资源。
1. 用GAN网络生成菜票号码:从“随机数”到“懂业务规则的随机数”
某餐饮系统的叫号模块需要压测数据,人工录了上千条排队号码后发现规律太明显:高峰时段连号多、低峰时段全是散号,测试根本压不出边界情况。用GAN网络生成菜票号码,不是简单输出几个随机整数,而是把号码当成一个数据分布来学习——训练一个生成器,让它产出和真实叫号记录同分布的新号码。这事听着绕,落地不难,真正的难点在于号码是离散整数,而GAN天然擅长连续数据,所以要从编码、网络结构、损失函数三处做适配。这篇文章写给要做数据增强、压测数据生成、号码仿真的人,先说清方案原理,再给可直接复跑的代码和踩坑记录。
2. 菜票号码的数据结构:先定义生成目标,再选GAN变体
2.1 号码不是纯随机:先拆出可学习的分布特征
要把GAN用在号码生成上,第一步不是搭网络,而是想清楚“真实号码到底长什么样”。我一般会把一张菜票小票上的号码拆成三段:窗口号(2位,01到24)、序号(3位,000到999)、校验位(1位)。窗口号代表哪个出餐口或叫号柜台,序号是当天的排队计数,校验位用于防错。这个结构在餐饮、食堂、机构叫号场景里很常见,改成你们自己的格式也只需要动编码函数。
真实叫号记录有三个分布特征值得GAN去学:第一,窗口冷热不均,某些窗口号出现的频率远高于其他窗口号;第二,序号不是均匀分布,饭点前后集中在030到080这种区间,低峰时段零零散散;第三,校验位在真实系统里几乎均匀分布,因为它是由前面几位算出来的。把这三点作为生成目标,GAN学到的就不是“随机吐数字”,而是“像真实记录一样的号码”。
很多入门教程直接拿随机整数训练GAN,结果生成器学到了均匀分布,看起来像随机数,实际上完全没学到业务特征。菜票号码的核心价值恰恰在“非均匀”上——压测系统时需要高峰密集、窗口集中这类真实负载形态,纯随机数根本复现不了。所以定义生成目标时一定要带上业务侧写,否则后面训练、评估全白费。
2.2 三个离散号码连续化方案:选最简单可微的那个
GAN从随机噪声生成数据,输出层通常要求连续可导,而菜票号码是整数序列,这个矛盾必须先在数据管线上解决。常见做法有三类:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| one-hot | 每个数字展开成向量 | 语义清晰 | 维度爆炸,10位号码变成上百维,训练极慢 |
| embedding | 用可学习向量表映射 | 表达力强 | 引入查表操作,生成器输出要先取最近邻,梯度断裂 |
| 归一化连续值 | 把每个整数缩放到[-1,1] | 端到端可微 | 量化有误差,需要后处理修正 |
我实际跑下来,最省事的是归一化连续值。以窗口号为例:真实值是1到24,除以24再乘2减1,得到[-1,1]区间的连续数;序号0到999同理。生成器输出连续数,训练时可以直接算loss,生成时再做逆变换取整。量化误差是有的,但窗口号和序号位数少,误差最多导致边界值越界,后处理一节会专门讲怎么兜底。
采用归一化方案还有一个附带好处:生成器的输出层可以用tanh激活函数,天然把输出限制在[-1,1]区间,不会出现极端数值导致训练梯度爆炸。embedding方案虽然表达力更强,但在“短序列号码”这个场景里收益很小,反而多出梯度断裂的麻烦——生成器输出向量与某一行的索引之间的argmax操作不可导,新手很容易在那里翻车。先选简单的,跑通了有需要再换。
2.3 损失函数选型:标准GAN在这里为什么容易崩
标准GAN用JS散度衡量真实分布和生成分布的差异,当两个分布支撑集不重叠时,JS散度是常数,判别器饱和,生成器拿不到有效梯度。号码分布是离散点集,训练初期生成器输出和真实号码差异很大,两个分布在数值空间里几乎不相交,这种情况正好踩中标准GAN的短板——判别器很快认出所有生成样本,生成器梯度消失,loss不再下降。
所以在菜票号码这类离散数据上,我一般用WGAN-GP替代标准GAN。WGAN把损失换成Wasserstein距离,即使两个分布不重叠,距离依然连续,梯度始终存在;梯度惩罚项则保证了判别器满足Lipschitz约束,训练更稳定。核心改动有三处:判别器最后一层去掉sigmoid、判别器每更新几次生成器才更新一次、loss计算方式改成Critic Loss形式。
实践里还有个容易被忽略的细节:WGAN-GP对梯度惩罚系数的敏感度高于学习率。系数设太小,判别器约束不足,loss照样飘;设太大,判别器更新缓慢,生成器学不动。后面避坑章节会给出具体数值范围。选型结论:标准GAN可以跑通小demo,但要做压测数据生成,建议直接用WGAN-GP,省下来的调参时间远超多写的那几行代码。
3. 本地跑通最小实现:数据准备与完整训练脚本
3.1 造训练数据:别直接拿线上库训练
训练GAN需要大量真实号码记录,但实际环境里线上叫号数据往往涉业务隐私,接口也未必开放。常见做法是先用规则合成一份仿真数据用于模型开发,等模型验证完再视情况拿小批量真实数据做微调。合成数据虽然不如真实数据精细,但能完整覆盖窗口冷热、时段集中两个关键分布特征,对压测场景足够。
import numpy as np import pandas as pd # 设定随机种子,保证训练可复现 np.random.seed(42) def synthesize_tickets(n=8000): records = [] for _ in range(n): # 高峰期窗口集中在4-8号与16-20号,低峰期分散 if np.random.rand() < 0.7: window = int(np.random.choice(np.arange(4, 9).tolist() + np.arange(16, 21).tolist())) else: window = int(np.random.randint(1, 25)) # 高峰序号集中在030-080,低峰均匀散布 if np.random.rand() < 0.65: seq = int(np.random.randint(30, 81)) else: seq = int(np.random.randint(0, 1000)) records.append((window, seq)) return pd.DataFrame(records, columns=["window", "seq"]) df = synthesize_tickets()合成逻辑里,窗口号和序号都做了概率分层:70%概率引到热门窗口区间,65%概率引到高峰序号区间。这样合成的数据分布和真实叫号记录在形态上是接近的——有聚集也有分散。生成数据后需要做归一化,把窗口号映射到[-1,1]:
def normalize(x, low, high): return (x - low) / (high - low) * 2 - 1 # 特征归一化,后续模型输入为2维向量 X = np.stack([ normalize(df["window"].values, 1, 24), normalize(df["seq"].values, 0, 999) ], axis=1).astype(np.float32)这里把归一化范围写进函数而不是写死数值,目的是后续如果号码格式调整,只需要改边界参数。归一化是GAN训练里极容易出错的一步:如果忘记缩放,生成器输出层根本无法稳定拟合数值范围,loss曲线会呈现剧烈震荡。训练数据集不需要太大,8000条足够学到分布形态,更多数据边际收益递减。
3.2 生成器与判别器搭建:小而稳才是关键
菜票号码是低维数据,网络结构不需要堆很深。生成器输入是128维随机噪声,经过三层全连接逐步升维到2维输出;判别器相反,从2维输入逐步降维到1维输出。中间层统一用256个神经元,激活函数用LeakyReLU避免负区间梯度死亡。
import torch import torch.nn as nn class Generator(nn.Module): def __init__(self, noise_dim=128, hidden_dim=256): super().__init__() self.net = nn.Sequential( nn.Linear(noise_dim, hidden_dim), nn.LeakyReLU(0.2), nn.Linear(hidden_dim, hidden_dim), nn.LeakyReLU(0.2), nn.Linear(hidden_dim, 2), nn.Tanh() # 输出限制在[-1,1] ) def forward(self, z): return self.net(z) class Discriminator(nn.Module): def __init__(self, input_dim=2, hidden_dim=256): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.LeakyReLU(0.2), nn.Linear(hidden_dim, hidden_dim), nn.LeakyReLU(0.2), nn.Linear(hidden_dim, 1) # WGAN-GP不接sigmoid ) def forward(self, x): return self.net(x)生成器输出层用tanh,与数据归一化范围[$-1,1]$严格对应;判别器最后一层不接sigmoid,这是WGAN-GP的硬性要求,接上反而会让训练不稳定。隐藏层维度选256是因为输入维度太低,再大容易过拟合到训练集的噪声上,再小则拟合不了冷热窗口的分布形态。LeakyReLU的斜率设0.2是常见默认值,歧视负区间的梯度不至于完全阻断。
3.3 训练主循环:五个关键参数决定成败
训练WGAN-GP需要固定4个参数:判别器迭代次数n_critic、梯度惩罚系数lambda、学习率lr、batch size。这些数值直接影响训练稳定性和生成质量,下面代码给出的是经过多轮验证可用的组合。
def train_wgan_gp(generator, discriminator, train_data, epochs=200, batch_size=128): g_optim = torch.optim.Adam(generator.parameters(), lr=1e-4, betas=(0.5, 0.9)) d_optim = torch.optim.Adam(discriminator.parameters(), lr=1e-4, betas=(0.5, 0.9)) data_tensor = torch.tensor(train_data) dataset_size = len(data_tensor) for epoch in range(epochs): for i in range(0, dataset_size, batch_size): real = data_tensor[i:i + batch_size] # 判别器更新足够多步,确保生成器拿到的梯度有质量 for _ in range(5): d_optim.zero_grad() z = torch.randn(batch_size, 128) fake = generator(z) real_score = discriminator(real) fake_score = discriminator(fake.detach()) # WGAN距离:最大化真实与生成的期望差 d_loss = fake_score.mean() - real_score.mean() # 梯度惩罚项 alpha = torch.rand(real.size(0), 1) interp = (alpha * real + (1 - alpha) * fake.detach()).requires_grad_(True) interp_score = discriminator(interp) grad = torch.autograd.grad( outputs=interp_score, inputs=interp, grad_outputs=torch.ones_like(interp_score), create_graph=True )[0] penalty = (grad.norm(2, dim=1) - 1).pow(2).mean() * 10 (d_loss + penalty).backward() d_optim.step() # 生成器更新 g_optim.zero_grad() z = torch.randn(batch_size, 128) fake_score = discriminator(generator(z)) g_loss = -fake_score.mean() g_loss.backward() g_optim.step() if epoch % 20 == 0: print(f"epoch {epoch}: d_loss={d_loss.item():.4f}, g_loss={g_loss.item():.4f}")训练循环里几个运筹细节需要解释。n_critic取5,意味着生成器每更新一次,判别器已经更新了5次,这样生成器拿到的梯度方向更准确。梯度惩罚项对插值样本计算L2范数,迫使判别器在真实分布和生成分布之间保持平滑。学习率选1e-4而不是常见的1e-3,是因为低维数据训练时大学习率容易导致loss震荡,生成样本在几个模式之间反复横跳。
如果训练正常,每20个epoch打印的d_loss会稳定在0附近小幅浮动,g_loss绝对值随之增大。d_loss长期为较大的正数,说明判别器明显强于生成器,需要降低n_critic;d_loss长时间为负且生成器几乎不产出有效号码,说明生成器输出被判别器压过头,需要调低学习率。
4. 调参与质量评估:生成号码能不能用,要看四个指标
4.1 三个必调训练参数:lr、batch size、梯度惩罚系数
训练稳定性由三个参数共同决定,单独动任何一个都会打破平衡。最常见效的是学习率,其次是batch size,最后才动梯度惩罚系数。
| 参数 | 推荐值 | 表现异常 | 调整方向 |
|---|---|---|---|
| 学习率 lr | 1e-4 | loss剧烈震荡 | 降到5e-5,每100个epoch指数衰减 |
| batch size | 128 | 生成样本多样性差 | 降到64,子采样噪声增大有助于跳出局部最优 |
| 梯度惩罚系数 lambda | 10 | loss持续为负 | 升到15-20;若d_loss快速归零则降到5 |
这三个参数之间有关联:batch size减小时梯度噪声会变大,可以略微调高学习率对冲,但不建议同时改两个,否则难判断是哪个参数引起了变化。梯度惩罚系数是最敏感的一个,通常只在其他参数调不动时才动它。WGAN-GP对lambda的敏感度和GPT系列模型对温度参数一样玄学,换一次数据规模就得重新搜一次。
4.2 生成质量评估脚本:不要只看loss曲线
训练完成后做一次离线评估,决定生成的号码能否投入实际使用。先写一个反归一化和后处理函数,把生成器输出变换回业务号码,然后统计四个指标:窗口号分布相似度、序号重复率、序号区间覆盖率、结构合法率。
def denormalize(x, low, high): return int(round((x + 1) / 2 * (high - low) + low)) def postprocess(raw): window = denormalize(raw[0], 1, 24) seq = denormalize(raw[1], 0, 999) if not (1 <= window <= 24 and 0 <= seq <= 999): return None return window, seq def evaluate(generator, num_samples=10000): z = torch.randn(num_samples, 128) with torch.no_grad(): fake = generator(z).numpy() valid = [] for row in fake: item = postprocess(row) if item: valid.append(item) if len(valid) == 0: return {"legal_rate": 0, "repeat_rate": 1, "window_freq": {}, "seq_coverage": 0} windows = [v[0] for v in valid] seqs = [v[1] for v in valid] legal_rate = len(valid) / num_samples repeat_rate = 1 - len(set(valid)) / len(valid) seq_coverage = len(set(seqs)) / 1000 window_freq = {w: windows.count(w) / len(windows) for w in set(windows)} return { "legal_rate": legal_rate, "repeat_rate": repeat_rate, "seq_coverage": seq_coverage, "window_freq": window_freq }评估脚本里每个指标都对应一个业务问题:legal_rate低说明后处理把太多越界样本过滤掉了,模型结构有问题;repeat_rate高说明生成器已经模态崩塌,所有noise都映射到同几个号码上;seq_coverage低说明生成器偷懒,只学会了区间中段的高概率值;window_freq偏差大则是没学到冷热窗口的真实比例。这四个指标合起来比单独看loss曲线可靠得多,loss有时候很漂亮但生成样本一塌糊涂,压测数据必须有这批定量的质量依据才能入库。
评估结果达到要求后,把生成器输出的原始数组做一次batch采样,人工扫一遍号码值,确认没有一眼就看出的规律(比如全部落在热门窗口、序号单调递增)。机器指标只能证明分布特征,人工目测能发现机器看不出的模式问题,这一步不要省。
4.3 输出后处理:让生成号码真正符合业务规则
即使生成器训练得很成功,直接输出的号码也可能不符合系统要求,原因在于生成器只学了二维向量分布,不知道业务规则。窗口号被量化为0或25这种越界值、序号落在定义域外、重复号码过多,都需要后处理兜底。
校验位就是一个典型例子:让GAN直接生成校验位很困难,因为校验位不是独立分布,而是前几位数字的函数。正确做法是让生成器只生成窗口号和序号,校验位在后处理阶段用规则函数补齐,不参与网络训练。
def calc_check_digit(window, seq): digits = [int(d) for d in f"{window:02d}{seq:03d}"] total = sum(digit * (i % 2 + 1) for i, digit in enumerate(digits)) return (10 - total % 10) % 10 def build_ticket_number(window, seq): check = calc_check_digit(window, seq) return f"{window:02d}{seq:03d}{check}"把校验位拎出来单独算,生成器的任务从“输出两个数”降维成“输出两个数的分布”,训练难度显著降低,同时生成的号码在业务系统里的合法率接近百分之百。后处理阶段还会顺带做去重:压测数据里重复率超过千分之一,系统可能把重复号码当成正常并发,压测结论失真。这里一般做一个集合去重,保留首次出现的号码,再补充采样凑够目标数量,保证压测数据既有真实分布又无重复。
5. GAN生成菜票号码的排查与避坑清单
5.1 模态崩塌:生成号码全挤在一个窗口号区间
现象是evaluate输出中window_freq出现单一窗口占比超90%,或者seq_coverage低于0.1,生成器把128维噪声全部映射到同一个分布中心。根本原因是生成器找到了一个能骗过当前判别器的捷径——某个局部区域的假样本足够逼真,就不再去探索其他区域。这问题在低维数据上出现概率远高于图像,因为号码分布模式少,局部最优很容易卡住。
解决分三步。第一步降学习率到5e-5,让生成器参数更新的步长小到不会跳过真实模式。第二步把n_critic提到8,加强判别器的分辨能力。第三步在生成器输入噪声上做文章,每次batch随机采样统一种子范围,增加输入差异度。如果三步都无效,最后一个手段是回退到标准GAN并配合标签平滑,但标准GAN对学习率更敏感,代价是要重新调参。我实际处理时降学习率解决了一半案例,剩下的一半靠调高n_critic解决,真正清空重跑的反而是少数。
5.2 训练到一半出现NaN,或生成的窗口号大量越界
现象是训练几个epoch后loss打印出nan,或者postprocess返回大量None。原因通常有两类:梯度惩罚项中插值样本的梯度范数计算出现除零,或生成器输出层tanh输入过大导致梯度爆炸。低维模型前向计算很轻,但Adam的默认epsilon在极端值时确实容易数值不稳定。
排查手法:在autograd.grad调用里加一个epsilon到梯度范数分母,比如grad.norm(2, dim=1).clamp(min=1e-6);另外检查数据归一化是否在[-1,1]区间,原始序号是1000就真的会拉出极端值。出现NaN后不要直接调超参数,先带着NaN继续跑10个epoch看loss能否自行恢复,WGAN-GP偶尔会因为梯度惩罚浮点误差闪一次NaN,能恢复就继续跑,不能恢复才停下来修数值稳定性。
5.3 判别器太强,生成器梯度长期不动
表现为d_loss快速降到负的大数值,g_loss却迟迟不变或缓慢下降,同时evaluate指标没有改善。原因是判别器收敛速度远超生成器,梯度惩罚不足以约束判别器在低维空间里过拟合。低维分布的判别器太容易做分类——真实和假样本在2维平面上完全是分得开的。
处理办法是降低判别器权重更新频率,n_critic从5降到2,给生成器更多时间追赶;同时把判别器hidden_dim从256降到128,模型容量小了,判别能力自然下降。另一个有效手段是在判别器输入上加噪声,对真实样本和生成样本统一添加标准差0.01的高斯扰动,拉大判别器的分类难度。这个技巧对低维GAN效果立竿见影,噪声幅度在0.01到0.05之间按需调整。
5.4 loss曲线正常,但生成号码重复率偏高
最隐蔽的一个坑:训练日志里d_loss和g_loss都很平稳,生成样本的legal_rate也高,但repeat_rate超过5%,现象到这一步才暴露问题。原因在于号码是离散值,生成器输出连续值经过取整后,多个原始连续输出落到了同一个整数网格点。
处理思路不是调网络超参数,而是调整输出分布:把生成器输出乘一个小的扰动系数再加回原始值,例如fake = fake + 0.05 * torch.randn_like(fake),让输出落在网格点上时不会总是精确命中同一点。这个扰动只在训练生成时加,判别器输入不加。另一种做法是在evaluate后处理时对重复样本二次采样,用拒绝采样补足。我建议训练时加扰动为主、采样时去重兜底,两者配合才能把重复率压到千分之一以下。
6. 进阶技巧:把生成器导出后接入叫号系统的压测流程
训练好的生成器留在Python训练环境里没有实际价值,要真正常规用于压测,推荐把它导出成ONNX格式,脱离PyTorch运行。ONNX文件小、推理快,能直接嵌入到测试脚本或服务端。导出逻辑很简单:先把生成器切到eval模式,再做一次示例输入完成trace,然后用torch.onnx导出。
generator.eval() dummy_z = torch.randn(1, 128) torch.onnx.export( generator, dummy_z, "ticket_generator.onnx", input_names=["noise"], output_names=["ticket_feat"], opset_version=13 )接入压测时用一个自写的推理脚本读ONNX,批量输入noise,输出原始数组,然后走同一套postprocess和校验函数,生成1000条合法号码写入csv,给叫号系统做负载输入。压测前先对比生成号码和真实次号码的窗口频率分布,差异超过20%就检查后处理是否有bug,而不是直接调GAN——多数压测数据不合格的问题出在管线其他环节。
生成器真正跑进压测流程后会有一个新的发现:GAN生成数据解决的是“数据不够”的问题,但它学的是训练集里的分布,如果业务规则变了(比如新增一个窗口号段),必须重新训练,不能拿旧模型硬顶。我经历过一次叫号规则调整,忘了重训练生成器,压测结果和线上表现偏差巨大,排查了半天才发现罪魁祸首是旧模型没更新。
做这类生成任务,我的一个习惯是把“模型版本+训练数据描述+质量指标”写进生成文件的头部注释,避免后续接手的同事拿旧数据当新数据用。GAN这种东西,训练一百次有一百次不同的细微差异,只有把版本和指标完整记录,才能保证跑出来的压测结论可信。希望帮到你。
本文还有配套的精品资源,点击获取