目录
- 一、七份文件,最大的比最小的大 25 倍
- 二、文件名里的年份,不是数据里的年份
- 三、字段会改名:Court → Court/Estate,还多了个 Wing
- 四、楼层是字符串,单位号里还有字母
- 五、把七份合在一起算均价,会算出一个不存在的数
- 六、可以直接抄的做法
- 参考链接
中国香港房委会把资助出售房屋的成交记录按「每期计划」发成一份文件,零鉴权,JSON 格式,中英文各一版。我把能抓到的七份全下载下来(共约 9.7MB、18,822 条成交),只想回答一个问题:这批数据的年份怎么对。结果是:
- 没有一份文件的「文件名年份」等于它里面的成交年份——七份里有5 份完全不重合;代号 g24 的那份文件名写着 2024,里面的成交全部发生在 2025 和 2026;
- 字段在改名:2018 年那份用
Court,之后六份全改成Court/Estate,还多出一个Wing字段。11 个字段里只有 8 个是七份共有的; - 同一条记录里楼层是字符串、面积是浮点、价格是整数——楼层排序会得到
1, 10, 11, 12, 13这种顺序,而单位号里还混着04A。
一、七份文件,最大的比最小的大 25 倍
先看清这批文件的规模:
| 代号 | 计划 | 成交条数 |
|---|---|---|
| 018 | 居屋 2018 | 4,431 |
| g20 | 绿置居 2020 | 3,294 |
| g22 | 绿置居 2022 | 4,586 |
| g23 | 绿置居 2023 | 3,002 |
| g24 | 绿置居 2024 | 3,069 |
| e22 | 2022(另一批) | 256 |
| e23 | 2023(另一批) | 184 |
最后两份只有 184 和 256 条,跟前面的几千条不在一个量级,最大与最小相差 24.9 倍。这两份对应的是规模小得多的批次,把它们和主期计划当成同一个均匀的时间序列做年度趋势,等于让 184 条和 4,586 条拥有同样的话语权——趋势线的形状主要由样本量决定,而不是由市场决定。所以按「期」分析时,要么显式加权,要么把小批次单独标注出来,不要和主期混在一条线上。
二、文件名里的年份,不是数据里的年份
这是今天最值得记住的一条。每份文件里都有一个签约日期字段,我按它统计了实际年份:
# 文件名年份 vs 实际签约年份importjson,pathlib,collections RAW=pathlib.Path("原始返回/ssfs")SERIES={"018":2018,"g20":2020,"g22":2022,"g23":2023,"g24":2024,"e22":2022,"e23":2023}DATE="Date of Agreement for Sale and Purchase (ASP)"forcode,file_yearinSERIES.items():recs=json.loads((RAW/f"{code}_en_r0.json").read_bytes().decode("utf-8-sig"))["records"]years=collections.Counter(r[DATE].split("/")[-1]forrinrecs)hit=str(file_year)inyearsprint(code,file_year,dict(years.most_common()),"重合:",hit)# 018 2018 {'2019': 4431} 重合: False# g24 2024 {'2025': 2783, '2026': 286} 重合: False# e22 2022 {'2022': 151, '2023': 105} 重合: True七份里五份完全不重合:2018 年那份的成交全部发生在2019(4,431 条),2024 年那份全部发生在2025(2,783)和 2026(286),2023 年那份全部发生在 2024。只有 e22、e23 两份有部分重合。
原因不复杂:文件名里的年份是「第几期计划」,成交签约发生在计划之后——先抽签选楼、再签买卖协议,跨年是常态。但如果你按文件名给数据打年份标签(这是最顺手的做法),整条时间轴会平移一到两年,跟任何其他年度数据(楼价指数、人口、利率)对不上,而且不会有任何报错:数字照样算得出来,图表照样画得出来,只是整体错了一格。
正确做法是只用日期字段派生年份,文件名只当版本号。
三、字段会改名:Court → Court/Estate,还多了个 Wing
把七份文件的字段取并集和交集,会看到漂移:
keysets={}forcodeinSERIES:recs=json.loads((RAW/f"{code}_en_r0.json").read_bytes().decode("utf-8-sig"))["records"]keysets[code]=set().union(*[set(r)forrinrecs])union=set().union(*keysets.values())inter=set.intersection(*keysets.values())print(len(union),len(inter),sorted(union-inter))forkinsorted(union-inter):print(k,"有于",[cforcinSERIESifkinkeysets[c]],"缺于",[cforcinSERIESifknotinkeysets[c]])# 11 8 ['Court', 'Court/Estate', 'Wing']# Court 有于 ['018'] 缺于 ['g20', 'g22', 'g23', 'g24', 'e22', 'e23']# Court/Estate 有于 ['g20', ...] 缺于 ['018']# Wing 有于 ['g20', ...] 缺于 ['018']2018 年那份里,屋苑字段叫Court;从 2020 年起改叫Court/Estate,同时新增了Wing(翼)字段。所以:
- 按
Court/Estate取数,2018 年那份会取到 4,431 个空值——不报错,只是那一整年的屋苑全是空; - 按
Court取数,另外六份全空。
这类漂移没有任何提示。处理办法是建一张别名表(Court→Court/Estate),并在读取时断言关键字段的空值率——空值率突然 100% 就是改名了。
四、楼层是字符串,单位号里还有字母
同一条记录里,字段类型并不统一:
recs=json.loads((RAW/"g24_en_r0.json").read_bytes().decode("utf-8-sig"))["records"]types={k:type(v).__name__fork,vinrecs[0].items()}print(types)# Scheme Type: str Court/Estate: str Block: str Wing: str# Floor: str Unit: str# Saleable Area of Flats (sq. m.): float Transaction Price: intfloors=sorted({r["Floor"]forrinrecs})print(floors[:5])# ['1', '10', '11', '12', '13'] ← 字典序,不是楼层顺序units=[r["Unit"]forrinrecs]print(sum(1foruinunitsifu.isdigit()),"/",len(units))# 3065 / 3069 ← 有 4 条不是纯数字(比如 '04A')两个后果:
- 楼层是字符串,直接排序得到
1, 10, 11, 12, 13…——想找「最高几层」或者按楼层分组,必须转成整数再排; - 单位号里混着
04A这种值,3,069 条里 4 条不是纯数字。所以「单位号是数字」这个假设不成立,int()会在这 4 条上抛异常——正好少到不容易被发现。
价格和面积反而是干净的:面积是浮点(35.4),价格是整数(715500)。所以不要因为「看起来都是数字」就统一处理。
五、把七份合在一起算均价,会算出一个不存在的数
最后看一眼口径:018(居屋 2018)均价267.6 万,绿置居各期在119.2 万到 224.2 万之间,七份合并后是210.7 万。
这个 210.7 万没有意义——它是两种不同性质的计划(居屋和绿置居的定价机制、单位面积都不同)加权出来的。均面积也一样,从 20.3 平方米到 38.6 平方米。合并统计之前,先确认「合并」这个动作在业务上是否成立。
好消息是中文版和英文版完全对齐:抽查两份,条数 4,431/4,431、3,069/3,069,逐条价格全部一致。所以跨语言对齐这一关,这份数据是过了的。
六、可以直接抄的做法
importjsonimportpathlib ALIASES={"Court":"Court/Estate"}# 旧名 → 新名INT_FIELDS=("Floor",)# 需要转整数的数字型字符串DATE_FIELD="Date of Agreement for Sale and Purchase (ASP)"defload_series(raw:pathlib.Path,code:str)->list:"""读一期刊的成交:别名归一 + 年份派生 + 类型修正。"""recs=json.loads((raw/f"{code}_en_r0.json").read_bytes().decode("utf-8-sig"))["records"]out=[]forrinrecs:rec={ALIASES.get(k,k):vfork,vinr.items()}d,m,y=rec[DATE_FIELD].split("/")# 明确按 D/M/YYYY 解析rec["date"]=f"{y}-{m}-{d}"rec["year"]=int(y)# 年份只从日期派生forfinINT_FIELDS:s=str(rec.get(f,"")).strip()rec[f"{f}_int"]=int(s)ifs.isdigit()elseNoneout.append(rec)# 关键字段全空 = 疑似改名,别让它静默通过missing=sum(1forxinoutifnotx.get("Court/Estate"))assertmissing<len(out),f"{code}: 屋苑字段全空,检查是否改名"returnout四个要点收尾:年份只从日期字段派生,文件名当版本号,别让「文件名看起来像年份」骗过去;字段改名要靠空值率发现,建一张别名表兜底,并对关键字段断言「不能全空」;类型不要靠猜,数字型字符串显式转换,遇到非数字值留None而不是硬转;合并之前先问一句「合并在业务上成立吗」,不同定价机制的计划不能加权平均,样本量差 25 倍的批次也不能等权处理。
今天 11 次请求(七份英文 + 两份中文 + 两份第二轮快照)全部真实抓取,原始文件已落盘,脚本可以整篇复跑。
参考链接
- https://data.housingauthority.gov.hk/dataset/ssfs/ssfs-sale-transactions-g24_en.json
- https://data.gov.hk/en-data/api/3/action/package_show?id=hk-housing-gsh2024-ssfs-sale-transactions-g24
- https://data.housingauthority.gov.hk/
原创声明:本文所有数据于 2026 年 9 月 27 日实测抓取自中国香港房屋委员会公开数据集与 data.gov.hk,抓取与校验脚本随文附上,欢迎复现。
文中「文件名年份 ≠ 成交年份」是这一系列文件的实际形态描述,不构成对发布方的评价;做年度对比时请以记录内的签约日期为准。
如果这篇帮你避开了一次「整条时间轴平移一年」的返工,点个收藏,下一篇继续拆系列型数据的坑。