趋势判断别信列表顺序:一次「进步显示下降」的排序 Bug

举报
岚枫 发表于 2026/10/27 08:38:40 2026/10/27
【摘要】 同一个学生,班级概览说他「下降」,个人详情说他「上升」。两处用的是同一套趋势算法,差别只在取数顺序:一个 DESC,一个 ASC。本文用最小复现拆开这个 Bug,给出修复方式,以及三条能提前拦住它的检查。

同一个学生,班级概览里显示「下降」,点进个人详情却显示「上升」。两处用的是同一段趋势判断代码,分数也一样,结论却相反。算法对输入顺序有一个没写出来的假设,而两处的取数顺序只有一个满足它。

最小复现

把问题缩到最小:四个分数,按时间由早到晚是 40、50、90、100,明显在进步。取数时按 submitted_at DESC,拿到的列表是 [100, 90, 50, 40]。趋势代码把列表切成前后两半,比较两段的总分:

def trend(scores):
    mid = len(scores) // 2
    first, second = scores[:mid], scores[mid:]
    if sum(second) < sum(first) - 2:
        return "下降"
    if sum(second) > sum(first) + 2:
        return "上升"
    return "平稳"

print(trend([100, 90, 50, 40]))   # 下降  ← 实际在进步
print(trend([40, 50, 90, 100]))   # 上升  ← 同一批成绩,正序

同一批数据,换个顺序就得到相反结论。代码把 first 当成「早期」、second 当成「近期」,倒序列表让这个假设落空,前后半段被整体互换。

错法依赖条数,不是一眼能看穿

判定条件是两个半段总分之差超过 2,而不是首尾成绩之差。这个细节决定了 Bug 什么时候显形:

  • 偶数条:倒序后前后半段正好互换,单调序列在差值超过阈值时方向翻转;
  • 奇数条:后半段比前半段多一条。比如倒序的 [100, 90, 80, 70, 60](时间正序是 60 到 100,在进步),切出来是 [100,90] 对 [80,70,60],两段和分别为 190 和 210,反而判成「上升」;[90, 50, 40] 则判成「平稳」。

所以同一个 Bug,两段总分差超过 2 的四条成绩班级会全错,五条成绩的班级可能看不出来。阈值 2 又是绝对差值:在有明显持续趋势的数据上,条数越多两段总分差通常越大,「平稳」几乎不会出现,判错时也很少被容差兜住。

为什么个人页是对的

个人详情走的是另一条查询:ORDER BY submitted_at(升序),拿到的就是 [40, 50, 90, 100],同一套算法算出「上升」。两个入口共用算法、不共用取数顺序,于是同一个学生在两个页面上有两个趋势。

这类问题麻烦在于:单看任何一个页面,都像是「数据就是这样」;只有把两个页面对起来,才会发现结论互斥。

同一份代码里往往不止一处

出问题的项目里,班级概览和班级诊断报告各自写了一遍「取提交 → 算趋势」,两处都用了倒序查询,所以两处都反了。修的时候如果只改用户最先看到的那个接口,诊断报告里的趋势仍然错,只是暂时没人对账。

所以修复前先搜两样东西:同一个 ORDER BY ... DESC,以及同一段「前半段 vs 后半段」的比较。它们经常成对出现。

修复:在边界处把顺序定死

最稳的改法不是重写趋势算法,而是让进入算法的数据只有一种顺序。三个层次,按侵入性从低到高:

  1. 取数时显式升序:趋势计算单独发一条 ORDER BY submitted_at ASC, id ASC 的查询,不和展示列表共用结果。加 id 是为了同一秒提交时次序稳定。
  2. 在算法入口排序:points = sorted(points, key=lambda p: (p[0], p[1])),把顺序责任收进函数内部。
  3. 改比较口径:不切前后半段,直接比较最早一次和最近一次。
def trend_v2(points):          # points: [(submitted_at, id, score), ...]
    points = sorted(points, key=lambda p: (p[0], p[1]))
    if len(points) < 2:
        return "样本不足"
    delta = points[-1][2] - points[0][2]
    if delta <= -3:
        return "下降"
    if delta >= 3:
        return "上升"
    return "平稳"

需要说清楚:trend_v2 换了判据,不是 trend 的无损替换。旧版比较半段总分,新版只看首末差值;阈值写成 3 只是整数下的等价写法(旧版 >2 等价于 >=3),真正变化的是比较对象。同一批历史数据可能因此换一个标签,上线前要和产品确认口径,必要时另存新旧两列做对照。

新版对输入顺序不敏感,前提是它按时间戳排序。如果只拿到一个位置列表、没有时间戳,倒序输入仍会让 delta 反号,问题原样复现。它另外两个代价是:单个异常的首末成绩就能反转方向;样本不足 成了新状态,前端标签要同步。单条分数大于 2 时旧版会直接判「上升」,新版统一返回「样本不足」。

三个能提前拦住的检查

  1. 顺序断言:如果沿用旧版「由调用方保证顺序」的写法,在函数入口写 assert points == sorted(points, key=...);更稳的是直接在取数边界断言查询返回升序。新版在函数内部排序,不需要这条。
  2. 双入口对账:凡是同一指标有两个入口(概览/详情、概览/报告),加一条断言两边结论一致。这次的结论互斥正属于这类对账能抓住的问题。
  3. 构造单调序列:单测要通过班级概览入口构造已知进步/退步的学生,而不是只测算法函数;或断言同一批数据分别以 ASC、DESC 输入时 v2 给出同向结论。只测「随机分数」很容易碰巧通过。

小结

趋势算法的输入是「按时间排列的序列」。这个前提要么由查询保证,要么由函数自己保证,不能靠调用方「碰巧传对了」。列表页为了展示用倒序没问题,但计算趋势时要把顺序重新定死;两处共用算法时,顺序也必须共用同一个约定。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。