整洁数据(Tidy data)#

Tidy data 是把数据整理成规则形式的一套做法,让分析和可视化都更容易。它不是某种文件格式,而是一组关于“行和列该表示什么”的约定,一共三条:

  1. 每列是一个变量:数据集里的每一列代表一个变量,比如“气温”“人口”,同一列里不该混进两种含义。

  2. 每行是一个观测:每一行代表一次观测,比如“某地某时的一次测量”。

  3. 每格一个值:每个单元格只放一个值,不能放进多个值。

违反这三条的数据并不是错,只是每做一步分析都需要先写许多转换。下面两张表是同一份数据的两种摆法,对比一下就能看出差别在哪。

不整洁的数据示例

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']
shape: (5, 60)
countryiso2iso3yearnew_sp_m014new_sp_m1524new_sp_m2534new_sp_m3544new_sp_m4554new_sp_m5564new_sp_m65new_sp_f014new_sp_f1524new_sp_f2534new_sp_f3544new_sp_f4554new_sp_f5564new_sp_f65new_sn_m014new_sn_m1524new_sn_m2534new_sn_m3544new_sn_m4554new_sn_m5564new_sn_m65new_sn_f014new_sn_f1524new_sn_f2534new_sn_f3544new_sn_f4554new_sn_f5564new_sn_f65new_ep_m014new_ep_m1524new_ep_m2534new_ep_m3544new_ep_m4554new_ep_m5564new_ep_m65new_ep_f014new_ep_f1524new_ep_f2534new_ep_f3544new_ep_f4554new_ep_f5564new_ep_f65newrel_m014newrel_m1524newrel_m2534newrel_m3544newrel_m4554newrel_m5564newrel_m65newrel_f014newrel_f1524newrel_f2534newrel_f3544newrel_f4554newrel_f5564newrel_f65
strstrstri64strstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstrstr
"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()
shape: (5, 6)
countryiso2iso3yearkeycases
strstrstri64stri32
"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+)$')
shape: (76_046, 8)
countryiso2iso3yeartypesexagecases
strstrstri64strstrstri32
"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 岁以上”也能和别的组用同一种方式比较。

  1. 为了统一,先用 replace() 把 "014" 换成 "0014"、"65" 换成 "65NA"。这样每个值的前 2 个字符是起始年龄,后 2 个字符是结束年龄。这个替换要用两次,所以存进变量 age。

  2. 取出前 2 个字符,cast 成 UInt8。

  3. 取出后 2 个字符,用 strict=False cast 成 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
shape: (76_046, 2)
age_startage_end
u8u8
014
014
014
014
014
……
65null
65null
65null
65null
65null

汇总#

把上面的步骤串成一条流水线,最后一次性取结果。为了提高速度,用 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
shape: (76_046, 7)
countryyeartypesexcasesage_startage_end
stri64strstri64u8u8
"Afghanistan"1997"sp""m"0014
"Afghanistan"1998"sp""m"30014
"Afghanistan"1999"sp""m"8014
"Afghanistan"2000"sp""m"52014
"Afghanistan"2001"sp""m"129014
…………………
"Viet Nam"2013"rel""f"311065null
"Wallis and Futuna Islands"2013"rel""f"265null
"Yemen"2013"rel""f"36065null
"Zambia"2013"rel""f"66965null
"Zimbabwe"2013"rel""f"72565null