Python 自检香港水塘 183 期数据:3294 个格子里有 1 个错的,靠同一行的两列找到了它

在这里插入图片描述

水务署每双周发布一次香港 18 个水塘的存水量,一期一个独立 CSV,从 2019 年 1 月到 2026 年 9 月共 183 期。这张表只有三列:水塘名、存水量(百万立方米)、蓄水百分比——但正是这三列,藏着一个不用任何外部基准就能做完的数据自检实验。

我抓了全部 183 期、3,294 个数据格,用「同一行里两个冗余列互相校验」的方法,精确定位出 1 个错值、1 条类型污染、1 期表头异常、2 期缺失。整个过程没有对照源,全靠表自己。

一、这张表为什么能自检

关键在于:存水量和蓄水百分比是冗余的。只要知道水塘容量,两者可以互相推出:

def capacity_audit(dates: list[str]) -> dict:
    """用冗余列反推容量:存水量 ÷ (百分比/100)。容量应当恒定。"""
    cap: dict[str, list[tuple[str, float]]] = {}
    for d in dates:
        for r in load(d)[1:]:
            if len(r) < 3:
                continue
            try:
                v = float(r[1].strip())
                p = float(r[2].strip().rstrip("%"))
            except ValueError:
                continue
            if p <= 0.5 or v <= 0:
                continue
            cap.setdefault(r[0].strip(), []).append((d, v / (p / 100)))
    rows = []
    for name, seq in cap.items():
        vals = [c for _, c in seq]
        med = st.median(vals)
        rows.append({"name": name, "median": med,
                     "lo": min(vals), "hi": max(vals),
                     "spread_pct": (max(vals) - min(vals)) / med * 100,
                     "n": len(vals)})
    rows.sort(key=lambda r: -r["spread_pct"])
    return rows

水塘的物理容量不可能一期一变。所以对每个水塘,把 183 期的「存水量 ÷ 百分比」摆在一起看——它应该是一条平线。跳出来的那一点,就说明这一行里至少有一格不对。

这个「反推恒定量」的思路建议收藏——它的妙处是不需要任何对照源:两列冗余本身就够。

二、先逐格体检:类型污染藏在第一期

183 期 × 18 个水塘 = 3,294 格。逐格做类型检查(存水量列必须是纯数字,百分比列必须是 xx% 格式),结果:

  • 3,293 格干净,只有 1 格类型不对;
  • 藏在第一期(2019-01-16):Aberdeen Lower Reservoir 的存水量列写的是 25.90%——一个带百分号的字符串混进了数字列,而它的蓄水百分比列是 53.29%

float() 直接转这一列会在第一期就抛异常;如果解析时悄悄跳过,这条污染又会静默消失。先逐格体检,再谈口径——顺序反了,后面的统计全是在漏数据的基础上算的。

这段逐格体检的写法值得收藏——两类检查各一个正则,三千多个格子一遍扫完。

三、反推容量:17 个水塘是一条平线,1 个跳出来

对 18 个水塘分别做「反推容量」的离散度统计:

结果水塘数
极差 < 2%(基本不动)16
极差 2%–5%1(Pok Fu Lam,2.6%)
极差 > 5%1(Lower Shing Mun,126.4%

在这里插入图片描述

17 个水塘的容量在 183 期里几乎是一条平线——比如 High Island Reservoir 反推出的容量稳定在 281.124,离散度 0.0%。这同时说明官方的存水量和百分比两列彼此是自洽的:随便哪一期,用一列都能精确复原另一列。

def locate_outlier(dates: list[str], name: str) -> list[dict]:
    """在指定水塘上,逐期标出偏离中位容量超过 5% 的期。"""
    seq = []
    for d in dates:
        for r in load(d)[1:]:
            if len(r) >= 3 and r[0].strip() == name:
                try:
                    v = float(r[1].strip())
                    p = float(r[2].strip().rstrip("%"))
                except ValueError:
                    continue
                if p > 0.5 and v > 0:
                    seq.append({"date": d, "storage": v, "pct": p,
                                "cap": v / (p / 100)})
    if not seq:
        return []
    med = st.median([s["cap"] for s in seq])
    return [s for s in seq if abs(s["cap"] - med) / med > 0.05]

唯一的例外是 Lower Shing Mun Reservoir,把它的 183 期拉出来看:

在这里插入图片描述

只有一期不对:2024-09-16 那期,存水量 1.792、蓄水 18.42%,反推容量 9.729——是其余 182 期中位值 4.299 的 2.26 倍。下一期立刻恢复到 4.3。

4.299 和 9.729 哪个是真的?把同期其他水塘的总存水量摆出来对比,再对照该水塘的官方公开容量,答案很清楚:9.729 是假的。大概率是那一期某个数字填错了——至于是存水量写错还是百分比写错,仅凭这张表分不出来(这正是冗余列的边界:它能告诉你哪一行有错,但不能告诉你哪一格错)。但定位到「期 + 行」已经够了:这一期这个水塘的数字不能用。

四、发布节奏:名义双周,缺了 2 期

183 期覆盖 2,800 天,相邻期间隔分布:15 天 120 次、16 天 53 次(这是正常的双周节奏,2 月短所以偶尔 13/14 天),但还有 28 天 1 次、31 天 1 次——多出来的天数对应缺失的期

  • 2022-02-01 → 2022-03-01:缺 1 期(本应有 20220216);
  • 2025-03-01 → 2025-04-01:缺 1 期(本应有 20250316)。

在这里插入图片描述

做时间序列的人要注意:这两段间隔会被自动当成「双月没有变化」,而真相是那一期的数据从来没发过。缺期不补,趋势图上就是一个不存在的平台。

这段间隔是怎么算的:

def cadence_audit(dates: list[str]) -> dict:
    """发布节奏:名义双周,实际间隔分布,以及缺期。"""
    dd = [dt.date(int(x[:4]), int(x[4:6]), int(x[6:])) for x in dates]
    gaps = [(dd[i + 1] - dd[i]).days for i in range(len(dd) - 1)]
    missing = []
    for i, g in enumerate(gaps):
        if g > 20:                       # 名义 15/16 天,超过 20 即缺期
            missing.append({"from": dates[i], "to": dates[i + 1],
                            "days": g,
                            "missing_issues": max(round(g / 15) - 1, 1)})
    return {"n": len(dates), "gaps": gaps, "missing": missing,
            "span_days": (dd[-1] - dd[0]).days}

顺带一提,间隔分布本身还能反过来校验「发布日历」:15 天与 16 天交替出现,是双周节奏撞上月份天数差异的正常表现;而 13 天和 14 天的 7 次,全部集中在 2 月→3 月的交界——月份长度会伪装成发布异常,看间隔分布时要把日历因素先剥掉。

五、双快照与总存水量

2026-09-16 这期(最新一期)抓了两次,745 字节,SHA-1 指纹完全一致——双周频的数据在两期内不会变,这符合它的发布契约。总存水量序列:最新 1,017.5 百万立方米,七年里最低 635.8(2022 年 5 月,大旱)、最高 1,072.8(2025 年 10 月),雨旱季的锯齿清晰可见。

另外两个结构层的小发现:2026-08-16 期的表头行尾多出 3 个空列(;,, 尾巴,用 csv 读会多出三列空字段);183 期里 18 个水塘的名字一个都没变过——没有新增、没有停用、没有改名。

六、结论

问题实测结果
3,294 个格子干净吗1 格类型污染(25.90% 混进数字列)
两列冗余能互检吗能:17/18 个水塘反推容量恒定
抓到几个错值1 个:2024-09-16 Lower Shing Mun,2.26×
发布节奏名义双周,缺 2 期(20220216、20250316)

三条:

  1. 表里有两列互相冗余,就是白送的自检器——不需要对照源,数值 ÷ 百分比 一除,异常自己跳出来。前提是其中一列在物理上应当恒定(容量、单价、汇率锚点都算)。
  2. 类型检查放在口径检查之前。一个 % 混进数字列,轻则少一行数据,重则让后面的求和整体翻车。
  3. 缺的期不会自己出现在图上。时间序列要先算相邻间隔的分布,间隔突然翻倍的地方就是数据从未存在过的地方。

这套「冗余列互检 + 逐格类型 + 间隔分布」的三步自检建议收藏——任何「数量 + 百分比」结构的周期性数据(库存、占比、水位、磁盘使用率)都能直接套。

七、边界与声明

  • 2024-09-16 那一期错在哪一格(存水量还是百分比),仅凭该表无法判定,本文只做「定位到期与行」,不做成因推断;该期数值是否已被官方修正,以水务署最新发布为准;
  • 数据为香港水务署公开数据,2026-09-20 当天实抓全部 183 期(2019-01-16 → 2026-09-16)并两次快照比对确认一致,脚本可原样复跑;
  • 本文只讨论数据质量,不构成任何用水或投资建议。

参考链接

  1. https://www.wsd.gov.hk/datagovhk/en-data/storage_position_of_individual_impounding_reservoirs_20260916_en.csv
  2. https://data.gov.hk/en-data/dataset/hk-wsd-wsd2-storage-position-individual-reservoir-bi-weekly
  3. https://www.wsd.gov.hk/en/core-businesses/water-resources/index.html

原创声明:本文全部数据来自 2026-09-20 当天对香港水务署公开数据的实抓(183 期全量 + 双快照),脚本可原样复跑。觉得有用点个关注不迷路,下一篇拆一个新数据源。

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Patrick在香港

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值