IXNORFID Insight
← 返回洞察
物理智能2026-09-23 · 18 min

盘点算法栈:从自动数数到自动决策

置信度评分、贝叶斯缺货检测、异常分类、趋势预测——六层算法把 RFID 盘点从计数器变成决策引擎。

上一篇把物体词典从 200 行原型拉成了生产架构——事件流、滞回去抖、分层存储。状态机解决了「这个标签在不在」的问题。但盘点要回答的从来不只是「在不在」。

仓库经理真正想问的是:哪个货架该补货了?哪些商品可能被放错了位置?这批标签的数据靠不靠谱?下周的促销会不会导致某个 SKU 断货?

这些问题,状态机一个都答不了。状态机只能告诉你「EPC 为 X 的标签当前在货架 A」。从「在不在」到「该怎么办」,中间差着一整层算法。

盘点自动化解决了「数得快」的问题。算法解决的是「数完之后干什么」。这篇文章把这一层拆开看。

一、从读取到决策:六层算法栈

在盘点管线那篇里,我们搭了一条从原始读取到状态快照的管线:读取 → 清洗 → 状态机 → 快照。状态机输出的是每个标签的在场状态和置信度。这是地基,但只是地基。

在地基之上,还需要五层算法。从底到顶依次是:

第一层:置信度评分。不是所有标签状态都同样可靠。一个每秒被读到 20 次的标签,和一个 30 秒才读到一次的标签,虽然状态机都判「在场」,但你对它们的信心完全不同。置信度评分把这个「信心」量化成一个 0 到 1 的数字。

第二层:贝叶斯推断。标签没被读到,不代表它不在。可能是遮挡、盲区、标签损坏。贝叶斯方法根据历史读取模式,计算「标签实际在场但没被读到」的概率,把假缺货和真缺货分开。

第三层:异常检测。有了可靠的状态和置信度,才能做异常检测。缺货、放错位置、幽灵标签——这三种异常的处理逻辑完全不同,需要先分类再处理。

第四层:趋势预测。知道现在有多少不够,还要知道未来会怎样。指数平滑做短期预测,回答「按这个速度,三天后还够不够」的问题。

第五层:自适应调参。不同商品的读取特性差异很大。高频移动的商品和三个月不动的滞销品,用同一套参数不合理。算法需要自动识别商品特性,调整监控密度和告警灵敏度。

这五层加上底层的状态机,构成六层算法栈。每一层依赖下面那层的输出,每一层的输出喂给上面那层。从原始 TagRead 到可执行的盘点决策,数据逐层变干净,信息逐层变浓缩。

二、置信度评分:不是所有「在场」都一样可靠

2.1 问题

状态机输出 PRESENT,你就信它?一个标签过去一小时被读到 300 次,另一个标签只被读到 2 次——都是 PRESENT,但可靠性天差地别。前者你几乎可以确定它真的在,后者可能是串读导致的假在场。

更麻烦的是时间衰减。一个标签 5 分钟前被读到 100 次,然后突然消失了。在消失的那一刻,它的状态还是 PRESENT(因为滞回去抖还没触发离场)。但你对它「在场」的信心已经在快速下降。

2.2 评分模型

置信度由两个因子组成:读取频率和信号稳定性。

import math

def compute_confidence(count, rssi_values):
    """
    count: 时间窗口内有效读取次数
    rssi_values: 最近 N 次读取的 RSSI 列表
    """
    # 因子 1:读取频率(对数饱和)
    # 读 1 次 → 0.32,读 10 次 → 1.0,读 100 次 → 1.0
    count_factor = min(1.0, math.log(max(count, 1) + 1) / math.log(11))

    # 因子 2:信号稳定性(RSSI 越稳定越可信)
    if len(rssi_values) < 2:
        spread_factor = 0.5  # 数据不足,给个中间值
    else:
        spread = max(rssi_values) - min(rssi_values)
        spread_factor = math.exp(-spread / 20)

    return count_factor * spread_factor

两个因子的设计思路:

读取频率用对数而不是线性。因为从 1 次到 10 次的信心提升远大于从 100 次到 110 次。对数函数天然捕捉这种边际递减。底数选 11 是因为 log₁₁(11) = 1,也就是读到 10 次就满分了。这个阈值可以根据实际场景调。

信号稳定性用指数衰减。RSSI 极差 20 dBm 时因子降到 e⁻¹ ≈ 0.37,极差 5 dBm 时因子是 e⁻⁰·²⁵ ≈ 0.78。信号越稳定,说明标签和读写器之间的射频环境越稳定,读取结果越可信。

2.3 时间衰减

置信度不是算完就固定了。标签每多一秒没被读到,信心就往下掉一点。

class ConfidenceTracker:
    def __init__(self, decay_rate=0.1):
        self.scores = {}  # epc -> {score, last_update}
        self.decay_rate = decay_rate

    def update(self, epc, count, rssi_values, now):
        fresh = compute_confidence(count, rssi_values)
        if epc in self.scores:
            dt = now - self.scores[epc]["last_update"]
            old = self.scores[epc]["score"]
            # 新分数取 max(实时计算值, 衰减后的旧值)
            decayed = old * math.exp(-self.decay_rate * dt)
            self.scores[epc] = {"score": max(fresh, decayed), "last_update": now}
        else:
            self.scores[epc] = {"score": fresh, "last_update": now}

    def get(self, epc, now):
        if epc not in self.scores:
            return 0.0
        dt = now - self.scores[epc]["last_update"]
        return self.scores[epc]["score"] * math.exp(-self.decay_rate * dt)

    def decay_all(self, now):
        """定期调用,清理置信度降到阈值以下的标签"""
        dead = [epc for epc in self.scores
                if self.get(epc, now) < 0.05]
        for epc in dead:
            del self.scores[epc]

衰减率 decay_rate 控制信心消失的速度。0.1 意味着 10 秒没被读到,置信度降到原来的 e⁻¹ ≈ 37%。对于快速移动的货品(比如流水线上的包裹),这个值要调大;对于静止的货架商品,调小。

衰减机制的实际效果:一个高置信度的标签短暂漏读几次,置信度从 0.9 掉到 0.7,还是「可信在场」。一个低置信度的标签(只被读到 1 次),几秒没被读到就掉到 0.05 以下被清理掉。这比硬性的「N 次没读到就判离场」更细腻。

三、贝叶斯缺货检测:没读到不等于不在

3.1 问题的本质

盘点最头疼的事:标签没被读到,到底是缺货了,还是读头没读到?

状态机的滞回去抖已经缓解了一部分——连续 N 个窗口没读到才判离场。但 N 选多大是个难题。N 太小,假离场多;N 太大,真缺货发现得慢。

贝叶斯方法提供了一个更优雅的框架:不用硬阈值,用概率。

3.2 模型

我们想求的是 P(在场|没读到)。根据贝叶斯定理:

def bayesian_stockout(prior_present, p_detect_if_present, p_false_read):
    """
    prior_present: 标签在场的基础概率(历史在场时间占比)
    p_detect_if_present: 标签在场时被读到的概率(读取覆盖率)
    p_false_read: 标签不在时被读到的概率(串读率)

    返回: P(在场|本次没读到)
    """
    p_miss_if_present = 1 - p_detect_if_present
    p_miss_if_absent = 1.0  # 不在的话肯定读不到(忽略串读)

    p_miss = (p_detect_if_present * prior_present +
              p_false_read * (1 - prior_present))

    if p_miss == 0:
        return prior_present

    return p_miss_if_present * prior_present / p_miss

举个具体例子。一个标签过去 30 天有 95% 的时间在场(prior_present = 0.95)。读头对它的检测率是 90%(p_detect_if_present = 0.9)。串读率极低(p_false_read = 0.01)。

某一次盘点窗口没读到它。代入公式:P(在场|没读到) = 0.1 × 0.95 / (0.1 × 0.95 + 1.0 × 0.05) = 0.095 / 0.145 ≈ 0.66。

也就是说,即使这次没读到,它实际在场的概率还有 66%。不应该急着报缺货。

但如果连续 3 个窗口都没读到呢?把 0.66 作为新的先验再算一次:P ≈ 0.1 × 0.66 / (0.1 × 0.66 + 1.0 × 0.34) ≈ 0.16。再算一次:P ≈ 0.02。三次之后,缺货概率就到了 98%,该告警了。

3.3 实现

class BayesianStockMonitor:
    def __init__(self):
        self.tags = {}

    def update(self, epc, detected, now):
        if epc not in self.tags:
            self.tags[epc] = {
                "p_present": 0.5,  # 初始无信息,给 50%
                "total_windows": 0,
                "present_windows": 0,
            }

        tag = self.tags[epc]
        tag["total_windows"] += 1
        if detected:
            tag["present_windows"] += 1

        # 动态估计先验和检测率
        prior = tag["present_windows"] / max(tag["total_windows"], 1)
        p_detect = 0.9  # 可根据历史数据动态估计

        if detected:
            # 读到了 → 提升在场概率
            tag["p_present"] = min(0.999,
                tag["p_present"] * p_detect /
                (tag["p_present"] * p_detect +
                 (1 - tag["p_present"]) * 0.01))
        else:
            # 没读到 → 用贝叶斯更新
            tag["p_present"] = bayesian_stockout(
                tag["p_present"], p_detect, 0.01)

    def is_stockout(self, epc, threshold=0.1):
        if epc not in self.tags:
            return False
        return self.tags[epc]["p_present"] < threshold

这个方法的好处是自适应。一个新标签刚开始没有历史数据,先验是 50%,几次读取之后就快速收敛。一个长期在场的标签偶尔漏读一两次,概率下降很少。一个频繁移动的标签连续几次没读到,概率下降很快。不需要人工设阈值,数据自己说话。

四、异常检测:三种「不对劲」

置信度和贝叶斯概率给了我们更精确的状态判断。在此基础上,异常检测就是把「不对劲」分类。

RFID 盘点里常见的异常有三种,处理逻辑完全不同:

类型 A:缺货。标签应该在但不在。概率低于阈值,触发补货或查找。上面贝叶斯方法已经在做这件事。

类型 B:放错位置。标签在,但出现在了不该出现的地方。比如一箱牛奶出现在了日用品货架上。这种异常不会触发缺货告警,但同样影响运营。

类型 C:幽灵标签。系统里有一个标签记录,但物理世界中它可能已经不存在了。标签损坏、被误带走、或者录入错误。这种异常最隐蔽,因为状态机可能一直报「在场」。

4.1 位置异常检测

class LocationAnomalyDetector:
    def __init__(self):
        # epc -> expected_zone(从业务系统获取的预期位置)
        self.expected = {}
        # epc -> 连续位置不匹配计数
        self.mismatch_count = {}

    def set_expected(self, epc, zone):
        self.expected[epc] = zone
        self.mismatch_count[epc] = 0

    def check(self, epc, actual_zone):
        if epc not in self.expected:
            return None  # 没有预期位置,不判断

        if actual_zone != self.expected[epc]:
            self.mismatch_count[epc] = \
                self.mismatch_count.get(epc, 0) + 1
            if self.mismatch_count[epc] >= 3:
                return "MISPLACED"
        else:
            self.mismatch_count[epc] = 0
        return None

连续 3 次位置不匹配才报异常,又是滞回思路。单次的位置跳变可能是串读,连续多次才是真的放错了。

4.2 幽灵标签检测

幽灵标签的特征是「永远在场、从不移动」。一个标签在货架上待了 30 天,一次都没离开过——这不正常。正常商品迟早会被拿走或移动。

class GhostTagDetector:
    def __init__(self, stale_days=7):
        self.stale_days = stale_days
        self.tags = {}  # epc -> {first_seen, last_moved, move_count}

    def observe(self, epc, zone, now):
        if epc not in self.tags:
            self.tags[epc] = {
                "first_seen": now,
                "last_zone": zone,
                "last_moved": now,
                "move_count": 0,
            }
            return

        tag = self.tags[epc]
        if zone != tag["last_zone"]:
            tag["last_moved"] = now
            tag["last_zone"] = zone
            tag["move_count"] += 1

    def check_ghost(self, epc, now):
        tag = self.tags.get(epc)
        if not tag:
            return False
        age_days = (now - tag["first_seen"]) / 86400
        idle_days = (now - tag["last_moved"]) / 86400
        # 存在超过 stale_days 且从未移动 → 疑似幽灵
        return (age_days > self.stale_days and
                tag["move_count"] == 0)

幽灵标签检测的阈值 stale_days 取决于场景。零售门店里,一件商品 7 天不动就不正常了。仓库里的备货可能一个月不动也合理。这个参数要按区域和品类来配。

五、趋势预测:从「现在有多少」到「还能撑多久」

知道货架上有 20 瓶水,不够。还要知道按当前的出库速度,这 20 瓶能撑多久。

5.1 指数平滑预测

class InventoryPredictor:
    def __init__(self, alpha=0.3):
        self.alpha = alpha  # 平滑系数,越大越敏感
        self.history = {}   # epc -> {level, trend, last_update}

    def observe_change(self, epc, delta, now):
        """delta: 库存变化量(出库为负,入库为正)"""
        if epc not in self.history:
            self.history[epc] = {
                "level": delta,
                "trend": 0,
                "last_update": now,
                "changes": [],
            }
            return

        h = self.history[epc]
        new_level = h["level"] + delta
        # 趋势也用指数平滑
        h["trend"] = (self.alpha * (new_level - h["level"]) +
                      (1 - self.alpha) * h["trend"])
        h["level"] = new_level
        h["last_update"] = now
        h["changes"].append((now, delta))
        # 只保留最近 50 条变化记录
        h["changes"] = h["changes"][-50:]

    def predict(self, epc, hours_ahead=24):
        if epc not in self.history:
            return None
        h = self.history[epc]
        if h["trend"] >= 0:
            return {"status": "stable", "remaining": h["level"]}
        # 预测耗尽时间
        if h["trend"] < 0:
            hours_to_zero = h["level"] / abs(h["trend"]) * 3600
            return {
                "status": "depleting",
                "current_level": h["level"],
                "trend_per_hour": h["trend"],
                "hours_to_zero": max(0, hours_to_zero),
                "reorder_soon": hours_to_zero < hours_ahead * 2,
            }

指数平滑的 alpha 参数控制预测的敏感度。alpha = 0.3 意味着最新一次变化占 30% 权重,历史趋势占 70%。对于日常消费品,0.3 够用。对于促销场景,可以临时调高到 0.7,让预测更快反映突变。

5.2 热点分析

不是所有商品都值得同样密度的监控。一件每天出库 50 次的爆款和一件每月动一次的老库存,用同一套参数是浪费。

import statistics

class HotspotAnalyzer:
    def __init__(self):
        self.items = {}  # epc -> {moves: [timestamps]}

    def record_move(self, epc, now):
        self.items.setdefault(epc, {"moves": []})
        self.items[epc]["moves"].append(now)
        # 只保留最近 7 天
        cutoff = now - 7 * 86400
        self.items[epc]["moves"] = [
            t for t in self.items[epc]["moves"] if t > cutoff]

    def classify(self, epc):
        moves = self.items.get(epc, {}).get("moves", [])
        if len(moves) < 10:
            return "cold"  # 数据不足,默认低频

        # 计算变异系数(CV)
        intervals = [moves[i+1] - moves[i]
                     for i in range(len(moves) - 1)]
        if not intervals:
            return "cold"
        mean = statistics.mean(intervals)
        if mean == 0:
            return "hot"
        cv = statistics.stdev(intervals) / mean

        if cv > 0.5 or mean < 3600:
            return "hot"   # 高频或不规律 → 密集监控
        elif cv > 0.2 or mean < 86400:
            return "warm"  # 中频
        else:
            return "cold"  # 低频 → 常规监控

分类结果影响下游参数。hot 商品的置信度衰减率调低(不容易丢失监控),异常检测阈值调严(更敏感),预测模型 alpha 调高(更快反映变化)。cold 商品反过来,省计算资源。

六、算法流水线:从读取到决策

把上面的算法串起来,一条完整的决策流水线:

class InventoryDecisionPipeline:
    def __init__(self):
        self.confidence = ConfidenceTracker()
        self.stock_monitor = BayesianStockMonitor()
        self.location_det = LocationAnomalyDetector()
        self.ghost_det = GhostTagDetector()
        self.predictor = InventoryPredictor()
        self.hotspot = HotspotAnalyzer()

    def process_reads(self, reads, now):
        """每个盘点窗口调用一次"""
        decisions = []

        for epc, read_info in reads.items():
            detected = read_info["detected"]
            zone = read_info.get("zone")
            rssi_vals = read_info.get("rssi_values", [])
            count = read_info.get("count", 0)
            delta = read_info.get("delta", 0)

            # 第一层:置信度
            self.confidence.update(epc, count, rssi_vals, now)
            conf = self.confidence.get(epc, now)

            # 第二层:贝叶斯缺货检测
            self.stock_monitor.update(epc, detected, now)

            # 第三层:异常检测
            if zone:
                loc_status = self.location_det.check(epc, zone)
                if loc_status == "MISPLACED":
                    decisions.append({
                        "type": "MISPLACED",
                        "epc": epc,
                        "zone": zone,
                        "confidence": conf,
                    })

            self.ghost_det.observe(epc, zone or "", now)
            if self.ghost_det.check_ghost(epc, now):
                decisions.append({
                    "type": "GHOST_TAG",
                    "epc": epc,
                    "confidence": conf,
                })

            if (self.stock_monitor.is_stockout(epc) and
                    conf > 0.3):
                decisions.append({
                    "type": "STOCKOUT",
                    "epc": epc,
                    "p_present": self.stock_monitor.tags[epc]
                        ["p_present"],
                    "confidence": conf,
                })

            # 第四层:趋势预测
            if delta != 0:
                self.predictor.observe_change(epc, delta, now)
                self.hotspot.record_move(epc, now)

            pred = self.predictor.predict(epc, hours_ahead=24)
            if pred and pred.get("reorder_soon"):
                decisions.append({
                    "type": "REORDER_SOON",
                    "epc": epc,
                    "hours_to_zero": pred["hours_to_zero"],
                    "current_level": pred["current_level"],
                })

        return decisions

流水线输出四种决策:STOCKOUT(缺货)、MISPLACED(放错位置)、GHOST_TAG(幽灵标签)、REORDER_SOON(即将需要补货)。每种决策都带着置信度,下游系统可以根据置信度决定是自动处理还是推送给人工确认。

一个工程上的关键点:这条流水线必须能回溯。参数调了之后,拿历史事件流重跑一遍,看新参数下的决策和旧参数有什么不同。这就是为什么事件驱动架构很重要——事件流不丢,算法可以反复迭代。如果事件流丢了,算法调参就只能靠线上盲调。

七、参数调优:让算法自己学

上面提到了很多参数:置信度衰减率、贝叶斯先验、异常检测阈值、平滑系数。这些参数怎么定?

最笨的办法是人工拍脑袋。稍微好一点的办法是拿历史数据跑网格搜索。但最好的办法是让系统自己调。

思路很简单:记录每次告警的结果。报了缺货,运维去查,确实缺货——真阳性。报了缺货,其实货在但标签坏了——假阳性。没报缺货,但其实已经缺货了——假阴性。

class ParameterTuner:
    def __init__(self):
        self.results = []  # [(prediction, actual_outcome)]

    def record(self, decision, actual):
        """
        decision: pipeline 输出的决策
        actual: 人工反馈的真实情况
          True = 确实有问题
          False = 误报
        """
        self.results.append((decision["type"], actual))

    def precision_recall(self, decision_type):
        tp = sum(1 for dt, a in self.results
                 if dt == decision_type and a)
        fp = sum(1 for dt, a in self.results
                 if dt == decision_type and not a)
        fn = sum(1 for dt, a in self.results
                 if dt != decision_type and a)
        precision = tp / max(tp + fp, 1)
        recall = tp / max(tp + fn, 1)
        return precision, recall

    def suggest_adjustment(self, decision_type):
        p, r = self.precision_recall(decision_type)
        if p < 0.5 and r > 0.8:
            return "tighten"   # 误报太多 → 提高阈值
        elif p > 0.9 and r < 0.5:
            return "loosen"    # 漏报太多 → 降低阈值
        else:
            return "keep"      # 参数合理

这个反馈闭环不需要很复杂。最简单的实现:运维人员在处理告警时点一下「确报」或「误报」按钮。积累几百条反馈后,系统就能自动建议参数调整方向。

更高级的做法是用贝叶斯优化自动搜索最优参数组合,但对于 RFID 盘点场景,反馈闭环加人工确认已经够用了。过度自动化调参反而危险——参数漂移了没人发现。

八、收尾

从状态机到决策流水线,六层算法栈做的事情可以总结成一句话:把「在不在」变成「该怎么办」。

置信度评分量化了数据的可靠性。贝叶斯推断把「没读到」翻译成概率而不是硬判断。异常检测把问题分成三类分别处理。趋势预测把视线从当下拉到未来。热点分析让系统把注意力放在最需要关注的商品上。反馈闭环让参数随时间自我优化。

这些算法没有一个是高深的。没有深度学习,没有大模型,最复杂的数学也就是贝叶斯定理和指数平滑。但它们组合在一起,能把一个只会计数的盘点系统变成一个能做决策的盘点系统。

回到开头那个问题:仓库经理想知道的不是「有多少」,而是「该怎么办」。算法栈给他的答案是:这个货架两小时后需要补货(预测),那箱牛奶被放到了错误的区域(异常检测),这三个标签的数据不太靠谱需要人工核实(置信度),那个标签可能是坏的可以清理掉(幽灵检测)。

这才是盘点自动化该有的样子。不是自动数数,是自动想事。

继续阅读