Tidy データ#

Tidy データは、データ分析と可視化をしやすくするために、データを決まった形に整える方法です。次の原則に基づきます。

  1. 各列が変数を表す — データセットの各列が1つの変数に対応します。たとえば気温のデータセットでは、列が地点や日時の気温を表します。

  2. 各行が観測値を表す — 各行が1つの観測値に対応します。気温のデータセットでは、行が特定の地点・日時の観測値を表します。

  3. 各セルが単一の値を表す — 各セルには1つの値だけが入ります。

表 1 整理されていないデータの例#

Country

1999_cases

1999_population

2000_cases

2000_population

Afghanistan

745

19987071

2666

20595360

Brazil

37737

172006362

80488

174504898

China

212258

1272915272

213766

1280428583

このデータセットでは、年ごとに症例数と人口の列が分かれています。この形では解析しにくく、変数と観測値の区別もはっきりしません。

表 2 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)の症例が入っています。疫学の情報は豊富ですが、そのままの形では扱いにくいデータです。

次のコードで who.csv を読み込み、列名とランダムな5行を確認します。

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
"Lithuania""LT""LTU"2013"NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""19""49""150""254""299""209""160""18""42""76""84""82""57""74"
"Bhutan""BT""BTN"1986"NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA"
"Colombia""CO""COL"1993"NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA"
"British Virgin Islands""VG""VGB"2004"NA""NA""NA""NA""1""NA""NA""NA""NA""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""NA""NA"
"Spain""ES""ESP"2012"10""112""259""299""276""156""220""15""101""202""161""70""24""74""125""92""172""175""166""133""297""120""72""141""118""70""50""114""35""71""164""162""104""85""196""38""71""138""140""75""54""173""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA""NA"

Tidy データに変更#

典型的な実データの例で、冗長な列や奇妙な変数コード、多くの欠損値が含まれています。データが散らかっていて、整えるには複数の手順が必要です。Polars の関数は1つのことをうまくこなすように設計されているため、実際には複数の操作をパイプラインでつなぐことになります。

国(country)、ISO2(iso2)、ISO3(iso3)は、国を重複して表す3つの変数です。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 にします。欠損値が多いため、drop_nulls() で存在する値だけに絞ります。

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歳以上

列名の形式を少し直す必要があります。new_rel のはずが newrel になっている箇所があるためです(気づきにくいですが、直さないと次の手順でエラーになります)。

  • .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_13676\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

年齢列変換#

最後に、年齢の範囲を表す列を、開始年齢と終了年齢の列に分けます。

  1. まず replace() で "014" を "0014" に、"65" を "65NA" に置き換え、すべての値の先頭2文字が開始年齢、後半2文字が終了年齢になるようにします。この操作を2回使うので、変数 age に保存します。

  2. 次に、先頭2文字を取り出し、UInt8 にキャストします。

  3. 最後に、後ろ2文字を取り出し、strict=False で UInt8 にキャストします。

警告

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