TT Lab
开始
学习 学习路径 课程

打造好服务的计算机科学 — 用测量重新学习教科书概念

堵住浮点数在求和、比较、ID、方差和百分比中的漏洞

在 TT Lab 中继续学习

目标

查看 0.1 的实际值,测量同一个数列的和如何随相加的顺序变化,并确定用什么来代替 == 做比较。统计 64 位 ID 在 JavaScript 的 double 中冲突的数对,并写出把它们作为字符串发送的函数;用 Decimal 固定舍入策略,用 Welford 求大偏移量之上的方差,做出合计为 100 的百分比,最后把这一切汇总成一个仪表盘响应。

为什么重要

float 是用 53 位做近似记录的约定,而服务规则通常要求“完全相同”和“合计要对”。这两者冲突的地方不会抛出异常,所以会很晚才被发现,比如每次重跑都相差几韩元的合计、只在浏览器里出错的 ID、负方差。分清什么是表示的局限、什么是契约(舍入位置、比较容差、传输格式),就能看出该修哪里。所以评分器不只看你写下的数字,而是把你的函数放到不在材料里的输入上重新运行,与参考实现对照。

材料

位于 /opt/fixtures/svccs/float/ 之下。只读取,不要修改。每行一个值的 .txt 用 float(줄) 或 int(줄)(占位符为该行文本)来读取。

series.txt   금액 흐름 3,010줄(float). 상쇄되는 큰 이체가 끼어 있다
pairs.csv    a,b — 계산한 값과 기대한 값의 쌍(float 표기)
ids.txt      64비트 주문 ID(정수)
lines.csv    line_id,amount — 소수 셋째 자리까지 있는 청구 금액(문자열 그대로 쓸 것)
samples.txt  에포크 밀리초로 찍힌 응답 시각(float)
shares.csv   category,count — 결제 수단별 건수
records.csv  id,amount,latency_ms,category — 대시보드 원본
params.json  exact_values — 1단계에서 볼 소수 표기 목록

步骤

  1. 在 /root/svccs/float/exact.json 中写入 values(按 params.json 的 exact_values 的顺序,由 {"text": 표기, "exact": str(Decimal(float(표기))), "hex": float(표기).hex(), "is_exact": Decimal(float(표기)) == Decimal(표기)}(占位符均为小数的文本表示)组成的列表)、sum_0_1_0_2(0.1 + 0.2 的 float 值)、equals_0_3(0.1 + 0.2 == 0.3 的结果)。
  2. 在 /root/svccs/float/floatkit.py 中编写 sums(xs)——返回 {"forward": 앞에서부터, "reverse": 뒤에서부터, "ascending": 값이 작은 것부터(sorted), "fsum": math.fsum}(占位符依次为:从前往后、从后往前、从小到大(sorted))。前三个要用 for 循环中的 += 来相加(不是内置 sum())。用 series.txt 把这四个值,以及 n(行数)、distinct(四个值中互不相同的个数)、builtin_sum(内置的 sum(xs))写入 /root/svccs/float/sums.json。
  3. 在同一个文件里编写 close(a, b) = math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-9)。用 pairs.csv 把 pairs、equal_exact(a == b 的数对数)、equal_close(close 为真的数对数)、equal_rel_only(math.isclose(a, b, rel_tol=1e-9),即不带 abs_tol 时为真的数对数)写入 /root/svccs/float/close.json。
  4. 用 ids.txt 把 ids(个数)、unsafe_ids(绝对值大于 2^53 − 1 的数)、float_collision_pairs(float(id) 变得相同的数对数——每 n 个相同的值对应 n·(n−1)/2)、ids_in_collisions(属于这种分组的 ID 数)、largest_group(最大分组的大小)写入 /root/svccs/float/ids.json。在同一个文件里编写 to_wire(ids),让它返回把 ID 全部以十进制字符串装入的 JSON 数组文本,并把结果写入 /root/svccs/float/ids_wire.json。
  5. 在同一个文件里编写 half_up(text, places)——把十进制字符串 text 以 ROUND_HALF_UP(并列时取离 0 较远的一侧)舍入到小数 places 位之后的字符串(Decimal(text).quantize(...),不经过 float)。用 lines.csv 把 lines、sum_of_rounded_lines(逐行 half_up(…, 2) 之后的值之和)、rounded_total(把 amount 全部用 Decimal 相加后再 half_up(…, 2))、difference(前者 − 后者)以字符串,lines_builtin_round_differs(round(float(amount), 2) 与 float(half_up(amount, 2)) 不同的行数)以整数写入 /root/svccs/float/rounding.json。
  6. 在同一个文件里编写 welford(xs)——返回 {"n", "mean", "var"}(总体方差,除以 n),用 Welford 的一次遍历更新来计算。用 samples.txt 把 n、mean(真实均值)、var_naive(用循环累加的 Σx²/n − (Σx/n)²)、var_welford、var_exact(用 fractions.Fraction 计算的总体方差再转成 float)写入 /root/svccs/float/variance.json。
  7. 在同一个文件里编写 largest_remainder(counts, total)——给每个份额 c·total/Σc 取整数部分,再把剩下的名额按小数部分从大到小(相同时靠前的项目优先)逐个加 1,得到的整数列表(当 Σc 为 0 时全部为 0)。用 shares.csv 把 categories、naive(各自 round(c·100/Σc))、naive_sum、largest_remainder(total=100)、largest_remainder_sum 写入 /root/svccs/float/percent.json。
  8. 在同一个文件里编写 summarize(records),并用 records.csv 写出 /root/svccs/float/dashboard.json。records 的每一行 = records.csv 的一行 {"id", "amount", "latency_ms", "category"}(值可能是字符串,也可能是数字)。要返回:ids(每个 id 的十进制字符串,按输入顺序)、total_amount(逐行 half_up(amount, 2) 之和的字符串)、latency_mean、latency_var(welford)、categories(互不相同的 category 排序后的列表)、share_pct(对按 categories 顺序的条数做 largest_remainder(…, 100))。

参考

查看 0.1 的实际值

对 params.json 的 exact_values 中的每一项,把实际存储的值、十六进制表示、是否精确,写入 /root/svccs/float/exact.json 的 values,并一并写入 sum_0_1_0_2、equals_0_3。

像 Decimal(0.1) 这样把 float 转成 Decimal,二进制值就会被无损地转成十进制。再看它是否与用字符串构造的 Decimal("0.1") 相等,就能知道这个写法在 float 中是否精确。只有分母是 2 的幂的小数才是精确的。

相加的顺序会改变合计

在 /root/svccs/float/floatkit.py 中编写 sums(xs),并用 series.txt 把 forward、reverse、ascending、fsum、n、distinct、builtin_sum 写入 /root/svccs/float/sums.json。评分器会用变形数列调用 sums。

前三个和用 for 循环的 += 来加。Python 3.12 的内置 sum() 对 float 求和使用了更精确的算法,可能与循环得到的值不同。差别的原因,是在大值 ±10^15 旁边,小额金额的低位被截掉了。

不用 == 该用什么比较

在 floatkit.py 中编写 close(a, b),并用 pairs.csv 把 pairs、equal_exact、equal_close、equal_rel_only 写入 /root/svccs/float/close.json。评分器会用混有接近 0 的数对的变形数对调用 close。

math.isclose 的默认 abs_tol 是 0.0,所以与 0 比较时,非零的值始终为假。如果是余额应为 0 的检查,就要同时给出 abs_tol。equal_rel_only 是故意去掉 abs_tol 来统计的。

64 位 ID 在浏览器里发生重叠

用 ids.txt 把 ids、unsafe_ids、float_collision_pairs、ids_in_collisions、largest_group 写入 /root/svccs/float/ids.json,并在 floatkit.py 中编写 to_wire(ids),把它的结果写入 /root/svccs/float/ids_wire.json。评分器会像 JavaScript 那样把 to_wire 的结果按 double 读取来检查。

Python 把整数读成 int,所以看上去毫无问题。必须像接收方那样用 float(id) 转换,再把相同的值归为一组,冲突才会显现。在 2^60 附近,相邻 double 的间隔是 256。传输不要用 JSON 数字,而要用字符串。

用 Decimal 固定舍入策略

在 floatkit.py 中编写 half_up(text, places),并用 lines.csv 把 lines、sum_of_rounded_lines、rounded_total、difference、lines_builtin_round_differs 写入 /root/svccs/float/rounding.json。评分器会用变形金额和多种位数调用 half_up。

Decimal("2.675") 恰好是 2.675,而 Decimal(2.675) 是 2.67499…。要直接从字符串构造再 quantize。round() 在并列时舍入到偶数一侧,而无法用 float 表示的值,甚至连并列都算不上。负数的 half-up 是离 0 较远的一侧。

大偏移量之上的方差

在 floatkit.py 中编写 welford(xs),并用 samples.txt 把 n、mean、var_naive、var_welford、var_exact 写入 /root/svccs/float/variance.json。评分器会用不同偏移量的变形样本调用 welford。

Σx²/n 与 (Σx/n)² 是 1.79e12 的平方附近的两个大数,相减时有效数字几乎全部消失。Welford 一点点移动均值,只累加偏差,所以避开了这种抵消。var_exact 是把每个 float 转成 Fraction 来计算的。

合计为 100 的百分比

在 floatkit.py 中编写 largest_remainder(counts, total),并用 shares.csv 把 categories、naive、naive_sum、largest_remainder、largest_remainder_sum 写入 /root/svccs/float/percent.json。评分器会用混有并列的变形条数调用 largest_remainder。

如果用 float 计算份额,相同的余数会有细微差别,使并列的判定发生动摇。用 fractions.Fraction 来计算份额就是精确的。并列时靠前的项目优先。

汇总成一个仪表盘响应

在 floatkit.py 中编写 summarize(records),并用 records.csv 写出 /root/svccs/float/dashboard.json。评分器会用变形记录调用 summarize,并像 JavaScript 那样把结果按 double 读取,看 ID 能否保留下来。

直接使用前面步骤的 half_up、welford、largest_remainder。ID 是字符串,金额合计是 Decimal 的字符串。如果用教科书公式求延迟,可能会得到负数。