1. 重复故障工单筛选与去重口径设计

问题拆解:重复故障不是“看起来像”,而是可计算的四元组

在城轨车辆维修工单里,同一故障常被不同班次、不同司机、不同检修人员反复报修,但工单文本、时间、原因码写法并不一致。若直接按“故障描述相似”去重,会把不同部件、不同原因的工单误并;若只按“同部件”去重,又会把偶发故障当成重复故障。本例把重复故障判定收敛为一个可配置的四元组口径:同部件 + 同现象 + 同原因码 + 时间窗,并在此基础上做聚类去重,输出重复故障清单,同时验证口径变化对重复率统计的影响。

口径定义(示例):两条工单满足以下全部条件时判为同一重复故障簇——
① 部件编码 part_code 相同;
② 现象编码 phenomenon_code 相同(本例先用已编码字段,文本标准化留待后续案例);
③ 原因码 cause_code 相同(允许配置是否把“待定/未填”视为同码);
④ 两条工单的报修时间差 ≤ 时间窗 window_h(小时)。
聚类采用“单链传递”:A 与 B 在窗内、B 与 C 在窗内,则 A、B、C 归入同一簇,即使 A 与 C 的时间差超过窗口。

判定逻辑与聚类传递关系

下图把四元组口径和单链传递聚类的关键关系画在一起:左侧是判定条件,右侧是同一部件下多条工单按时间排列后被聚成簇的情形。注意簇的“代表工单”取簇内最早一条,重复率按“被合并掉的工单数 / 原始工单数”计算。

判定口径(四元组) ① 同部件 part_code 例:制动控制单元 BCU-01 ② 同现象 phenomenon_code 例:P07 制动缓解慢 ③ 同原因码 cause_code 例:C12 阀体卡滞(可配待定策略) ④ 时间窗 ≤ window_h 例:72 小时(可扫描对比) 聚类规则:单链传递 A–B 在窗内、B–C 在窗内 ⇒ A、B、C 同簇(A–C 可超窗) 代表工单 = 簇内最早一条 同一部件 BCU-01 / 现象 P07 下的工单时间轴 t 0h 24h 48h 72h 96h 簇① 代表 W1(0h) W1 W2 W3 ≤72h ≤72h 簇② 代表 W4(60h) W4 W5 ≤72h 簇③ W6 W6 口径对统计的影响(示例数据) 原始工单 6 条 → 去重后 3 簇 → 重复率 = (6−3)/6 = 50% 若 window_h 收紧到 24h:W1–W2 同簇,W3 独立,簇数变 4,重复率降至 33% 若忽略原因码:W6 若原因码不同也会被并入簇②,重复率虚高 若把“待定”原因码视为同码:可能把不同根因的工单误并 结论:重复率不是固定值,必须随口径一起报告 (window_h、原因码策略、传递规则)

可运行实现:聚类去重与口径扫描

下面用 Python 标准库实现,不依赖第三方包。核心是先把工单按“部件+现象+原因码”分组,再在组内按时间排序做单链聚类;同时提供口径扫描函数,输出不同时间窗下的重复率,便于验证口径敏感性。

from datetime import datetime, timedelta
from collections import defaultdict

# 示例工单:id, part_code, phenomenon_code, cause_code, report_time
RAW = [
    ("W1", "BCU-01", "P07", "C12", "2024-03-01 08:00"),
    ("W2", "BCU-01", "P07", "C12", "2024-03-02 09:00"),
    ("W3", "BCU-01", "P07", "C12", "2024-03-03 10:00"),
    ("W4", "BCU-01", "P07", "C12", "2024-03-03 20:00"),
    ("W5", "BCU-01", "P07", "C12", "2024-03-04 21:00"),
    ("W6", "BCU-01", "P07", "C99", "2024-03-05 08:00"),
]

def parse(t):
    return datetime.strptime(t, "%Y-%m-%d %H:%M")

def cluster(rows, window_h, merge_pending=False):
    """返回 (簇列表, 重复率)。簇为工单 id 列表,代表工单为簇内最早一条。"""
    groups = defaultdict(list)
    for wid, part, phen, cause, ts in rows:
        key_cause = "PENDING" if (merge_pending and cause in ("", "待定", None)) else cause
        groups[(part, phen, key_cause)].append((parse(ts), wid))

    clusters = []
    for key, items in groups.items():
        items.sort()
        cur = [items[0]]
        for prev, nxt in zip(items, items[1:]):
            if (nxt[0] - prev[0]) <= timedelta(hours=window_h):
                cur.append(nxt)          # 单链传递:与上一条在窗内即并入
            else:
                clusters.append(cur)
                cur = [nxt]
        clusters.append(cur)

    total = len(rows)
    dup_rate = (total - len(clusters)) / total if total else 0.0
    return clusters, dup_rate

def scan(rows, windows, merge_pending=False):
    out = []
    for w in windows:
        clusters, rate = cluster(rows, w, merge_pending)
        out.append((w, len(clusters), round(rate, 4)))
    return out

if __name__ == "__main__":
    clusters, rate = cluster(RAW, window_h=72)
    print("簇数:", len(clusters), "重复率:", round(rate, 4))
    for c in clusters:
        print("代表:", c[0][1], "成员:", [x[1] for x in c])

    print("\n时间窗扫描(小时, 簇数, 重复率):")
    for row in scan(RAW, [24, 48, 72, 96]):
        print(row)

按示例数据运行,预期输出为:72 小时窗口下 3 个簇,重复率 0.5;簇①成员 W1/W2/W3,簇②成员 W4/W5,簇③成员 W6。时间窗扫描会显示 24h 时簇数变为 4、重复率约 0.3333,96h 时 W3 与 W4 也可能被单链并入,簇数进一步下降。这些数值由给定输入直接推出,属于示例结果,不是实测统计。

口径设计要点与验证清单

  • 原因码缺失策略要显式化:merge_pending=False 时“待定”自成一码,避免把未定根因的工单强行并入已定原因簇;若业务上认为待定应跟随同部件同现象,再开启合并,但必须在报告中注明。
  • 单链传递会放大簇:连续多次在窗内的工单会连成一条长链,簇的时间跨度可能远超窗口。若业务要求“簇内任意两条都在窗内”,应改用完全连接聚类,并同步修改重复率口径。
  • 重复率必须与口径绑定报告:同一批工单在 24h/72h 下重复率不同,单独报一个数字没有可比性。建议输出“窗口 + 原因码策略 + 传递规则 + 簇数 + 重复率”五元组。
  • 去重清单要保留代表工单与成员:代表工单用于后续根因分析的主工单,成员列表用于核对是否误并;两者都输出,便于人工抽检。
  • 边界验证:构造恰好等于窗口的工单对(应并入)、超过窗口 1 分钟的工单对(应分开)、原因为空与原因已填的工单对(按策略验证),确认实现与口径一致。
建议把 cluster() 的输入输出固化为可回归的测试用例:给定 6 条示例工单和 72h 窗口,断言簇数为 3、重复率为 0.5;再给定 24h 窗口,断言簇数为 4。这样后续调整口径时能立刻发现统计口径漂移。