趋势判断别信列表顺序:一次「进步显示下降」的排序 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 后半段」的比较。它们经常成对出现。
修复:在边界处把顺序定死
最稳的改法不是重写趋势算法,而是让进入算法的数据只有一种顺序。三个层次,按侵入性从低到高:
- 取数时显式升序:趋势计算单独发一条
ORDER BY submitted_at ASC, id ASC的查询,不和展示列表共用结果。加id是为了同一秒提交时次序稳定。 - 在算法入口排序:
points = sorted(points, key=lambda p: (p[0], p[1])),把顺序责任收进函数内部。 - 改比较口径:不切前后半段,直接比较最早一次和最近一次。
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 时旧版会直接判「上升」,新版统一返回「样本不足」。
三个能提前拦住的检查
- 顺序断言:如果沿用旧版「由调用方保证顺序」的写法,在函数入口写
assert points == sorted(points, key=...);更稳的是直接在取数边界断言查询返回升序。新版在函数内部排序,不需要这条。 - 双入口对账:凡是同一指标有两个入口(概览/详情、概览/报告),加一条断言两边结论一致。这次的结论互斥正属于这类对账能抓住的问题。
- 构造单调序列:单测要通过班级概览入口构造已知进步/退步的学生,而不是只测算法函数;或断言同一批数据分别以 ASC、DESC 输入时 v2 给出同向结论。只测「随机分数」很容易碰巧通过。
小结
趋势算法的输入是「按时间排列的序列」。这个前提要么由查询保证,要么由函数自己保证,不能靠调用方「碰巧传对了」。列表页为了展示用倒序没问题,但计算趋势时要把顺序重新定死;两处共用算法时,顺序也必须共用同一个约定。
- 点赞
- 收藏
- 关注作者
评论(0)