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 里手动映射列名。
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();
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();
3.4 数据集划分:训练、验证、测试三件套
数据准备好了,归一化也做了,接下来就是切分。标准做法是 70% 训练、15% 验证、15% 测试。但具体比例可以灵活调整,数据量大可以多留点给验证集,数据少就得精打细算。
在 TensorFlow.js 里,我习惯用 tf.data.Dataset 的 take 和 skip 方法来切分:
// 假设 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% 的时间把数据收拾利索了,后面模型训练反而轻松。下一章咱们就拿着这些处理好的数据,开始搭建真正的神经网络模型。