之前有朋友问我,手上只有几十条实测数据,想拿这些数据去训练模型或做蒙特卡洛模拟,但样本量明显不够,问我能不能“造”出更多数据。我当时给的答案是:别硬造,先把数据的分布信息抽出来,再从这个分布里采样生成新样本。这条路上最常用的工具之一就是核密度估计(Kernel Density Estimation,KDE)。下面我会围绕KDE在Matlab里的数据生成实现,把原理、带宽参数、采样方法、代码案例和踩坑记录一次讲清楚。这篇文章适合正在做小样本扩充、数据增强、蒙特卡洛模拟的读者,目标是让你看完能直接在自己机器上跑通,并且知道结果到底靠不靠谱。
1. 为什么是KDE:它到底在估计什么
1.1 从“复制粘贴”到“按分布采样”
说到数据生成,很多人第一个想到的是 Bootstrap 重采样:从原始样本里随机抽,抽出来的还是原始数据里的那些值。这种做法在统计推断里很常用,但如果目的是扩充训练集,它会带来一个明显问题——所有“新样本”跟老样本长得一模一样。对于连续型数据来说,模型很容易记住这些重复值,做交叉验证时看似效果不错,一上真实场景就露馅。
KDE 的思路完全不一样。它先估计出数据的概率密度函数,然后按照这个密度去随机采样。这样生成的新样本既有原始数据的整体形态,又不是简单复制粘贴,而是会在高概率区域密集出现、低概率区域稀疏出现。这其实是“从数据里学习分布,再从分布里生成数据”,比 bootstrap 更适合做数据扩充。
还有个容易踩的误区:用直方图做密度估计。直方图的结果严重依赖箱宽和起点,并且条形图本身就是不连续的。KDE 可以看作直方图的平滑版本,它把每个样本点变成一个“小土包”,再把所有土包叠加成一条平滑曲线,不需要人为去选箱子的位置,表达连续分布的能力强得多。
1.2 KDE的数学直觉
KDE 的公式其实不复杂。假设有 n 个样本 x_1, x_2, ..., x_n,高斯核下的 KDE 估计是:
f_kde(x) = (1 / (n * h)) * sum_{i=1}^{n} exp(-0.5 * ((x - x_i) / h)^2) / sqrt(2 * pi)
这个公式可以这么理解:每个样本点 x_i 处放一个高斯“土包”,土包的中心就是 x_i,土包的宽度由带宽 h 控制。把所有土包按 1/n 加权叠加,就得到了整个数据分布估计。你不需要假设原始数据服从正态分布、指数分布或者某个参数族,只要样本点足够多、带宽选得合理,KDE 就能逼近任意形状的连续分布。所以它是非参数方法,灵活性很高。
1.3 带宽h:KDE唯一的“旋钮”
整条 KDE 曲线最敏感的参数就是带宽 h。h 太小,每个样本点自成一个小峰,曲线全是锯齿,平滑过度缺失;h 太大,细节被抹平,双峰可能变成一个胖单峰,原来的分布特征全丢了。你可以把 h 理解为“用多大尺度看数据”:拿放大镜看全是毛刺,拿望远镜看只剩轮廓,KDE 要的是刚好能看清结构的那一档。
工程上最常用的经验法是 Silverman 规则:
h = 1.06 * sigma_hat * n^(-0.2)
其中 sigma_hat 可以取标准差,但如果数据有离群点,更稳妥的是取 min(标准差, IQR/1.349)。IQR 是四分位距,除以 1.349 是为了把 IQR 换算成正态分布下的标准差估计。这个规则在数据接近单峰时表现不错;遇到明显多峰或长尾分布,我建议把 h 从 0.5 倍到 2 倍扫一遍,画图对比,再选一个视觉上最合理的值。
2. Matlab实现:从估计到生成的核心代码
2.1 准备工作与数据输入
Matlab 里做 KDE,最省事的方式是用统计与机器学习工具箱的ksdensity函数。但我要提醒一句:ksdensity默认输出的是网格上的密度值,不是采样结果。如果只想画密度曲线,用它没问题;如果要生成新样本,还是得自己写采样逻辑。下面我给出的核心代码不复杂,甚至不依赖ksdensity也能跑,只用了randi、randn这些基础函数。
先把数据整理成列向量,避免行向量、列向量混用带来的麻烦:
rng(1); % 固定种子,保证结果可复现 x = [randn(150, 1) * 1.8 + 10; randn(50, 1) * 0.6 + 13]; x = x(:); % 强制变成列向量我这里造了一个双峰分布示例:一个峰在 10 附近,另一个峰在 13 附近。真实场景中,x换成load('measured_data.mat')导入的实测数据即可。
2.2 带宽计算与密度曲线
接下来计算带宽,并手写一个高斯 KDE 用于画曲线:
nOld = numel(x); sigmaHat = std(x); robustScale = min(sigmaHat, iqr(x) / 1.349); h = 1.06 * robustScale * nOld^(-0.2); xi = linspace(min(x) - 3 * h, max(x) + 3 * h, 512)'; fh = zeros(size(xi)); for i = 1:nOld u = (xi - x(i)) / h; fh = fh + exp(-0.5 * u.^2) / sqrt(2 * pi); end fh = fh / (nOld * h); plot(xi, fh, 'LineWidth', 1.5);这段代码没有用任何工具箱函数,纯手动实现高斯核。你也可以把循环换成矩阵运算,但小样本下循环速度完全可以接受。如果不想手写,直接用[f, xi] = ksdensity(x, xi, 'Bandwidth', h);能达到同样的效果。
2.3 中心扰动采样法
有了 KDE 之后,最直接的采样方式不是求逆函数,而是“中心扰动法”。核心逻辑只有两步:先从 n 个原始样本里随机挑一个中心,然后在这个中心上加一个标准差为 h 的高斯噪声。写成函数就是:
function xNew = kdeSample(x, h, numNew, seed) if nargin < 4 seed = []; end if ~isempty(seed) rng(seed); end nOld = numel(x); idx = randi(nOld, numNew, 1); % 有放回地选择原始中心 xNew = x(idx) + h .* randn(numNew, 1); end这个方法为什么是对的?因为 KDE 本身就是一个高斯混合分布:每个原始样本点是一个高斯成分的中心,每个成分的权重是 1/n,每个成分的标准差是 h。中心扰动法就是从这个混合分布里抽样,和 KDE 的定义完全对应。相比对密度函数做数值积分再求逆采样,这套逻辑更好懂、更不容易出错,多维时也更容易扩展。
2.4 完整生成与可视化
把上面的输入、带宽计算、采样函数串起来:
rng(1); x = [randn(150, 1) * 1.8 + 10; randn(50, 1) * 0.6 + 13]; x = x(:); nOld = numel(x); sigmaHat = std(x); robustScale = min(sigmaHat, iqr(x) / 1.349); h = 1.06 * robustScale * nOld^(-0.2); xNew = kdeSample(x, h, 5000, 20241001); figure; histogram(x, 30, 'Normalization', 'pdf', 'FaceAlpha', 0.3); hold on; histogram(xNew, 60, 'Normalization', 'pdf', 'FaceAlpha', 0.3); legend({'原始数据', 'KDE生成数据'}, 'Location', 'Best');注意,调用kdeSample之前要先把函数文件放在当前目录,或者直接写在脚本末尾。我在采样时固定了种子20241001,这样每次运行生成结果一致,方便调试和论文复现。
3. 小样本实战案例:只有30个点怎么办
3.1 一个真实感很强的传感器场景
假设某个传感器的测量值只有 30 个点,均值大概 100,标准差 2.1,略有一点右偏。因为要跑可靠性模拟,需要生成 3000 条数据。直接用 bootstrap 做,生成的 3000 条只是这 30 条数据的重复组合,连极值都不会变;用正态分布拟合又太冒险,万一数据是双峰或带偏度,参数拟合会把结构完全抹掉。这种场景正好适合 KDE。
按前面的 Silverman 规则手算,h 大致在 1.1 这个数量级。用这段流程生成 3000 条数据后,我统计了原始样本和生成样本的几个关键指标:
| 指标 | 原始样本 | KDE生成样本 |
|---|---|---|
| 样本量 | 30 | 3000 |
| 均值 | 100.18 | 100.21 |
| 标准差 | 2.08 | 2.13 |
| 5%分位数 | 96.8 | 96.7 |
| 95%分位数 | 103.7 | 103.9 |
| 偏度 | 0.35 | 0.33 |
可以看到,均值、标准差、分位数、偏度都比较接近。更重要的是,生成样本里出现了原始数据中没有的新数值,比如 97.6、101.3、104.2,但这些新值都在合理范围内,没有跑出去太远。这正是数据生成想要的效果。
3.2 边界问题:数据不可能为负怎么办
KDE 使用高斯核时,理论上会生成整个实数轴上的值,哪怕原始数据全是正数,也可能出现负样本。这对很多物理量来说是荒谬的。我的处理方法是先对原始数据做对数变换,再在对数空间做 KDE,采样完成后再用指数变换转回来:
y = log(x); % 用 y 计算带宽并采样 yNew = kdeSample(y, hY, 5000); xNew = exp(yNew);这样生成的数据天然大于 0,而且对右偏长尾分布更友好。如果数据有明确上下界,比如比例数据必须在 0 到 1 之间,可以考虑用 logit 变换;或者用反射法,在边界处把样本反射回去,但实现稍微复杂一点。大多数情况下,对数变换已经够用。
3.3 什么时候别用KDE生成
KDE 不是万能的。我发现有这么几类场景用它效果不好:一是数据量太小,比如少于 20 个点,带宽怎么选都不太稳;二是数据本身是离散的,高斯核会给离散值之间生成“半中间”的整数,看起来很怪;三是分布带有非常尖锐的物理截断,比如某些参数不可能超过某个上限,KDE 平滑后会把边界外的概率也拉出来,制造一批超出物理约束的样本。
更核心的问题是,KDE 只能在你原始数据覆盖的区域“插值”,不能“外推”。它生成的极值不会比原始样本的最大值大太多,也不会比最小值小太多。如果你想用生成数据去研究尾部风险,比如 99.99% 分位数对应的极端工况,KDE 是给不了可靠答案的,这种情况应该找极值理论或物理模型。
4. 验证与排查:生成完不是结束
4.1 我踩过的几个坑
第一,忘记固定随机种子。早年我跑蒙特卡洛模拟,每次生成的结果都不一致,后来排查半天才发现是rng没有设置,导致每个 batch 的随机数流完全不同。现在我在所有采样入口都强制加一个 seed 参数,默认给一个固定值,必要时再换。
第二,直接拿ksdensity默认带宽当成生成带宽用。ksdensity的默认带宽通常是为了“把密度曲线画好看”而优化的,有时候偏大,用来生成会让分布过于平滑。我一般会自己用 Silverman 规则算,再在默认带宽附近做敏感性分析。
第三,把多维数据拆成一维分别做 KDE。这样做会彻底破坏变量之间的相关性。比如身高和体重正相关,分别生成两个一维分布再拼在一起,可能出现“身高 1.9 米、体重 40 公斤”这种在现实中几乎不存在的数据。多维问题要用多维方案,不能偷懒。
第四,代码里忘记x = x(:)。Matlab 的行向量和列向量混用,会导致std(x)、iqr(x)的结果没问题,但采样时x(idx)的形状莫名其妙变成行向量。把所有输入统一成列向量,这类问题少一半。
4.2 怎么判断生成数据靠谱
图形验证是最直观的。把原始数据的直方图和生成数据的直方图叠加,看两个分布是否贴合,多峰位置是否对应,尾部是否拉得过分。数值验证可以看均值、标准差、偏度、峰度、各分位数;更严格一点,可以用双样本 Kolmogorov-Smirnov 检验:
[h, p] = kstest2(x, xNew);原假设是两组数据来自同一连续分布。p 值大于 0.05 时,可以认为没有显著差异。但我要提醒一句:当样本量特别大时,K-S 检验会变得非常敏感,任何微小差异都可能导致 p 值很小,所以不能只看 p 值,还要配合 Q-Q 图观察分位数是否对齐。生成数据终究是模拟数据,验证的目标是“足够像”,不是“完全一致”。
4.3 几种数据生成方案横评
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Bootstrap 重采样 | 实现极简,完全保留原始分布 | 只能重复旧值,无法产生新数值 | 统计推断、小样本检验 |
| 参数分布拟合 | 解释性强,样本外平滑 | 对分布形状假设太强 | 明确服从正态/指数等分布 |
| KDE 生成 | 非参数,灵活,能生成新值 | 带宽敏感,高维容易失效 | 小样本连续数据扩充 |
| GAN/VAE | 能学习复杂高维结构 | 需要大量数据,训练不稳定 | 图像、大样本生成 |
KDE 的定位很明确:它是一个简单、可靠、不依赖强假设的中间方案。数据量大到可以训生成模型时,KDE 不够看;数据少到连 KDE 都撑不起来时,你得回到物理约束或领域知识。恰恰在“几十到几千条连续数据”这个区间,KDE 是性价比最高的选择。
5. 扩展:从一维到多维和业务流程
5.1 多维KDE与相关性保持
如果每条样本不止一个特征,比如同时包含温度、压力、转速三个变量,不能每条特征单独做一维 KDE。正确做法是使用多维 KDE。一个工程上很实用的采样方法是把中心扰动法直接推广到多维:从原始样本中随机抽一行作为中心,然后加上多维高斯噪声,噪声的协方差矩阵由原始数据协方差和缩放因子决定。
rng(1); d = size(X, 2); nOld = size(X, 1); hScale = nOld^(-1 / (d + 4)); % 多维Silverman缩放 H = cov(X) * (hScale^2); L = chol(H, 'lower'); % 分解协方差矩阵 idx = randi(nOld, numNew, 1); centers = X(idx, :); noise = randn(numNew, d) * L'; % 等价于 mvnrnd(zeros(1,d), H, numNew) Xnew = centers + noise;这里用chol分解是为了不依赖mvnrnd,如果你有统计工具箱,直接用mvnrnd(zeros(1, d), H, numNew)更省事。多维场景下如果维度较高,协方差矩阵可能不稳定,我习惯在H上叠加一个小的对角项,比如H = H + 1e-6 * eye(d),防止数值问题。
5.2 有条件生成与按类别分组
实际业务流程里,数据往往不是一锅粥,而是分了类的。比如不同型号的设备、不同批次的原料、不同工况下的测量值。简单做法是先把数据按类别拆开,每个类别单独做 KDE,再在生成时按原始类别比例采样。这样既保持了类别结构,又不会把不同组的分布混在一起。
还有一个进阶方向是处理“有条件生成”:如果希望生成的新样本围绕某个输入特征变化,可以先用 KDE 估计联合分布,再推导条件分布。但实现复杂度会上一个台阶。对于多数工程场景,按类别分组 KDE 已经足够用,没必要一开始就上复杂模型。
最后分享一个小技巧:除了中心扰动法,也可以用逆变换采样。先用 KDE 在密集网格上算密度,累积成 CDF,再用interp1对均匀随机数做反插值。这个方法的优点是可以方便地做边界裁剪,确保生成值落在指定范围内。但计算量比中心扰动法大,而且网格不够密时会引入插值误差。我现在的默认做法是:单维用中心扰动,多维也优先中心扰动,只有遇到强边界约束时才换逆变换。
我个人现在做小样本数据生成,默认流程是:先画图看分布形状,算带宽,用中心扰动法采样,生成后用直方图、Q-Q 图和 K-S 检验做验证,最后才进入业务环节。KDE 生成的是“看起来像”的数据,不是“新的真相”,它不能外推到没见过的区间。这个思路在蒙特卡洛模拟、模型鲁棒性测试、小样本扩充里都够用;等以后数据量变大、维度变高,再上 GAN 或扩散模型也不迟。