4. 表格数据清洗与缺失值处理

真实世界的表格数据几乎不可能“开箱即用”。传感器采集的数值可能因通信中断而空缺,人工录入的字段可能混入空格或单位,多个系统合并后可能出现完全重复的记录,而极端值又常常掩盖真实的数据分布。MATLAB 的 table 数据类型为这些清洗任务提供了统一的容器:每一列有明确的变量名和类型,行可以带行标签,缺失值用 missing 统一表示。本节围绕“识别—处理—规范化—去重—标记”这条主线,讲清楚每一步在 MATLAB 中具体怎么做、结果如何判断。

一、缺失值的识别:先看清缺口在哪里

MATLAB 中不同数据类型的缺失表示并不相同:数值型默认用 NaN,字符串用 <missing>,分类变量用 <undefined>,datetime 用 NaT。判断缺失最稳妥的方式是 ismissing,它对上述类型都能正确识别,而 isnan 只适用于数值数组。对整张表调用 ismissing(T) 会返回同尺寸的逻辑矩阵,再配合 sum 即可得到每列缺失数量:

T = readtable("sensor_log.csv", TextType="string");
missCount = sum(ismissing(T), 1);          % 每列缺失个数
missRatio = missCount / height(T);          % 每列缺失比例
disp(table(T.Properties.VariableNames', missCount', missRatio', ...
    VariableNames=["变量","缺失数","缺失比例"]))

识别阶段的关键判断是“缺失是否有规律”。如果某一列的缺失比例超过 40%,或者缺失集中在某一段时间、某一台设备上,那么简单填充可能引入系统性偏差,此时更应考虑删除该列或回溯数据源。可以用 ismissing 的结果按行求和,观察缺失是否成簇出现:rowMiss = sum(ismissing(T), 2); histogram(rowMiss),若大量行同时缺多个字段,往往说明整条记录采集失败。

表格数据清洗处理链与判断分支 原始 table 数值 / 字符串 / 时间 含 NaN、空串、重复行 类型混杂、单位不一 readtable 读入 ① 识别缺失 ismissing(T) sum 统计每列缺失 按行求和看缺失簇 输出缺失比例表 缺失比例 > 40% ? 是 删除整列 / 回溯数据源 removevars 或重新采集 否 ② 填充或删除 fillmissing:均值/中位数/前值 rmmissing:按行删除 按分组填充(设备/时段) 保留缺失标记列备查 ③ 标准化变量类型 convertvars / str2double / datetime ④ 去重 + ⑤ 异常值标记 unique / isoutlier / 新增标记列 清洗后 table 类型一致、无重复、异常已标记 判断依据:缺失比例、缺失是否成簇、字段业务重要性、异常值是否来自真实工况。 原则:先识别再处理,填充策略要可解释,删除操作要留痕,异常值优先标记而非直接剔除。

二、填充还是删除:让策略可解释

缺失处理没有万能公式,但有一条原则:处理方式必须能说清楚理由。数值型变量的常用填充方式包括均值、中位数、前一个有效值(适用于时间序列)以及按分组统计量填充。fillmissing 的 Method 参数支持 "mean"、"median"、"previous"、"linear" 等。对于传感器时序数据,线性插值通常比均值更合理,因为它保留了变化趋势:

T.temp = fillmissing(T.temp, "linear");          % 温度按时间线性插值
T.status = fillmissing(T.status, "previous");    % 状态量沿用上一有效值
T.pressure = fillmissing(T.pressure, "median");  % 压力用中位数抗极端值

当缺失行占比很小(例如低于 5%)且缺失分散时,T = rmmissing(T) 直接删除含缺失的行是简单可靠的选择;若只想在特定变量上判断,可用 rmmissing(T, DataVariables=["temp","pressure"])。需要强调的是,删除会改变样本量,若后续要做分组统计,应记录删除前后的行数对比,避免结论建立在被悄悄削减的数据上。一个实用习惯是新增一列 wasFilled 逻辑标记,记录哪些单元格被填充过,便于后续敏感性分析。

三、标准化变量类型:让每列“名副其实”

readtable 读入 CSV 时,若某列混有数字和文本,往往会被推断为字符串或字符向量,导致后续无法直接计算。类型标准化的目标是:数值列是 double,类别列是 categorical,时间列是 datetime,标识列是 string。常用手段有三种:

  • convertvars 批量转换指定变量,适合一次处理多列;
  • str2double 处理带千分位或空格的数值字符串,转换失败会得到 NaN,正好与缺失识别衔接;
  • datetime 配合 InputFormat 明确时间格式,避免“03/04/2024”被误解析为月/日或日/月。
T.unit = categorical(T.unit);                       % 单位转为分类变量
T.time = datetime(T.time, InputFormat="yyyy-MM-dd HH:mm:ss");
T.flow = str2double(erase(string(T.flow), ","));    % 去掉千分位再转数值
T = convertvars(T, ["deviceID","batch"], "string");

转换后应做一次“类型体检”:summary(T) 会列出每列的最小值、最大值、缺失数和类别计数,能快速暴露“本应是数值却仍是字符串”的问题。分类变量还有一个额外好处:categories(T.unit) 可以直接看出是否存在“kg”“KG”“千克”这类同义异写,为后续统一口径提供依据。

四、重复行处理:先定义“重复”

去重前必须先明确按哪些列判重。整行完全一致用 unique(T, "rows") 即可;但更常见的情形是“同一设备同一时刻被重复上报”,此时应只按关键列判重并保留一条:

[~, idx] = unique(T(:, ["deviceID","time"]), "rows", "stable");
T = T(idx, :);                       % stable 保留首次出现顺序
dupCount = height(T_before) - height(T);

"stable" 参数很重要,它保证保留的是原始顺序中第一次出现的记录,而不是排序后的任意一条。若重复记录的数值字段不一致,说明存在冲突,此时不应盲目保留第一条,而应按业务规则取舍,例如保留时间戳最新的一条,或对数值取平均并单独记录冲突条数。

五、异常值标记:标记优先于剔除

异常值不一定是错误值,也可能是真实工况。稳妥做法是先标记、后决策。isoutlier 支持 "median"(基于中位数绝对偏差)、"mean"、"quartiles" 等方法,返回逻辑向量:

isOut = isoutlier(T.pressure, "quartiles");
T.pressureFlag = string(repmat("正常", height(T), 1));
T.pressureFlag(isOut) = "疑似异常";
fprintf("疑似异常记录:%d 条(占 %.1f%%)\n", sum(isOut), 100*mean(isOut));

标记之后,可以结合业务阈值二次判断:例如压力超过量程上限的必然是采集错误,应置为缺失再填充;而落在量程内但偏离分布的,可能只是设备启停瞬间的真实波动,应保留并备注。把“统计异常”和“物理越界”分开处理,是避免误删有效数据的关键。

建议把清洗过程写成一段可重复执行的脚本,并在开头用 rng 固定随机种子(若涉及随机抽样)。每次运行输出“原始行数、删除行数、填充单元格数、重复行数、异常标记数”五项统计,形成可追溯的清洗日志。

把上述步骤串起来,一个典型的清洗流程是:读入后先统计缺失比例,对高缺失列决定删除或回溯;对保留列按变量特性选择填充方法并留下标记;统一数值、分类、时间三类变量的类型;按业务主键去重;最后用 isoutlier 生成异常标记列。整个过程不追求“一步到位”,而追求每一步都有据可查、可回退、可复现——这正是表格数据清洗从“能跑”走向“可信”的分界线。