整洁数据(Tidy data)#
Tidy data 是把数据整理成规则形式的一套做法,让分析和可视化都更容易。它不是某种文件格式,而是一组关于“行和列该表示什么”的约定,一共三条:
每列是一个变量:数据集里的每一列代表一个变量,比如“气温”“人口”,同一列里不该混进两种含义。
每行是一个观测:每一行代表一次观测,比如“某地某时的一次测量”。
每格一个值:每个单元格只放一个值,不能放进多个值。
违反这三条的数据并不是错,只是每做一步分析都需要先写许多转换。下面两张表是同一份数据的两种摆法,对比一下就能看出差别在哪。
不整洁的数据示例
Country |
1999_cases |
1999_population |
2000_cases |
2000_population |
|---|---|---|---|---|
Afghanistan |
745 |
19987071 |
2666 |
20595360 |
Brazil |
37737 |
172006362 |
80488 |
174504898 |
China |
212258 |
1272915272 |
213766 |
1280428583 |
这份数据把每年的病例和人口拆成不同的列,年份这个变量跑到了列名里。想算“逐年变化”得先做一次 unpivot,每个分析动作都要重复这一步。
Tidy 数据示例
Country |
Year |
Cases |
Population |
|---|---|---|---|
Afghanistan |
1999 |
745 |
19987071 |
Afghanistan |
2000 |
2666 |
20595360 |
Brazil |
1999 |
37737 |
172006362 |
Brazil |
2000 |
80488 |
174504898 |
China |
1999 |
212258 |
1272915272 |
China |
2000 |
213766 |
1280428583 |
这样每列是一个变量、每行是一个观测、每格只有一个值,筛选、分组、画图都能直接写。
Tidy 数据的好处
好处理:tidy 数据可以直接交给现成的工具(R 的 tidyverse、Python 的 pandas 等),不必为每种数据结构单独写代码。
一致:结构统一,出错的机会少。
可复用:整理好的数据能搬到别的项目和研究里用。
import polars as pl
from polars import selectors as cs
读取文件#
who.csv 按年、国家、年龄、性别、诊断方式记录了结核(TB)病例。这份数据的流行病学信息很全,但按现在这个形状很难直接使用:一年的数据散在几十列里,想算“某国逐年的趋势”需要先做一次大转换。
这种“宽”不是随意造成的:原始表格为了让每行容纳一个国家的全部年份,把年份、诊断方式、性别、年龄段都编码进了列名。要分析,就得先把它还原成“一列一个变量”的长表。
df = pl.read_csv('data/who.csv')
print(df.columns)
df.sample(5)
['country', 'iso2', 'iso3', 'year', 'new_sp_m014', 'new_sp_m1524', 'new_sp_m2534', 'new_sp_m3544', 'new_sp_m4554', 'new_sp_m5564', 'new_sp_m65', 'new_sp_f014', 'new_sp_f1524', 'new_sp_f2534', 'new_sp_f3544', 'new_sp_f4554', 'new_sp_f5564', 'new_sp_f65', 'new_sn_m014', 'new_sn_m1524', 'new_sn_m2534', 'new_sn_m3544', 'new_sn_m4554', 'new_sn_m5564', 'new_sn_m65', 'new_sn_f014', 'new_sn_f1524', 'new_sn_f2534', 'new_sn_f3544', 'new_sn_f4554', 'new_sn_f5564', 'new_sn_f65', 'new_ep_m014', 'new_ep_m1524', 'new_ep_m2534', 'new_ep_m3544', 'new_ep_m4554', 'new_ep_m5564', 'new_ep_m65', 'new_ep_f014', 'new_ep_f1524', 'new_ep_f2534', 'new_ep_f3544', 'new_ep_f4554', 'new_ep_f5564', 'new_ep_f65', 'newrel_m014', 'newrel_m1524', 'newrel_m2534', 'newrel_m3544', 'newrel_m4554', 'newrel_m5564', 'newrel_m65', 'newrel_f014', 'newrel_f1524', 'newrel_f2534', 'newrel_f3544', 'newrel_f4554', 'newrel_f5564', 'newrel_f65']
| country | iso2 | iso3 | year | new_sp_m014 | new_sp_m1524 | new_sp_m2534 | new_sp_m3544 | new_sp_m4554 | new_sp_m5564 | new_sp_m65 | new_sp_f014 | new_sp_f1524 | new_sp_f2534 | new_sp_f3544 | new_sp_f4554 | new_sp_f5564 | new_sp_f65 | new_sn_m014 | new_sn_m1524 | new_sn_m2534 | new_sn_m3544 | new_sn_m4554 | new_sn_m5564 | new_sn_m65 | new_sn_f014 | new_sn_f1524 | new_sn_f2534 | new_sn_f3544 | new_sn_f4554 | new_sn_f5564 | new_sn_f65 | new_ep_m014 | new_ep_m1524 | new_ep_m2534 | new_ep_m3544 | new_ep_m4554 | new_ep_m5564 | new_ep_m65 | new_ep_f014 | new_ep_f1524 | new_ep_f2534 | new_ep_f3544 | new_ep_f4554 | new_ep_f5564 | new_ep_f65 | newrel_m014 | newrel_m1524 | newrel_m2534 | newrel_m3544 | newrel_m4554 | newrel_m5564 | newrel_m65 | newrel_f014 | newrel_f1524 | newrel_f2534 | newrel_f3544 | newrel_f4554 | newrel_f5564 | newrel_f65 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| str | str | str | i64 | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str | str |
| "Turks and Caicos Islands" | "TC" | "TCA" | 1998 | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" |
| "Palau" | "PW" | "PLW" | 2004 | "NA" | "NA" | "NA" | "NA" | "1" | "2" | "NA" | "NA" | "NA" | "NA" | "1" | "1" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" |
| "Chile" | "CL" | "CHL" | 1982 | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" |
| "Saint Kitts and Nevis" | "KN" | "KNA" | 1981 | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" |
| "China, Hong Kong SAR" | "HK" | "HKG" | 1994 | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" | "NA" |
转成 tidy 数据#
这是很典型的真实数据:列冗余、变量编码不直观、大量缺失值。整体看数据比较杂乱,整理要分好几步,而 Polars 的每个函数只做一件事,所以实际工作里通常要把多个操作串成一条流水线。
country、iso2、iso3 三个变量都在重复表达国家,year 显然也是变量。其他列代表什么还不清楚,但从列名结构(new_sp_m014、new_ep_m014、new_ep_f014)看,它们更像是“取值”而不是变量名:编码里藏着诊断方式、性别、年龄段三层信息。
另外这些列是整数,用 "NA" 字符串表示 NULL,这是这份文件的约定。下面的代码先把它们转成整数,strict=False 会把转不了的值变成 NULL。
variable_cols = ['country', 'iso2', 'iso3', 'year']
df2 = df.with_columns(
pl.exclude(variable_cols)
.cast(pl.Int32, strict=False)
)
下面的代码把 new_sp_m014 到 newrel_f65 的所有列合并起来:这些列的列名本身就是数据,所以先把列名变成一列 key,把值放进 cases。
这些列的含义还不清楚,所以先统一叫 key,值列则按内容叫 cases。缺失值很多,用 drop_nulls() 只保留有值的行,避免后面的统计被 NULL 拉偏。
df3 = (
df2
.unpivot(
index=variable_cols,
variable_name="key",
value_name="cases"
)
.drop_nulls('cases')
)
df3.head()
| country | iso2 | iso3 | year | key | cases |
|---|---|---|---|---|---|
| str | str | str | i64 | str | i32 |
| "Afghanistan" | "AF" | "AFG" | 1997 | "new_sp_m014" | 0 |
| "Afghanistan" | "AF" | "AFG" | 1998 | "new_sp_m014" | 30 |
| "Afghanistan" | "AF" | "AFG" | 1999 | "new_sp_m014" | 8 |
| "Afghanistan" | "AF" | "AFG" | 2000 | "new_sp_m014" | 52 |
| "Afghanistan" | "AF" | "AFG" | 2001 | "new_sp_m014" | 129 |
从 key 列取出信息#
key 列的值由几段拼成,每一段都有含义。
开头 3 个字符表示这列是新病例还是旧病例,这份数据里全是新病例。
随后是结核的类型:
rel:复发病例ep:肺外结核sn:涂片阴性的肺结核sp:涂片阳性的肺结核
再往后是性别:男性(m)和女性(f)。
最后是年龄段,分成 7 组:
014= 0 – 14 岁1524= 15 – 24 岁2534= 25 – 34 岁3544= 35 – 44 岁4554= 45 – 54 岁5564= 55 – 64 岁65= 65 岁以上
列名需要先修正,因为它有轻微的不一致:有的是 newrel 而不是 new_rel,从列名上看不出来,但不修下一步就会报错。
用
.str.replace()替换字符串,思路很简单:把"newrel"换成"new_rel",让所有变量名一致。用
.str.extract_groups()按正则取出各段信息,结果存在结构体列里。用
unnest()把结构体字段变成普通列。
df4 = (
df3
.with_columns(
pl.col('key')
.str.replace("newrel", "new_rel")
.str.extract_groups('^new_(?<type>\w+)_(?<sex>\w)(?<age>\d+)$')
)
.unnest('key')
)
df4
<>:6: SyntaxWarning: "\w" is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\w"? A raw string is also an option.
<>:6: SyntaxWarning: "\w" is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\w"? A raw string is also an option.
C:\Users\ruoyu\AppData\Local\Temp\ipykernel_15888\3199331167.py:6: SyntaxWarning: "\w" is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\w"? A raw string is also an option.
.str.extract_groups('^new_(?<type>\w+)_(?<sex>\w)(?<age>\d+)$')
| country | iso2 | iso3 | year | type | sex | age | cases |
|---|---|---|---|---|---|---|---|
| str | str | str | i64 | str | str | str | i32 |
| "Afghanistan" | "AF" | "AFG" | 1997 | "sp" | "m" | "014" | 0 |
| "Afghanistan" | "AF" | "AFG" | 1998 | "sp" | "m" | "014" | 30 |
| "Afghanistan" | "AF" | "AFG" | 1999 | "sp" | "m" | "014" | 8 |
| "Afghanistan" | "AF" | "AFG" | 2000 | "sp" | "m" | "014" | 52 |
| "Afghanistan" | "AF" | "AFG" | 2001 | "sp" | "m" | "014" | 129 |
| … | … | … | … | … | … | … | … |
| "Viet Nam" | "VN" | "VNM" | 2013 | "rel" | "f" | "65" | 3110 |
| "Wallis and Futuna Islands" | "WF" | "WLF" | 2013 | "rel" | "f" | "65" | 2 |
| "Yemen" | "YE" | "YEM" | 2013 | "rel" | "f" | "65" | 360 |
| "Zambia" | "ZM" | "ZMB" | 2013 | "rel" | "f" | "65" | 669 |
| "Zimbabwe" | "ZW" | "ZWE" | 2013 | "rel" | "f" | "65" | 725 |
年龄段列转换#
最后把表示年龄段的列拆成起始年龄和结束年龄两列,这样“65 岁以上”也能和别的组用同一种方式比较。
为了统一,先用
replace()把"014"换成"0014"、"65"换成"65NA"。这样每个值的前 2 个字符是起始年龄,后 2 个字符是结束年龄。这个替换要用两次,所以存进变量age。取出前 2 个字符,cast 成
UInt8。取出后 2 个字符,用
strict=Falsecast 成UInt8,"NA"会变成 NULL,正好表示“没有上限”。
警告
replace() 和 .str.replace() 不是一回事:replace() 替换整个值,.str.replace() 替换字符串里的一部分。
age = pl.col("age").replace(["014", "65"], ["0014", "65NA"])
df5 = df4.select(
age.str.slice(0, 2).cast(pl.UInt8).alias("age_start"),
age.str.slice(2).cast(pl.UInt8, strict=False).alias("age_end"),
)
df5
| age_start | age_end |
|---|---|
| u8 | u8 |
| 0 | 14 |
| 0 | 14 |
| 0 | 14 |
| 0 | 14 |
| 0 | 14 |
| … | … |
| 65 | null |
| 65 | null |
| 65 | null |
| 65 | null |
| 65 | null |
汇总#
把上面的步骤串成一条流水线,最后一次性取结果。为了提高速度,用 scan_csv() 拿到延迟帧,最后用 collect() 一次执行,这样不会产生中间表,内存占用只和最终结果相当。读 CSV 时用 null_values 指定表示 NULL 的字符串,new* 列就都会变成整数型。
age = pl.col('age').replace(["014", "65"], ["0014", "65NA"])
variable_cols = ['country', 'iso2', 'iso3', 'year']
df = (
pl.scan_csv('data/who.csv', null_values=['NA'])
.unpivot(
index=variable_cols,
variable_name="key",
value_name="cases"
)
.drop_nulls('cases')
.with_columns(
pl.col('key')
.str.replace("newrel", "new_rel")
.str.extract_groups('^new_(?<type>\\w+)_(?<sex>\\w)(?<age>\\d+)$')
)
.unnest('key')
.with_columns(
age
.str.slice(0, 2)
.cast(pl.UInt8)
.alias('age_start'),
age
.str.slice(2)
.cast(pl.UInt8, strict=False)
.alias('age_end')
)
.drop('iso2', 'iso3', 'age')
.collect()
)
df
| country | year | type | sex | cases | age_start | age_end |
|---|---|---|---|---|---|---|
| str | i64 | str | str | i64 | u8 | u8 |
| "Afghanistan" | 1997 | "sp" | "m" | 0 | 0 | 14 |
| "Afghanistan" | 1998 | "sp" | "m" | 30 | 0 | 14 |
| "Afghanistan" | 1999 | "sp" | "m" | 8 | 0 | 14 |
| "Afghanistan" | 2000 | "sp" | "m" | 52 | 0 | 14 |
| "Afghanistan" | 2001 | "sp" | "m" | 129 | 0 | 14 |
| … | … | … | … | … | … | … |
| "Viet Nam" | 2013 | "rel" | "f" | 3110 | 65 | null |
| "Wallis and Futuna Islands" | 2013 | "rel" | "f" | 2 | 65 | null |
| "Yemen" | 2013 | "rel" | "f" | 360 | 65 | null |
| "Zambia" | 2013 | "rel" | "f" | 669 | 65 | null |
| "Zimbabwe" | 2013 | "rel" | "f" | 725 | 65 | null |