2.4 预处理与增强:在管道里加工 本节摘要:预处理与数据增强不该写在训练循环里,而该搬进数据管道——用 map 把标准化、one-hot、图片翻转裁剪声明为管道的一环,让框架并行调度、与训练重叠执行。本节覆盖三类加工:数值特征的标准化统计量冻结、图片增强的随机化与只动输入不动标签、文本的向量化预处理,并讲清"训练用随机增强、验证用确定性预处理"这条纪律的实现方式。 本节能力清单 阅读完本节,你应当能够: 把标准化搬进 map,并保证验证集使用训练集统计量; 编写随机增强函数,理解"只扰动输入不扰动标签"的配对约束; 区分 preprocess 与 augment 两个环节,把增强只挂到训练管道; 用 tf.onehot 与类型转换完成标签侧加工。
本节摘要:预处理与数据增强不该写在训练循环里,而该搬进数据管道——用 map 把标准化、one-hot、图片翻转裁剪声明为管道的一环,让框架并行调度、与训练重叠执行。本节覆盖三类加工:数值特征的标准化统计量冻结、图片增强的随机化与只动输入不动标签、文本的向量化预处理,并讲清"训练用随机增强、验证用确定性预处理"这条纪律的实现方式。
阅读完本节,你应当能够:
预处理写在循环里有两个代价。一是位置错了:循环里每次取批次后现做加工,加工时间与训练串行累加,GPU 等着 CPU 做算术;搬进 map 后,加工成为管道工位,2.5 节的并行与预取调度能把它与训练重叠。二是风险变了:循环里对整个数组先标准化再逐批喂,标准化统计量往往是"顺手全量算"的,验证集的统计量若单独重算,训练与验证的输入分布悄悄错位——这是静默的评估失真。管道化的正确姿势是:统计量只在训练集上算一次、冻结成常量、两套管道共用。
import tensorflow as tf import numpy as np from sklearn.datasets import fetch_california_housing housing = fetch_california_housing() X = housing.data.astype("float32") y = housing.target.astype("float32") # 统计量只在训练集算:这里演示用前 80% 假装训练划分 n_train = int(len(X) * 0.8) mu = X[:n_train].mean(axis=0) sigma = X[:n_train].std(axis=0) mu_t = tf.constant(mu) sigma_t = tf.constant(sigma) def preprocess(feat, label): feat = (feat - mu_t) / sigma_t # 冻结的统计量做标准化 return feat, label raw = tf.data.Dataset.from_tensor_slices((X, y)) train_ds = raw.take(n_train).shuffle(10000).map(preprocess).batch(64) val_ds = raw.skip(n_train).map(preprocess).batch(256) # 同一套统计量 for xb, yb in train_ds.take(1): print(tf.reduce_mean(xb, axis=0)[:3]) # 输出示例(接近 0,因训练段标准化后均值趋零): # [ 0.01 -0.02 0.00]
增强的本质是在管道里给输入注入受控随机性,逼模型学不变性。随机化发生在 map 里,每次迭代每个样本独立抽取参数。纪律只有一条但极易破:扰动只施加给输入,绝不波及标签——翻转一张猫图还是猫,但要是你"顺手"把标签张量也过了同一个随机变换,配对就错了。
def augment(img, label): img = tf.image.random_flip_left_right(img) # 随机水平翻转 img = tf.image.random_brightness(img, max_delta=0.1) # 随机亮度 img = tf.image.random_contrast(img, lower=0.9, upper=1.1) img = tf.clip_by_value(img, 0.0, 1.0) # 增强后值域修正 return img, label # 标签原样通过 def center_norm(img, label): img = tf.cast(img, tf.float32) / 255.0 # 确定性预处理 return img, label train_img = (tf.data.Dataset.from_tensor_slices((np.random.rand(500, 32, 32, 3).astype("float32") * 255, np.random.randint(0, 2, 500))) .map(center_norm) .map(augment) .shuffle(500).batch(64)) val_img = (tf.data.Dataset.from_tensor_slices((np.random.rand(100, 32, 32, 3).astype("float32") * 255, np.random.randint(0, 2, 100))) .map(center_norm).batch(64)) # 验证管道不挂 augment for xb, yb in train_img.take(1): print(xb.numpy().min(), xb.numpy().max()) # 输出示例:0.0 1.0 —— clip 保证增强后值域仍合法
两个环节要分开:preprocess 是确定性的、训练与验证共用;augment 是随机的、只挂训练管道。这个结构让"验证集分布等于真实分布"不被增强污染。
回归标签往往是 float32 标量直接可用;分类标签常需 one-hot 或类型转换。加工放 map 里与输入加工同批完成:
raw_cls = tf.data.Dataset.from_tensor_slices((np.random.rand(100, 8).astype("float32"), np.random.randint(0, 3, 100))) def to_training_pair(feat, label): feat = (feat - mu_t) / sigma_t label = tf.one_hot(tf.cast(label, tf.int32), depth=3) # 整型标签转 one-hot return feat, label cls_ds = raw_cls.map(to_training_pair).batch(16) for xb, yb in cls_ds.take(1): print(yb.shape, yb[0]) # 输出:(16, 3) [0. 0. 1.](随机,视样本而定) # one-hot 后配合 CategoricalCrossentropy 损失 # 若损失选 SparseCategoricalCrossentropy,标签保持整型即可,省内存
不是。增强的随机幅度是超参数,过猛会把有效分布推离真实分布:亮度抖动开到 0.5,模型在"基本看不见"的图上也算损失,梯度方向被垃圾样本污染。经验做法是从弱增强起步(翻转加小幅度色彩抖动),观察训练与验证曲线的差距是否收窄,再决定加码。文本侧同理:遮词比例、截断长度都是幅度旋钮。另一个取舍是缓存的兼容性——增强过的数据不适合 cache(2.5 节),因为缓存会冻结随机性,每轮增强必须重新现做,这正是 cache 只套在 preprocess 之前、augment 挂在 cache 之后的原因。
# cache 与增强的正确夹层:缓存确定性部分,随机部分不缓存 ds = (tf.data.Dataset.from_tensor_slices((np.random.rand(200, 8).astype("float32"), np.random.rand(200))) .map(lambda f, y: ((f - mu_t) / sigma_t, y)) # 确定性预处理 .cache() # 缓存标准化结果 .map(augment) # 随机增强在缓存之后 .shuffle(200).batch(32)) print("layered pipeline ready") # 输出:layered pipeline ready
⚠️ 常见坑:增强函数里忘了 clip,亮度、对比度抖动后像素值越界为负,模型对负像素毫无防御,训练曲线剧烈震荡。所有色彩类增强后都补一道 clip_by_value。
💡 关键直觉:把管道想成两级厨房——preprocess 是冷处理,做完可以入冷库(cache);augment 是热烹饪,必须现做。入错库的菜(缓存了随机增强)每轮都一个味,模型学不到不变性。
下节是本章的重头戏:并行、缓存、预取三件套的调度原理。