pythonのpandasによる簡単な統計処理:第2回 数値のカテゴリ化とクロス集計

カテゴリー名: [pandasによる簡単な統計処理

2017/11/04

 当シリーズでは、pythonのpandasを使って、
簡単な統計処理を試みます。

 日本語MS-Windowsの環境下で、Excelファイルを素材にします。
文字コードは cp932 (Shift_JISの拡張版)を前提にします。

 当Webページで紹介するスクリプトや素材データ一式は、
pandas02.zip という圧縮ファイルに同梱しておきます。

 今回は、身長(数値)を「160〜170」のようにカテゴリ化し、
性別とのクロス集計を行います。

    


《このページの目次》


    

はじめに

 pythonをめぐる環境は「第1回」のときと同じですが、念のため記します。

    

 素材となるソースデータは pt_source.xls です。

 四つの列(ID、性別、身長、体重)からなるデータですが、
今回、体重は扱いません。

ID 性別 身長 体重
C3 女性 159.1 57.8
W5 男性 163.8 78.2
W11 女性 162.7 59.5
H1 女性 157 59.6

 上の形式で 400人分のデータが書かれています。

 IDには欠損値がありませんが、性別、身長、体重には
僅かですが欠損値があります。

目次に戻る


1. 数値データのカテゴリ化と人数のカウント

 身長(数値データ)を「〜150、150〜160、160〜170、170〜180、180〜」
のようにカテゴリ化して、各カテゴリに所属する人数をカウントします。

 この項で作成する表は次のとおり。

身長区分 人数 構成比
〜150 13 3.2
150〜160 106 26.5
160〜170 206 51.5
170〜180 61 15.2
180〜 6 1.5
不明 8 2
合計 400 100

    

(1) cutによるカテゴリ化

 変数 dtf に Excelファイルを読み込んだ結果が入っているものとします。

 dtf は、DataFrame です。

 この場合、身長をカテゴリ化するには次のようにします。

bins = [0.0, 150.0, 160.0, 170.0, 180.0, 500.0]
labels = [u'〜150', u'150〜160', u'160〜170', u'170〜180', u'180〜']
dtf[u'身長区分'] = pd.cut(dtf[u"身長"], bins, right=False, labels=labels)

 上を実行すると、「身長区分」という列が新たにできます。

 身長 159.1 の人の「身長区分」は「150〜160」になり、
167.3 の人なら「160〜170」になります。

 いわば数値データに名前を付けたことになります。

 こうしておけば、「150〜160」などの名前によって分類できます。

    

 cut() の第2引数 binsは、カテゴリに分割するときの区切りの値です。

bins = [0.0, 150.0, 160.0, 170.0, 180.0, 500.0]

 上の指定は、「0〜150、150〜160、160〜170、170〜180、180〜500」に
分割することを指示します。

    

 第2引数 right=False は、各カテゴリの上限値(右側の値)の人を
カテゴリに含めないようにするための指定です。

 False にしておくと、「150〜160」に 160.0 の人は含まれません。

 カテゴリが「150以上・160未満」の意味になります。

 デフォルトは right=True です。

    

 第3引数の labels は、各カテゴリの名前を指定するものです。

labels = [u'〜150', u'150〜160', u'160〜170', u'170〜180', u'180〜']

 上のリストで指定した名前がカテゴリ名になります。

    

 身長の列には欠損値があるのですが、それに「不明」と名前を付けるとすれば、
欠損値に便宜上 999.0 を割り当てることにして、たとえば下のようにします。

bins = [0.0, 150.0, 160.0, 170.0, 180.0, 500.0, 1000.0]
labels = [u'〜150', u'150〜160', u'160〜170', u'170〜180', u'180〜', u'不明']
dtf[u'身長区分'] = pd.cut(dtf[u"身長"].fillna(999.0),
  bins, right=False, labels=labels)

目次に戻る


(2) カテゴリごとの人数をカウント

 新たに「身長区分」という列が加わった DataFrame を材料にして、
カテゴリごとの人数をカウントします。

 また、各カテゴリの合計値も追加します。

 「身長区分」による分類は、groupby() で行うことにします。

dgb = dtf.groupby(u'身長区分')

 上を実行すると、身長区分ごとに分類された DataFrame ができます。

 「不明」を入れると6分類になるので
「ID、性別、身長、体重、身長区分」の5列から構成される
DataFrame が6個できることになります。

 変数dgbに、その6個の DataFrame が記録される訳です。

    

 次に、各カテゴリに所属する人の人数をカウントします。

ser = dgb[u'身長区分'].size()

 上のようにすると、各カテゴリの人数が series として取得できます。

 変数 ser を print() で表示させると次のとおり。

身長区分
〜150        13
150〜160    106
160〜170    206
170〜180     61
180〜         6
不明           8
Name: 身長区分, dtype: int64

目次に戻る


(3) 合計値の追加とパーセンテージの算出

 初心者の私は、ここで引っかかりました。

 series の合計値は ser.sum() のようにして求めることができます。

 だとすれば、下の2行で変数serに合計値を追加できるのでは?と考えました。

xsum = ser.sum()
ser[u'合計'] = xsum

 ところが、これだとエラーが発生します。

 変数serの行ラベルは、「〜150、150〜160、……」ですが、
これが CategoricalIndex という特殊な型のようで、

TypeError: cannot insert an item into a CategoricalIndex  
    that is not already an existing category

 上のエラーが起きます。

 そこで、ser.index = list(ser.index) として、
serの行ラベルを付け直してみます。

 その上で合計値を追加すると、正常に処理できました。

    

 合計値に対する各カテゴリの構成比(パーセンテージ)は、

ser2 = ser / xsum * 100.0

 上の1行で求めることができます。

 人数の ser、パーセンテージの ser2 が得られれば、
この項の冒頭に掲げた表を出力できます。

目次に戻る


(4) スクリプト pd01.py の全体像

 ここで、身長区分ごとの人数と構成比を
Excelファイルに書き出すスクリプトを掲載しておきます。

 1# pd01.py (coding: cp932)
 2import os, sys
 3import pandas as pd
 4import xlwt
 5
 6from platform import python_version
 7if int(python_version()[0]) < 3:  # python ver 2 の場合
 8    reload(sys)
 9    sys.setdefaultencoding('cp932')  # デフォルト文字コードを変更
10
11xls_file = "pt_source.xls"
12dtf = pd.read_excel(xls_file)
13
14bins = [0.0, 150.0, 160.0, 170.0, 180.0, 500.0, 1000.0]
15labels = [u'〜150', u'150〜160', u'160〜170', u'170〜180', u'180〜', u'不明']
16dtf[u'身長区分'] = pd.cut(dtf[u"身長"].fillna(999.0),
17  bins, right=False, labels=labels)
18dgb = dtf.groupby(u'身長区分')
19ser = dgb[u'身長区分'].size()  # 身長区分ごとの人数を得る
20ser.index = list(ser.index)  # CategoricalIndexを通常のindexに
21xsum = ser.sum()  # 人数の合計値
22ser[u'合計'] = xsum
23dtf2 = pd.DataFrame()
24dtf2[u'人数'] = ser
25dtf2[u'構成比'] = (ser / xsum * 100.0).round(1)
26dtf2.index.name = u'身長区分'
27dtf2.to_excel("pd01.xls")

目次に戻る


2. クロス集計(度数表)

 性別と身長区分とのクロス集計を行います。

 作成する表は次のとおり(人数の表)。

  〜150 150〜160 160〜170 170〜180 180〜 不明 合計
男性 0 25 117 50 6 6 204
女性 13 81 87 11 0 2 194
記載なし 0 0 2 0 0 0 2
合計 13 106 206 61 6 8 400

    

(1) pivot_tableによるクロス集計

 これまで用いてきた groupby() を二段構えで行えば、
クロス集計の表を得ることができます。

 でも、pivot_table() を使えば、より簡単です。

 ただ、簡単とはいえ引っかかったところがあるので、それを中心に記します。

    

◇ pivot_tableの主な引数の指定の仕方

 変数dtf(DataFrame型)に、既に「身長区分」の列が設けられているものとします。

 そのとき、下のように pivot_table() を呼び出します。

dtf2 = pd.pivot_table(dtf, values=[u'ID'], index=u'性別',
  columns=u'身長区分', aggfunc='count')

 どの項目とどの項目をクロスさせるかは、
index と columns で指定します。

 index=u'性別' は、性別の内訳(女性、男性、記載なし)を
縦方向に並べるための指定です。

 columns=u'身長区分' は、身長区分の内訳を横方向に並べる指定。

 最後の引数 aggfunc は、計算処理の種類を指定するものです。

 aggfunc='count' とすれば、度数(人数・個数)の数え上げです。

 これを省略すると、'mean' の平均値算出になります。

    

 私が引っかかったのは values の指定です。

 これは、どの項目に対して aggfunc の計算処理を適用するかを指定するものです。

 上記では values=[u'ID'] としました。

 IDの列には欠損値がないので、取りこぼしなく数え上げができるからです。

 他に、性別と身長区分にも実質的に欠損値がない状態になっているので、
values=[u'性別'] とか values=[u'身長区分'] も試してみましたが、
どちらもエラーになりました。

 引数 index, columns で指定した項目は、
values で指定できない(?)のだろうとおもいます。

    

◇ pivot_tableの戻り値

dtf2 = pd.pivot_table(dtf, values=[u'ID'], index=u'性別',
  columns=u'身長区分', aggfunc='count')

 上記で、引数 values を省略したとすると、
「ID、身長、体重」の三つについて
「性別×身長区分」のクロス集計表が生成されます。

 戻り値が代入される変数 dtf2 には、この三つの集計表(DataFrame)が入ります。

 当然、dtf2は、MultiIndexの構造になります。

 では、ちゃんと values を指定した場合にどうなるかというと、
やはり MultiIndex になります。

 大枠の階層には「ID」の一つしか要素がありませんが、
それでも MultiIndex です。

 通常の2次元マトリクス的な DataFrame にしたいときは次のようにします。

dtf2 = dtf2[u'ID']

目次に戻る


(2) 「合計」の追加

 ここまでの処理で、変数 dtf2 に「性別×身長区分」のクロス集計表が入りました。

 ただ、「合計」を追加する場合、扱いにくい点があります。

 「合計」追加の下準備を含めて少し書きます。

    

◇ CategoricalIndexの変換

 dtf2の列ラベル「〜150、150〜160、……」は CategoricalIndex です。

 dtf2に「合計」を付加するとき、このことが障壁になります。

 なので、下のように CategoricalIndex型を通常型に変換します。

dtf2.columns = list(dtf2.columns)

    

◇ 欠損値の置き換え

 dtf2 には欠損値が含まれています。

 性別の「記載なし」は全部で2人だけなので、数え上げできなう箇所が生じ
クロス集計表内に空欄セルができます。

 その空欄セルが欠損値です。

 欠損値のままでもいいのですが、数値 0 に置き換えることにします。

 dtf2.fillna(0) とすれば、欠損値を置換できるかとおもいましたが、
ダメなようです。

 なので、次のように列ごとに置換することにします。

for col in dtf2.columns:  # 欠損値を0にする
    dtf2[col] = dtf2[col].fillna(0)

 これで「合計」を追加する準備ができました。

    

◇ 「合計」の追加

 series に「合計」を追加するのは簡単でしたが、
DataFrame の場合は少々面倒です。

 私には整理してロジックをたどるのが難しいので、
「こう書けば、こうなる」という慣用句のように使うことにしました。

 結局、次のようにすれば行と列の両方の「合計」を追加できます。

dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=0), columns=[u’合計’]).T]) # 最後の行として合計を追加 dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=1), columns=[u’合計’])], axis=1) # 最後の列として合計を追加

 これで変数 dtf2 に縦・横両方の「合計」を追加できます。

目次に戻る


(3) スクリプト pd02.py の全体像

 ここで、「性別×身長区分」のクロス集計表を
Excelファイルに書き出すスクリプトを掲げておきます。

 1# pd02.py (coding: cp932)
 2import os, sys
 3import pandas as pd
 4import xlwt
 5
 6from platform import python_version
 7if int(python_version()[0]) < 3:  # python ver 2 の場合
 8    reload(sys)
 9    sys.setdefaultencoding('cp932')  # デフォルト文字コードを変更
10
11xls_file = "pt_source.xls"
12dtf = pd.read_excel(xls_file)
13dtf[u'性別'] = dtf[u'性別'].fillna(u'記載なし')
14
15bins = [0.0, 150.0, 160.0, 170.0, 180.0, 500.0, 1000.0]
16labels = [u'〜150', u'150〜160', u'160〜170', u'170〜180', u'180〜', u'不明']
17dtf[u'身長区分'] = pd.cut(dtf[u"身長"].fillna(999.0),
18  bins, right=False, labels=labels)
19dtf2 = pd.pivot_table(dtf, values=[u'ID'], index=u'性別',
20  columns=u'身長区分', aggfunc='count')
21dtf2 = dtf2[u'ID']  # MultiIndex構造から通常のDataFrameへ
22dtf2.columns = list(dtf2.columns)  # CategoricalIndex型を通常型へ
23for col in dtf2.columns:  # 欠損値を0にする
24    dtf2[col] = dtf2[col].fillna(0)
25dtf2 = dtf2.ix[[u'男性', u'女性', u'記載なし']]
26dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=0),
27  columns=[u'合計']).T])  # 最後の行として合計を追加
28dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=1),
29  columns=[u'合計'])], axis=1)  # 最後の列として合計を追加
30dtf2.to_excel("pd02.xls")

目次に戻る


3. 度数と構成比のクロス集計表

 度数表から構成比の表を生成し、
その二つの表を2種類の形でくっつけます。

 二つの表を単純に縦にくっつけるやり方と、
 度数と構成比を隣接させて一つの表にまとめるやり方です。

    

(1) 構成比(パーセンテージ)の表を作成

 男女それぞれ別個に、身長区分別のパーセンテージを算出します。

 具体的には次の表を作成します。

  〜150 150〜160 160〜170 170〜180 180〜 不明 合計
男性 0.0 12.3 57.4 24.5 2.9 2.9 100.0
女性 6.7 41.8 44.8 5.7 0.0 1.0 100.0
記載なし 0.0 0.0 100.0 0.0 0.0 0.0 100.0
合計 3.2 26.5 51.5 15.2 1.5 2.0 100.0

    

 度数表(人数の表)が既に変数 dtf2 にセットされているものとします。

 dtf2 は DataFrame です。

 DataFrameから series を取り出すには、列単位の方がやりやすいので、
まず、行と列を入れ替えて「男性、女性、……」を列にします。

 そうすれば、男女それぞれの身長区分別人数を series として取り出せます。

 series の最後の要素が「合計」なので、
各要素を「合計」で割り算し、100を掛ければOKです。

 変数 ser に series が代入されている場合、
ser[-1] で最後の要素の値を参照できます。

 ということで、度数表が入っている dtf2 を構成比表にするための
スクリプト pd03.py の該当箇所は下のとおり。

dtf2 = dtf2.T  # 男性、女性、……を行から列へ
for col in dtf2.columns:  # 男女別にパーセンテージを算出
    xsum = dtf2[col][-1]  # 最後の要素「合計」の値
    dtf2[col] = (dtf2[col] / xsum * 100.0).round(1)  # 構成比の算出
dtf2 = dtf2.T  # 行と列を元に戻す
dtf2.to_excel("pd03.xls", u'性別×身長区分のパーセンテージ')

目次に戻る


(2) 度数表と構成比表を縦にくっつける

 度数表が変数 dtf_frq に入っていて、
構成比表が dtf_pct に入っているとすれば、
それを縦にくっつけるのは次のスクリプトで可能です。

dtf2 = pd.concat([dtf_frq, dtf_pct], axis=0,
  keys=[u'度数(人)', u'構成比(%)'])  # 度数表と構成比表を縦にくっつける

 この項の肝は上記でおわりですが、
ちょっと引っかかった点があるので記します。

 DataFrame の代入処理に関するものです。

    

 変数 dtf2 にクロス集計のための処理とか、
「合計」不可の処理を施して度数表に仕上げたとします。

 そこで dtf_frq = dtf2 として度数表を保存します。

 その上で、今度は dtf2 に加工処理を施して構成比表に仕立て上げます。

 すると、変数 dtf_frq が度数表でなく構成比表になってしまいます。

 この場合、dtf_frq = dtf2 とするのでなく
dtf_frq = dtf2.copy() としなければダメなようです。

 DataFrame の = による単なる代入処理は、
「値渡し」でなく「参照渡し」に該当するようです。

    

 pd04.py から該当箇所を抜粋しておきます。

  (前略)
dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=0),
  columns=[u'合計']).T])  # 最後の行として合計を追加
dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=1),
  columns=[u'合計'])], axis=1)  # 最後の列として合計を追加
dtf_frq = dtf2.copy()  # 度数表として保存

dtf2 = dtf2.T  # 男性、女性、……を行から列へ
for col in dtf2.columns:  # 男女別にパーセンテージを算出
    xsum = dtf2[col][-1]  # 最後の要素「合計」の値
    dtf2[col] = (dtf2[col] / xsum * 100.0).round(1)  # 構成比の算出
dtf_pct = dtf2.T  # 行と列を元に戻して構成比の表に
dtf2 = pd.concat([dtf_frq, dtf_pct], axis=0,
  keys=[u'度数(人)', u'構成比(%)'])  # 度数表と構成比表を縦にくっつける
dtf2.to_excel("pd04.xls")

目次に戻る


(3) 度数と構成比を隣接させて一つの表にまとめる

 ここで作る表は下のようになります。

    〜150 150〜160 160〜170 170〜180 180〜 不明 合計
男性 人数 0 25 117 50 6 6 204
  構成比 0.0 12.3 57.4 24.5 2.9 2.9 100.0
女性 人数 13 81 87 11 0 2 194
  構成比 6.7 41.8 44.8 5.7 0.0 1.0 100.0
記載なし 人数 0 0 2 0 0 0 2
  構成比 0.0 0.0 100.0 0.0 0.0 0.0 100.0
合計 人数 13 106 206 61 6 8 400
  構成比 3.2 26.5 51.5 15.2 1.5 2.0 100.0

    

 上の表とは行と列が逆転した状態の 度数表と 構成比表が
それぞれ dtf_frq, dtf_pct に入っているとします。

 男性の人数と構成比の二つを新しい DataFrame に記録するには次のようにします。

dtfx = pd.DataFrame()  # 空のDataFrameを設ける
ser1 = dtf_frq[u'男性']  # 男性の度数に関するseries
ser2 = dtf_pct[u'男性']  # 男性の構成比に関するseries
dtfx[u'人数'] = ser1
dtfx[u'構成比'] = ser2

 上の処理を「女性、記載なし、合計」についても行うと、
人数と構成比の2列からなる新しい DataFrame が4個できます。

 最後に、その四つの DataFrame を pd.concat() で結合し、
行と列を入れ替えれば目的の DataFrame が得られます。

 行が MultiIndexの構造になっている DataFrame です。

    

 スクリプト pd05.py から該当箇所を抜粋しておきます。

  (前略)
dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=0),
  columns=[u'合計']).T])  # 最後の行として合計を追加
dtf2 = pd.concat([dtf2, pd.DataFrame(dtf2.sum(axis=1),
  columns=[u'合計'])], axis=1)  # 最後の列として合計を追加
dtf2 = dtf2.T  # 男性、女性、……を行から列へ
dtf_frq = dtf2.copy()  # 度数表として保存

for col in dtf2.columns:  # 男女別にパーセンテージを算出
    xsum = dtf2[col][-1]  # 最後の要素「合計」の値
    dtf2[col] = (dtf2[col] / xsum * 100.0).round(1)  # 構成比の算出
dtf_pct = dtf2.copy()  # 構成比として保存

  # 人数と構成比を隣接させ、一つの表にする
clist = list(dtf_frq.columns)  # 列名:「男性、女性、……」
dlist = list()
for col in clist:
    dtfx = pd.DataFrame()
    dtfx[u'人数'] = dtf_frq[col]
    dtfx[u'構成比'] = dtf_pct[col]
    dlist.append(dtfx)
dtf2 = pd.concat(dlist, axis=1, keys=clist)
dtf2 = dtf2.T  # 「男性、女性、……」を列から行に
dtf2.to_excel("pd05.xls")

    

 今回は これで終了です。

 体重については取り上げませんでしたが、身長と同じように処理可能です。

Copyright (C) T. Yoshiizumi, 2017 All rights reserved.