3、数据准备:数据加载、预处理、归一化与数据集划分

数据准备这一步,说实话,是整条流水线里最枯燥、但也是最容易翻车的地方。我见过不少同学模型写得飞起,结果数据没处理好,训练出来的模型跟抽风一样。嗯,咱们今天就把这块硬骨头啃下来。

3.1 数据加载:把数据请进浏览器

在浏览器里做机器学习,数据来源无非这么几种:本地文件、网络请求、还有浏览器自带的 IndexedDB。我个人最常用的是 tf.data 这个 API,它能把各种数据源统一成一个 Dataset 对象,用起来特别顺手。

先看一个最常见的场景——加载 CSV 文件。假设你有一个房价预测的数据集,存在服务器上:

// 从 URL 加载 CSV 文件
const csvUrl = 'https://example.com/housing.csv';
const dataset = tf.data.csv(csvUrl, {
  columnConfigs: {
    // 指定哪些列是特征,哪列是标签
    price: {
      isLabel: true
    }
  },
  // 配置是否跳过空行
  configuredColumnsOnly: true
});

// 看一眼数据长什么样
const sample = await dataset.take(5).toArray();
console.log(sample);

这里有个坑,我曾经踩过——CSV 文件里如果有中文字段名,或者列名带空格,tf.data.csv 会直接报错。解决办法很简单,要么预处理 CSV,要么在 columnConfigs 里手动映射列名。

注意:浏览器有同源策略限制。如果你的 CSV 文件在另一个域名下,记得配 CORS 头,或者用代理服务器转发。不然数据加载这一步就卡死了。

3.2 数据预处理:洗数据是门手艺活

数据加载进来之后,十有八九是脏的。什么叫脏?缺失值、异常值、格式不统一,这些都是家常便饭。我习惯把预处理分成三步走:清洗、转换、增强。

3.2.1 处理缺失值

真实世界的数据,缺胳膊少腿太正常了。比如某个房子的卧室数量字段是空的,怎么办?

// 假设我们拿到了一个包含缺失值的 tensor
const dataWithNaN = tf.tensor2d([
  [3, 1500, 300000],
  [4, NaN, 450000],   // 面积缺失
  [2, 800, NaN]       // 价格缺失
]);

// 方法一:用均值填充
const mean = tf.mean(dataWithNaN, 0);  // 按列求均值
const filled = tf.where(
  tf.isNaN(dataWithNaN),
  mean,  // 缺失位置用均值替换
  dataWithNaN
);

// 方法二:直接丢弃包含 NaN 的行
const mask = tf.logicalNot(tf.any(tf.isNaN(dataWithNaN), 1));
const cleaned = dataWithNaN.gather(
  tf.where(mask).reshape([-1])
);

我个人更倾向于用均值填充,除非缺失比例特别高(超过 30%),那时候就得考虑这个特征是不是本身就有问题。

3.2.2 数据类型转换

有时候数据是字符串,比如颜色 "red"、"blue",模型不认识这些。得转成数字。我记得有一次做图像分类,标签是 "cat"、"dog",忘了做映射,模型训练出来准确率只有 50%,跟抛硬币似的。

// 字符串标签转 one-hot 编码
const labelMap = { 'cat': 0, 'dog': 1, 'bird': 2 };
const rawLabels = ['cat', 'dog', 'bird', 'cat'];
const indices = rawLabels.map(l => labelMap[l]);

// 转成 one-hot
const oneHotLabels = tf.oneHot(indices, 3);
oneHotLabels.print();
小技巧:如果类别特别多(比如上千种),one-hot 编码会非常稀疏。这时候可以考虑用 Embedding 层,或者直接用整数索引配合 tf.layers.embedding

3.3 归一化:让所有特征站在同一起跑线

你想想看,如果房价是几百万,卧室数量是几个,这两个特征直接喂给模型,模型肯定优先关注房价,因为它的数值大、波动也大。归一化就是解决这个问题的。

常用的归一化方法有两种:Min-Max 归一化和 Z-score 标准化。我一般这么选:

方法 公式 适用场景
Min-Max (x - min) / (max - min) 数据有明确边界,比如像素值 0-255
Z-score (x - mean) / std 数据分布近似正态,或者有异常值

来看代码实现:

// Min-Max 归一化
function minMaxNormalize(tensor) {
  const min = tensor.min(0);
  const max = tensor.max(0);
  const range = max.sub(min);
  return {
    normalized: tensor.sub(min).div(range),
    // 保存参数,预测时要用同样的参数
    params: { min, range }
  };
}

// Z-score 标准化
function zScoreNormalize(tensor) {
  const mean = tensor.mean(0);
  const std = tensor.std(0);
  return {
    normalized: tensor.sub(mean).div(std),
    params: { mean, std }
  };
}

// 使用示例
const features = tf.tensor2d([
  [3, 1500],
  [4, 2000],
  [2, 800]
]);
const { normalized, params } = minMaxNormalize(features);
normalized.print();
重点:归一化的参数(min、max、mean、std)必须从训练集计算,然后用同样的参数去归一化验证集和测试集。千万别混在一起算,否则会造成数据泄露,模型评估结果会虚高。

3.4 数据集划分:训练、验证、测试三件套

数据准备好了,归一化也做了,接下来就是切分。标准做法是 70% 训练、15% 验证、15% 测试。但具体比例可以灵活调整,数据量大可以多留点给验证集,数据少就得精打细算。

在 TensorFlow.js 里,我习惯用 tf.data.Datasettakeskip 方法来切分:

// 假设 dataset 是已经加载并预处理好的数据集
const totalSize = 1000;  // 总样本数
const trainSize = Math.floor(totalSize * 0.7);
const valSize = Math.floor(totalSize * 0.15);
const testSize = totalSize - trainSize - valSize;

// 切分数据集
const trainDataset = dataset.take(trainSize);
const valDataset = dataset.skip(trainSize).take(valSize);
const testDataset = dataset.skip(trainSize + valSize);

// 打乱顺序很重要!防止数据有隐含的顺序模式
const shuffledTrain = trainDataset.shuffle(100);
const batchedTrain = shuffledTrain.batch(32);
const batchedVal = valDataset.batch(32);
const batchedTest = testDataset.batch(32);

这里有个细节——shuffle 的 buffer size 怎么设?我一般设成数据集大小的 1/10 左右,太小了打不乱,太大了内存扛不住。浏览器环境尤其要注意内存,我曾经在手机上跑一个 10 万条的数据集,buffer size 设了 5 万,结果页面直接崩了。

注意:测试集在模型训练过程中绝对不能碰。我见过有人反复用测试集调参,最后模型在测试集上表现很好,上线后一塌糊涂。这就是典型的过拟合到测试集上。

3.5 完整流程串联

最后,咱们把整个流程串起来,写一个完整的数据准备函数。这也是我在实际项目中常用的模板:

async function prepareData(csvUrl) {
  // 1. 加载数据
  const rawDataset = tf.data.csv(csvUrl, {
    columnConfigs: { price: { isLabel: true } }
  });

  // 2. 转换为 tensor
  const dataArray = await rawDataset.toArray();
  const features = dataArray.map(d => Object.values(d).slice(0, -1));
  const labels = dataArray.map(d => d.price);

  const featureTensor = tf.tensor2d(features);
  const labelTensor = tf.tensor2d(labels, [labels.length, 1]);

  // 3. 归一化
  const { normalized: normFeatures, params } = minMaxNormalize(featureTensor);

  // 4. 划分数据集
  const total = normFeatures.shape[0];
  const trainEnd = Math.floor(total * 0.7);
  const valEnd = trainEnd + Math.floor(total * 0.15);

  const trainX = normFeatures.slice([0, 0], [trainEnd, -1]);
  const trainY = labelTensor.slice([0, 0], [trainEnd, -1]);
  const valX = normFeatures.slice([trainEnd, 0], [valEnd - trainEnd, -1]);
  const valY = labelTensor.slice([trainEnd, 0], [valEnd - trainEnd, -1]);
  const testX = normFeatures.slice([valEnd, 0], [total - valEnd, -1]);
  const testY = labelTensor.slice([valEnd, 0], [total - valEnd, -1]);

  return {
    train: { x: trainX, y: trainY },
    val: { x: valX, y: valY },
    test: { x: testX, y: testY },
    normParams: params  // 预测时要用
  };
}

// 调用
const data = await prepareData('https://example.com/housing.csv');
console.log('训练集样本数:', data.train.x.shape[0]);

嗯,到这里数据准备这块就讲完了。说白了,数据准备就是「垃圾进,垃圾出」这句话的最好体现。你花 80% 的时间把数据收拾利索了,后面模型训练反而轻松。下一章咱们就拿着这些处理好的数据,开始搭建真正的神经网络模型。