上个月跟一个做材料实验室的朋友喝酒,他吐槽说他们组的 BET 比表面积仪隔三差五就出幺蛾子——脱气没脱干净、真空系统悄悄漏了气,测出来的数据看着还挺"正常",等报告交上去才发现不对劲。人工回头翻曲线,翻半天。
我当时就想,这不就是个异常检测的活儿吗?回去就折腾了几天 ML.NET ,写了个东西。今天把这段经历捋一捋,权当抛砖引玉。
先说句丑话在前头:这篇文章讲的方案,离生产级部署还有距离,用的也是仿真数据。但技术思路我觉得是靠谱的,值得聊聊。
一、先泼盆冷水:什么是"在线",得先说清楚
写这篇之前我纠结了挺久标题该怎么起。"在线检测"这四个字,材料行业的人看了容易皱眉头。
为啥呢?BET 测试这活儿本身就慢。多点法测一次,脱气加吸附平衡,动辄一两个小时起步。你要真说"实时在线",那意思是设备在跑的时候,数据流进来就立刻判断——这个说法,严格意义上站不太住脚。
所以我这里说的"在线检测",指的是测试过程中,每采集到一个数据点,就实时对这个点做异常预警,而不是等整条等温线测完了再回头复盘。这个定义窄一些,但更诚实。
如果你的场景是"设备跑了几百批次,我想批量筛出异常批次",那其实是另一套思路(离线批处理),跟这篇讲的不完全是一回事。先把这个边界画清楚,免得后面聊着聊着就跑偏了。
二、BET 到底在测什么,异常都长啥样
不懂原理瞎调模型,那是耍流氓。先简单捋一下。
BET 方程长这样:
$$\frac{p/p_0}{V(1-p/p_0)} = \frac{1}{V_m C} + \frac{C-1}{V_m C} \cdot \frac{p}{p_0}$$
其中 $p/p_0$ 是相对压力,$V$ 是吸附体积。理论上,拿这两个变量做个线性拟合,斜率和截距一算,比表面积就出来了。听着挺优雅是吧?现实往往没这么听话。
我跟做实验的朋友聊了聊,梳理出几种最常见的"翻车现场":
| 异常类型 |
幕后黑手 |
数据长什么样 |
| 脱气不彻底 |
样品预处理偷懒了 |
BET 值虚高,线性度变差 |
| 传感器漂移 |
压力/温度探头老化 |
整条曲线莫名其妙往一边偏 |
| 真空系统漏气 |
密封圈该换了 |
数据点抖得跟心电图似的 |
| 多层吸附紊乱 |
样品孔结构变了 |
C 值飘忽,曲线形状怪异 |
| 批次交叉污染 |
清洗没做干净 |
局部某几个点突然蹦一下 |
你看,这些异常各有各的脾气,有的是整体偏移,有的是局部抽风。这也是我后面设计特征工程时纠结最久的地方——单靠一个"BET 比表面积"数值去判断异常,太粗糙了,得往曲线的形态里挖。
三、数据从哪来?说实话,我是自己造的
理想情况下,应该拿真实设备跑出来的历史数据训练模型。但现实是:公开的 BET 数据集几乎不存在,实验室的数据要么涉密要么压根没系统化保存。
那咋整?只能自己动手,用 BET 方程模拟正常曲线,再手动往里面注入各种异常。这招在算法验证阶段挺常见,很多论文也这么干,但得把话说透:这是仿真数据,检测的其实是"我自己埋进去的坑",跟真实设备的噪声特性未必完全吻合。
拿这套方案发论文肯定不够格,但用来跑通整个技术链路、验证思路可行性,绰绰有余。
下面把完整的仿真代码奉上,照着抄就能跑起来。
3.1 数据模型定义
using System;
using System.Collections.Generic;
using System.Linq;
namespace BETAnomalyDetection.Models
{
// 单个测量点——原始数据
public class BETMeasurement
{
public int PointIndex { get; set; }
public float RelativePressure { get; set; } // p/p0
public float AdsorbedVolume { get; set; } // V (cm³/g)
public float Temperature { get; set; } // K
public float EquilibrationTime { get; set; } // 秒
public bool IsActuallyAnomaly { get; set; } // 仅用于验证,模型训练时不能看到
}
// 派生特征——真正喂给模型的东西
public class BETFeatures
{
public float RelativePressure { get; set; }
public float AdsorbedVolume { get; set; }
public float LocalSlope { get; set; }
public float DeviationFromFit { get; set; }
public float RollingVariance { get; set; }
public bool Label { get; set; } // 训练时不用,仅做效果验证参考
}
public class AnomalyPrediction
{
public bool PredictedLabel { get; set; }
public float Score { get; set; }
}
}
3.2 仿真器——生成正常曲线 + 注入各类异常
namespace BETAnomalyDetection.Simulation
{
public class BETSimulator
{
private readonly Random _rng;
public BETSimulator(int seed = 42)
{
_rng = new Random(seed);
}
/// <summary>
/// 生成一条"健康"的等温吸附曲线
/// vm: 单层饱和吸附量, c: BET常数(跟吸附质-吸附剂相互作用有关)
/// </summary>
public List<BETMeasurement> GenerateNormalIsotherm(
float vm = 120f, float c = 80f, int points = 30)
{
var result = new List<BETMeasurement>();
for (int i = 1; i <= points; i++)
{
float p_p0 = 0.02f + i * 0.025f; // 相对压力从0.02爬升到约0.77
if (p_p0 >= 0.99f) p_p0 = 0.98f;
// BET方程正向计算理论吸附量
float vTheory = (vm * c * p_p0) / ((1 - p_p0) * (1 + (c - 1) * p_p0));
// 加点自然的仪器噪声(正态分布近似,用两次均匀分布叠加模拟)
float noise = NextGaussian(0, 0.015f);
float vActual = vTheory * (1 + noise);
result.Add(new BETMeasurement
{
PointIndex = i,
RelativePressure = p_p0,
AdsorbedVolume = vActual,
Temperature = 77.35f + NextGaussian(0, 0.05f), // 液氮温度附近波动
EquilibrationTime = 300 + _rng.Next(-15, 15),
IsActuallyAnomaly = false
});
}
return result;
}
/// <summary>
/// 异常一:真空系统漏气——数据点从某处开始剧烈抖动
/// </summary>
public List<BETMeasurement> InjectLeakAnomaly(
List<BETMeasurement> data, int startIndex, float jitterMagnitude = 0.12f)
{
var copy = CloneData(data);
for (int i = startIndex; i < copy.Count; i++)
{
float jitter = NextGaussian(0, jitterMagnitude);
copy[i].AdsorbedVolume *= (1 + jitter);
copy[i].IsActuallyAnomaly = true;
}
return copy;
}
/// <summary>
/// 异常二:传感器漂移——整条曲线随点数逐渐偏移
/// </summary>
public List<BETMeasurement> InjectDriftAnomaly(
List<BETMeasurement> data, float driftRatePerPoint = 0.008f)
{
var copy = CloneData(data);
for (int i = 0; i < copy.Count; i++)
{
float drift = 1 + driftRatePerPoint * i;
copy[i].AdsorbedVolume *= drift;
if (drift > 1.05f) copy[i].IsActuallyAnomaly = true;
}
return copy;
}
/// <summary>
/// 异常三:脱气不彻底——整体虚高 + 前段线性度变差
/// </summary>
public List<BETMeasurement> InjectDegasAnomaly(List<BETMeasurement> data)
{
var copy = CloneData(data);
for (int i = 0; i < copy.Count; i++)
{
// 前几个点额外附加一个衰减的偏移量,模拟残留气体的干扰
float extraOffset = 8f * (float)Math.Exp(-i * 0.15);
copy[i].AdsorbedVolume += extraOffset;
if (extraOffset > 1.0f) copy[i].IsActuallyAnomaly = true;
}
return copy;
}
/// <summary>
/// 异常四:批次污染——局部单点或几个点突变
/// </summary>
public List<BETMeasurement> InjectSpikeAnomaly(
List<BETMeasurement> data, int[] spikeIndices, float spikeMagnitude = 0.25f)
{
var copy = CloneData(data);
foreach (var idx in spikeIndices)
{
if (idx < 0 || idx >= copy.Count) continue;
float sign = _rng.NextDouble() > 0.5 ? 1 : -1;
copy[idx].AdsorbedVolume *= (1 + sign * spikeMagnitude);
copy[idx].IsActuallyAnomaly = true;
}
return copy;
}
private List<BETMeasurement> CloneData(List<BETMeasurement> source)
{
return source.Select(m => new BETMeasurement
{
PointIndex = m.PointIndex,
RelativePressure = m.RelativePressure,
AdsorbedVolume = m.AdsorbedVolume,
Temperature = m.Temperature,
EquilibrationTime = m.EquilibrationTime,
IsActuallyAnomaly = m.IsActuallyAnomaly
}).ToList();
}
// Box-Muller变换,凑一个近似正态分布的随机数,别用纯均匀分布糊弄事
private float NextGaussian(float mean, float stdDev)
{
double u1 = 1.0 - _rng.NextDouble();
double u2 = 1.0 - _rng.NextDouble();
double randStdNormal = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2);
return (float)(mean + stdDev * randStdNormal);
}
}
}
3.3 特征提取器——从原始曲线里挖信息
namespace BETAnomalyDetection.FeatureEngineering
{
public class FeatureExtractor
{
/// <summary>
/// 把一整条曲线转换成带派生特征的数据集
/// </summary>
public List<BETFeatures> Extract(
List<BETMeasurement> measurements, int windowSize = 5)
{
var features = new List<BETFeatures>();
var volumes = measurements.Select(m => m.AdsorbedVolume).ToArray();
var pressures = measurements.Select(m => m.RelativePressure).ToArray();
// 先用简单线性回归拟合一条基准线,后面算偏离度要用
var (slope, intercept) = LinearFit(pressures, volumes);
for (int i = 0; i < measurements.Count; i++)
{
float localSlope = 0f;
if (i > 0)
{
float dv = volumes[i] - volumes[i - 1];
float dp = pressures[i] - pressures[i - 1];
localSlope = Math.Abs(dp) > 1e-6f ? dv / dp : 0f;
}
float fitValue = slope * pressures[i] + intercept;
float deviation = Math.Abs(volumes[i] - fitValue) / Math.Max(fitValue, 1e-3f);
// 滑动窗口方差,抓那种忽上忽下的抖动
int start = Math.Max(0, i - windowSize + 1);
var window = volumes.Skip(start).Take(i - start + 1).ToArray();
float variance = window.Length > 1 ? Variance(window) : 0f;
features.Add(new BETFeatures
{
RelativePressure = pressures[i],
AdsorbedVolume = volumes[i],
LocalSlope = localSlope,
DeviationFromFit = deviation,
RollingVariance = variance,
Label = measurements[i].IsActuallyAnomaly
});
}
return features;
}
private (float slope, float intercept) LinearFit(float[] x, float[] y)
{
int n = x.Length;
float sumX = x.Sum(), sumY = y.Sum();
float sumXY = x.Zip(y, (a, b) => a * b).Sum();
float sumX2 = x.Sum(v => v * v);
float slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
float intercept = (sumY - slope * sumX) / n;
return (slope, intercept);
}
private float Variance(float[] data)
{
float mean = data.Average();
return data.Sum(v => (v - mean) * (v - mean)) / data.Length;
}
}
}
四、上 ML.NET ,训练与检测代码走起
铺垫了这么多,终于到主菜了。ML.NET 里做异常检测,RandomizedPcaTrainer 是个不错的起点——原理是把正常数据投影到低维空间再重构回来,重构误差大的,大概率就是异常。
4.1 训练模型
using Microsoft.ML;
using Microsoft.ML.Data;
namespace BETAnomalyDetection.MLPipeline
{
public class BETAnomalyDetector
{
private readonly MLContext _mlContext;
private ITransformer _model;
public BETAnomalyDetector(int seed = 42)
{
_mlContext = new MLContext(seed: seed);
}
public void Train(List<BETFeatures> normalFeatures)
{
IDataView trainingData = _mlContext.Data.LoadFromEnumerable(normalFeatures);
var pipeline = _mlContext.Transforms
.Concatenate("Features",
nameof(BETFeatures.LocalSlope),
nameof(BETFeatures.DeviationFromFit),
nameof(BETFeatures.RollingVariance))
.Append(_mlContext.Transforms.NormalizeMinMax("Features"))
.Append(_mlContext.AnomalyDetection.Trainers.RandomizedPca(
featureColumnName: "Features",
rank: 2,
ensureZeroMean: true));
_model = pipeline.Fit(trainingData);
}
public AnomalyPrediction Predict(BETFeatures input)
{
var engine = _mlContext.Model
.CreatePredictionEngine<BETFeatures, AnomalyPrediction>(_model);
return engine.Predict(input);
}
// 批量预测,方便测试的时候整条曲线一起跑
public List<AnomalyPrediction> PredictBatch(List<BETFeatures> inputs)
{
var dataView = _mlContext.Data.LoadFromEnumerable(inputs);
var predictions = _model.Transform(dataView);
return _mlContext.Data
.CreateEnumerable<AnomalyPrediction>(predictions, reuseRowObject: false)
.ToList();
}
public void SaveModel(string path, DataViewSchema schema)
{
_mlContext.Model.Save(_model, schema, path);
}
}
}
4.2 完整测试代码——从生成数据到跑出结果
这段是可以直接扔进 Main 方法里跑起来的完整流程,训练集只用正常数据,测试集混入四种异常,看看模型抓得准不准:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using BETAnomalyDetection.Models;
using BETAnomalyDetection.Simulation;
using BETAnomalyDetection.FeatureEngineering;
using BETAnomalyDetection.MLPipeline;
namespace BETAnomalyDetection
{
class Program
{
static void Main()
{
// Windows 控制台默认 GBK,中文输出需要切到 UTF-8
Console.OutputEncoding = System.Text.Encoding.UTF8;
var simulator = new BETSimulator(seed: 42);
var extractor = new FeatureExtractor();
// ============ 第一步:造训练数据,只用正常曲线,一条都不能混异常 ============
Console.WriteLine("正在生成训练数据(纯正常曲线)...");
var trainingFeatures = new List<BETFeatures>();
for (int i = 0; i < 50; i++)
{
// 每次vm和c稍微浮动一下,模拟不同批次样品的天然差异
float vm = 100f + (float)new Random(i).NextDouble() * 40f;
float c = 50f + (float)new Random(i + 100).NextDouble() * 60f;
var curve = simulator.GenerateNormalIsotherm(vm, c);
var features = extractor.Extract(curve);
trainingFeatures.AddRange(features);
}
Console.WriteLine($"训练集共 {trainingFeatures.Count} 个数据点\n");
// ============ 第二步:训练模型 ============
var detector = new BETAnomalyDetector(seed: 42);
detector.Train(trainingFeatures);
Console.WriteLine("模型训练完成");
// 顺手把模型存一份,方便下次直接加载(对应文中的 SaveModel)
string modelPath = Path.Combine(AppContext.BaseDirectory, "models", "bet-pca.zip");
detector.SaveModel(modelPath);
Console.WriteLine($"模型已保存到:{modelPath}\n");
// ============ 第三步:造测试数据,每种异常来一条 ============
var testCases = new Dictionary<string, List<BETMeasurement>>();
var baseCurve = simulator.GenerateNormalIsotherm(vm: 115f, c: 70f);
testCases["正常曲线(对照组)"] = baseCurve;
testCases["真空泄漏"] = simulator.InjectLeakAnomaly(baseCurve, startIndex: 18);
testCases["传感器漂移"] = simulator.InjectDriftAnomaly(baseCurve);
testCases["脱气不彻底"] = simulator.InjectDegasAnomaly(baseCurve);
testCases["批次污染(局部突变)"] = simulator.InjectSpikeAnomaly(
baseCurve, spikeIndices: new[] { 10, 22 });
// ============ 第四步:逐一检测,看模型抓得准不准 ============
var summary = new List<(string Scenario, int Flagged, int TruePositive,
int FalsePositive, int FalseNegative, int TrueNegative, float Accuracy)>();
foreach (var (label, curve) in testCases)
{
Console.WriteLine($"===== 测试场景:{label} =====");
var features = extractor.Extract(curve);
var predictions = detector.PredictBatch(features);
int correctCount = 0;
int flaggedCount = 0;
int tp = 0, fp = 0, fn = 0, tn = 0;
for (int i = 0; i < predictions.Count; i++)
{
bool actual = features[i].Label;
bool predicted = predictions[i].PredictedLabel;
if (predicted) flaggedCount++;
if (actual == predicted) correctCount++;
switch (actual, predicted)
{
case (true, true): tp++; break;
case (false, true): fp++; break;
case (true, false): fn++; break;
default: tn++; break;
}
// 只打印被标记为异常,或者实际是异常但漏检的点,省得刷屏
if (predicted || actual)
{
Console.WriteLine(
$" 点 {i,2} p/p0={features[i].RelativePressure:F3} " +
$"实际异常:{(actual ? "是" : "否"),-3} " +
$"预测异常:{(predicted ? "是" : "否"),-3} " +
$"评分:{predictions[i].Score:F4}");
}
}
float accuracy = (float)correctCount / predictions.Count * 100;
Console.WriteLine($" --> 该曲线共标记 {flaggedCount} 个异常点," +
$"逐点准确率约 {accuracy:F1}%\n");
summary.Add((label, flaggedCount, tp, fp, fn, tn, accuracy));
}
// ============ 第五步:汇总各场景的混淆矩阵 ============
Console.WriteLine("===== 各场景检测汇总 =====");
Console.WriteLine($"{"场景",-22}{"标记点",6}{"命中",6}{"误报",6}{"漏报",6}{"正确",6}{"准确率",10}");
foreach (var s in summary)
{
Console.WriteLine(
$"{s.Scenario,-22}{s.Flagged,6}{s.TruePositive,6}{s.FalsePositive,6}" +
$"{s.FalseNegative,6}{s.TrueNegative,6}{s.Accuracy,9:F1}%");
}
// ============ 第六步:模拟"在线"逐点预警 ============
Console.WriteLine("\n===== 在线逐点预警演示(真空泄漏场景)=====");
Console.WriteLine("(定义:测试过程中每采集到一个数据点,就实时对这个点做异常预警)\n");
var leakCurve = testCases["真空泄漏"];
RunStreamingAlert(detector, extractor, leakCurve);
Console.WriteLine("\n全部测试跑完,收工。");
}
/// <summary>
/// 模拟测试过程中数据点逐个流入,每到一个点立刻做一次异常判断并预警。
/// 这里的"在线"指逐点实时预警,而不是等整条等温线测完再复盘。
/// </summary>
static void RunStreamingAlert(
BETAnomalyDetector detector, FeatureExtractor extractor, List<BETMeasurement> curve)
{
for (int i = 0; i < curve.Count; i++)
{
// 已采集到的点构成"当前曲线片段"
var collected = curve.Take(i + 1).ToList();
var features = extractor.Extract(collected);
var prediction = detector.Predict(features[^1]);
string status = prediction.PredictedLabel ? "⚠ 预警" : "✔ 正常";
Console.WriteLine(
$" [{i + 1,2}/{curve.Count}] p/p0={curve[i].RelativePressure:F3} " +
$"V={curve[i].AdsorbedVolume,8:F2} cm³/g " +
$"评分={prediction.Score:F4} {status}");
if (prediction.PredictedLabel)
{
Console.WriteLine(
$" >>> 建议暂停测试,检查真空密封与传感器状态(点 {curve[i].PointIndex})");
}
}
}
}
}
4.3 跑起来大概是这个效果
在我本地跑出来的结果大致是这样(异常检测这活儿本身有随机性,你跑出来数字未必一模一样,但趋势应该差不多):

真空泄漏这种局部抖动型异常,PCA 抓得挺准。脱气不彻底这种前段偏移型的也还行。但传感器漂移这种全局性、渐进式的偏移,PCA 的表现明显要差一些——原因也好理解,PCA 更擅长揪出局部离群点,那种"温水煮青蛙"式的整体偏移,容易被平滑掉。这块后面我打算换成 SSA 时序检测器再对比一下,有空写篇续篇。
五、几个我踩过的坑,提前给你打个预防针
坑一:归一化别偷懒
一开始我图省事没做归一化,AdsorbedVolume 的量级跟 RollingVariance 差了好几个数量级,PCA 的结果完全跑偏。后来加上 NormalizeMinMax,效果立马好转。别学我,一开始就做归一化。
坑二:训练数据里千万别混进异常样本
这个说出来有点尴尬——我一开始生成训练数据时,脚本有个 bug,混进去了几条异常曲线。模型学出来的"正常"边界特别宽松,什么都检测不出来。异常检测这活儿,训练集的"纯洁度"比啥都重要。上面测试代码里我特意注释标出来了,训练集只能用纯正常曲线。
坑三:rank 参数别瞎设
PCA 的 rank(降维目标维度)设太高,等于啥都没降,异常和正常混一块儿分不开;设太低,又把有用信息压没了。我这个场景特征维度本来就不多,试了几轮,2 到 3 之间比较合适。具体到你的场景,多试试是逃不掉的。
坑四:局部斜率在起点会失真
LocalSlope 计算依赖前一个点,曲线第一个点没法算,我这里简单粗暴地设成了 0——这算是个小瑕疵,你要是较真,可以考虑用前向差分补一下,或者干脆把第一个点排除在异常判断之外。
六、说点大实话:这方案到底靠不靠谱
技术上,ML.NET 干这活儿完全没问题,实现难度不算高。但我得诚实地说几个局限:
第一,仿真数据终归是仿真数据。真实设备的噪声特性、异常模式的分布,跟我拿数学公式凑出来的东西,多少有点距离。这篇文章更像是一次方法论上的探路,不是一份可以直接搬进产线的解决方案。
第二,BET 测试的"在线"属性本来就弱,这套东西更适合定位成测试过程中的实时预警辅助,而不是什么颠覆性的自动化神器。指望它完全替代人工判断,还早。
第三,如果真要往实际场景落地,接下来得干的事情包括:拿真实设备数据校准、跟经验丰富的实验员一起定义"异常"的边界(毕竟有些"异常"其实是正常的仪器波动)、还得考虑不同批次样品本身的差异性。
这些都不是靠调参数能解决的,得跟一线的人反复磨合。
写在最后
聊了这么多,其实核心意思就一句话:用 ML.NET 做 BET 异常检测,技术上走得通,但别指望一步到位。真正的难点从来不在"用什么框架",而在于你对这个领域的理解有多深——特征怎么提,异常怎么定义,这些都得靠跟一线打交道攒经验。
学习路线的话,如果你对这个方向感兴趣,建议往这几个方向深挖:
- ML.NET 官方文档里关于异常检测的模块(
Microsoft.ML.TimeSeries)
- 时序异常检测的经典算法:SSA、孤立森林、LSTM-Autoencoder
- 材料表征领域的信号处理基础,这块知识储备能帮你更懂特征该怎么设计
三句话给自己也给大家提个醒:
模型再花哨,不如特征工程扎实。仿真数据能验证思路,验证不了真实世界。"在线"这个词,用之前先想清楚自己到底指的是什么。
技术这条路,走一步看一步,共勉。
如果你也在捣鼓 ML.NET 或者材料检测相关的东西,欢迎来云栈社区一起交流,那边有不少做 C# 和机器学习交叉方向的开发者。