pythonのprastクラスによる集計(7) 複数回答の集計・後編

2018/11/10

 今回は複数回答の簡単な分析を取り上げます。

 扱うcsvファイルは前回と同じ。

 主な情報源として「新聞、雑誌、テレビ、ラジオ」の4つの選択肢を示し、
該当するものに○をつけてもらったケースです。

 選択肢を分解してクロス集計を行い、
それをカイ2乗検定やフィッシャーの直接確率検定にかけます。

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

    


《このページの目次》


    

1. 選択個数の確認

 1人の回答者が複数回答の選択肢のうちで
何個に○をつけたかをチェックします。

 ○を全くつけていないケースを無回答とするかどうか悩ましいところですが、
もし無回答として集計の対象外にするなら選択個数をチェックする必要があります。

 また、ほとんどの人が1つの選択肢しか選んでいないとすれば
「新聞を選んだ人は雑誌も選ぶ傾向がある」といった
選択肢相互の関連性を検証できないことになります。

 これも、やはり選択個数のチェックが必要です。

 さらに言うと、どれにも○をつけない回答者や全部に○をつけた回答者が
4割とか5割のような多数にのぼる場合、
選択肢の立て方が適切だったかどうか疑問になります。

 そうした情報を開示した上で分析しないと、
フェアな解説にならないだろうとおもいます。

 ということで、Prastクラスに ma_select() を設けて
選択個数を簡単に検出できるようにしました。

(1) ma_selectメソッドの機能

 ma_select() は選択個数を記録した新しい列をDataFrameに追加します。

 data01.csv を読み込んだ DataFrame は6つの列から構成されますが、
ma_select() を適用すると7つ目の列が追加され、
その列には各回答者の選択個数(数値)が記録されます。

 今回のケースでは 0〜4 のどれかの数値が各回答者ごとに記録されます。

ma_select(clist, value=1, name="ma_select")

 引数の意味は次のとおり。

 ma_select() は、新設の列が追加された DataFrame を返します。

 Prastオブジェクトに内部的に保持されている DataFrame にも
新設の列が追加されます。

 なお、引数 name で指定した名前の列が既にある場合は
上書きになるので注意して下さい。

    

(2) サンプルスクリプト

 ma_select() を適用して各人の選択個数を記録してから、
個数別に回答者数を数え上げるスクリプトを掲げます。

 まず、得られる表は次のとおり。

選択個数 度数 割合
0 4 4.1
1 25 25.5
2 41 41.8
3 27 27.6
4 1 1.0
全体 98 100.0

 どれにも○をつけなかった人が4人、
全部に○をつけた人が1人いることがわかります。

 以下、この表を出力するスクリプトです。

 1# ma_select.py (coding: cp932)
 2import pandas as pd
 3from prast import Prast
 4
 5dtf = pd.read_csv("data01.csv")
 6psx = Prast(dtf, "data01_c.txt", "data01_i.txt", "cp932")
 7rng = ['newspaper', 'magazine', 'tv', 'ragio']
 8ename = "ma_select"  # 新設する選択個数の列ラベル(英字)
 9jname = u"選択個数"  # enameの別名(日本語)
10psx.ma_select(rng, value=1, name=ename)
11psx.put_columns("%s,%s" % (ename, jname))  # 列ラベルの別名を追加登録
12dod = psx.count(ename)  # 選択個数別の集計
13print(dod[jname])

 新設された選択個数の列は単一回答として扱えるので
count() を用いて度数と割合を集計しています。

    

(3) 無回答と全回答を対象外とする場合

 選択肢のどれにも○をつけていない回答者、および
選択肢全部に○をつけた回答者を集計の対象外とする方法を記します。

 Prastオブジェクトが内部的に保持しているDataFrameを得るのは get_dtf()
また、DataFrameをオブジェクトに記録するのは put_dtf() です。

 この両メソッドにはそれぞれ gd(), pd() という短縮名があります。

 これを使えば、選択個数0の人と個数4の人を対象外にするのは次のとおり。

 スクリプトの要所のみ掲載。

rng = ['newspaper', 'magazine', 'tv', 'ragio']
ename = "ma_select"  # 新設する選択個数の列ラベル(英字)
psx.ma_select(rng, value=1, name=ename)
dtf = psx.gd()
ser1 = dtf[ename] > 0  # True, FalseからなるSeriesその1
ser2 = dtf[ename] < len(rng)  # True, FalseからなるSeriesその2
dtf = dtf[ser1 & ser2]  # ser1, ser2 どちらもTrueのデータを抽出
psx.pd(dtf)  # PrastオブジェクトにDataFrameをセット

 上のようにすれば、選択個数が1以上・4未満の人だけからなるDataFrameが
Prastオブジェクトにセットされます。

    

 pandasの query() を使えば、もう少し簡略化した書き方が可能です。

psx.pd(psx.gd()
    .query("ma_select > 0 & ma_select < 4")
)

 これで選択個数1以上・4未満の人だけからなるDataFrameがセットされます。

 念のため query() を用いたスクリプトを掲げておきます。

 1# ma_select02.py (coding: cp932)
 2import pandas as pd
 3from prast import Prast
 4
 5dtf = pd.read_csv("data01.csv")
 6psx = Prast(dtf, "data01_c.txt", "data01_i.txt", "cp932")
 7rng = ['newspaper', 'magazine', 'tv', 'ragio']
 8ename = "ma_select"  # 新設する選択個数の列ラベル(英字)
 9jname = u"選択個数"  # enameの別名(日本語)
10psx.ma_select(rng, value=1, name=ename)
11psx.put_columns("%s,%s" % (ename, jname))  # 列ラベルの別名を追加登録
12all = len(rng)  # 選択肢の総個数
13psx.pd(psx.gd()
14    .query("{ename} > 0 & {ename} < {all}".format(**locals()))
15)
16dod = psx.count(ename)  # 選択個数別の集計
17print(dod[jname])

 query() の引数のところで出てくる locals() は、
ローカル変数の名前と値の組を辞書として返してくれる関数です。

 "{ename} > 0 & {ename} < {all}".format(**locals()) というのは
結局、"ma_select > 0 & ma_select < 4" になります。

目次に戻る


2. 複数回答の選択肢を分解して集計

 複数回答の選択肢は、○をつけるかつけないかの2択を回答者に求めるもの
つまり、○, ×のどちらかで回答する1つの質問と解釈できます。

 そう考えるなら、下のような集計が可能です。

  新聞○ 新聞× 合計
20代 13 18 31
30代 24 10 34
40代 15 18 33
合計 52 46 98

 これは「年齢層」と「新聞」という単一回答同士のクロス集計です。

 なので、カイ2乗検定で有意性を検証できます。

 以下、このようなクロス集計と統計的検定を行う手順を記します。

(1) 行ラベルの別名定義と欠損値の置き換え

 これまで data01_c.txt, data01_i.txt に触れずにきました。

 列ラベルの別名定義(data01_c.txt)は下のとおり。

1group,年齢層
2newspaper,新聞
3magazine,雑誌
4tv,テレビ
5ragio,ラジオ

 また、行ラベルの別名定義(data01_i.txt)は次のとおり。

 1group
 220,20代
 330,30代
 440,40代
 5
 6newspaper
 71,新聞○
 80,新聞×
 9
10magazine
111,雑誌○
120,雑誌×
13
14tv
151,テレビ○
160,テレビ×
17
18ragio
191,ラジオ○
200,ラジオ×

 この定義を使うには、newspaperなどの複数回答の列において
欠損値を数値の 0 に置き換える必要があります。

rng = ['newspaper', 'magazine', 'tv', 'ragio']
psx.fillna(rng, 0)

 上の2行で欠損値を数値の 0 に置き換えることができます。

    

(2) 年齢層と各選択肢とのクロス集計

 「年齢層」と1つの選択肢(「新聞」など)のクロス集計は、
Prastクラスの table() で行えます。ma_table() ではないので注意。

 選択肢が新聞、雑誌、テレビ、ラジオの4つなので
4つのクロス集計表を作成し、それぞれをカイ2乗検定にかけて
有意な関連性が検証されるかどうかチェックします。

 以下にそのスクリプトを掲げます。

 1# ma_chisq.py (coding: cp932)
 2import pandas as pd
 3import prast as ps
 4ps.set_encoding("cp932")  # python2用にencodingを設定
 5
 6dtf = pd.read_csv("data01.csv")
 7psx = ps.Prast(dtf, "data01_c.txt", "data01_i.txt", "cp932")
 8rng = psx.crange(2, 5)  # 複数回答の列ラベル群
 9psx.fillna(rng, 0)  # 欠損値を数値0に
10for col in rng:  # 各選択肢ごとに処理
11    tod = psx.table("group", col)  # クロス集計
12    jtbl = ps.join_table(tod['tbl2'], tod['pct1'])
13    cod = ps.chisq_test(tod['tbl1'])  # カイ2乗検定
14    if cod['p_value'] < 0.05:  # 有意性あり
15        cod['name'] = tod['name']
16        print((u"◇ {name}: 有意な関連あり\n" + \
17            "chi2={chi2}, df={df}, p={p_value}").format(**cod))
18        print(u"*分割表")
19        print(jtbl)
20        print(u"*調整済み残差")
21        print(cod['stdres'])
22    else:  # 有意性なし
23        print(u"◇ %s: 互いに独立(p=%f)" % (tod['name'], cod['p_value']))
24        print(u"*分割表")
25        print(jtbl)
26    print('')

 実行してみると、「年齢層×新聞」では有意性が検出され、
他の組み合わせでは検出されません。

 30歳代は、新聞に○をつけた人が有意に多いことが確認できます。

 なお、これは乱数で生成したデータなので、実態とは関係ありません。

目次に戻る


3. 選択肢相互の関係をフィッシャー検定で検証

 新聞、雑誌、テレビ、ラジオの4つの選択肢から
2つを取り出す組み合わせは6通りあります。

 それぞれの組み合わせについてクロス集計をおこないます。

 その1つは次のとおり。

  雑誌○ 雑誌× 合計
新聞○ 28 23 51
新聞× 27 15 42
合計 55 38 93

 「合計」の列と行を取り除くと、2×2のクロス表です。

 なのでフィッシャーの直接確率検定にかけてみます。

(1) フィッシャーの直接確率検定

 pythonのscipyライブラリにフィッシャーの直接確率検定用のメソッドがあります。

import scipy.stats as stats
oddsratio, p_value = stats.fisher_exact(table, alternative='two-sided')

 第1引数の table は、2次元配列(または、それに類するもの)です。

 pythonの解説サイトには 2×2をあらわす配列を指定するよう書かれています。

 第2引数 alternative は対立仮設の指定で
two-sided, less, greater のいずれかを指定します。
デフォルトは two-sided (両側検定)

 戻り値は odds比、p値の2つ。

 一般に、p値が 0.05未満だと有意性が認められると判断します。

 odds比は、1に近いほど均一性が高い(差が少ない)ことになります。

 alternative は、通常、デフォルトの two-sided でいいとおもいますが、
「新聞を読む人は雑誌も読むケースが多いはず」という前提に立って
多いか否かを検証するなら片側検定の greater を指定します。

 両側検定は、多い|少ないの両方の可能性を視野に入れて
「違いがあるか否か」を検証します。

    

(2) サンプルスクリプト

 4つの選択肢のいずれにも○をつけていない人(4人)、および
4つ全部に○をつけている人(1人)は、
選択の特徴・傾向を検証できないのでここでは対象外とします。

 以下、スクリプトです。

 1# ma_fisher.py (coding: cp932)
 2import pandas as pd
 3import scipy.stats as stats
 4import prast as ps
 5ps.set_encoding("cp932")  # python2用にencodingを設定
 6
 7dtf = pd.read_csv("data01.csv")
 8psx = ps.Prast(dtf, "data01_c.txt", "data01_i.txt", "cp932")
 9rng = psx.crange(2, 5)  # 複数回答の列ラベル群(選択肢群)
10psx.fillna(rng, 0)  # 欠損値を数値0に
11all = len(rng)  # 選択肢の総個数
12psx.pd(psx.ma_select(rng)
13    .query("ma_select > 0 & ma_select < @all")
14)
15num = 0
16for i in range(all-1):
17    col1 = rng[i]
18    for j in range(i+1, all):
19        col2 = rng[j]
20        num = num + 1
21        tod = psx.table(col1, col2)
22        jtbl = ps.join_table(tod['tbl2'], tod['pct1'])
23        oddsratio, p_value = stats.fisher_exact(tod['tbl1'])
24        msg = u"有意差なし"
25        if p_value < 0.05:
26            msg = u"有意差あり"
27        print("#%d  %s: %s" % (num, tod['name'], msg))
28        print(u"odds比=%f, p値=%g" % (oddsratio, p_value))
29        print(jtbl)
30        print('')

 上記を実行してみると、6通りの組み合わせのうち有意性が検出されるのは
「テレビ/ラジオ」だけで、それに関する出力は下のとおり。

#6  テレビ/ラジオ: 有意差あり
odds比=0.141659, p値=4.2118e-05
  ラジオ○ ラジオ× 合計
テレビ○ 8(17.8) 37(82.2) 45(100.0)
テレビ× 29(60.4) 19(39.6) 48(100.0)
合計 37(39.8) 56(60.2) 93(100.0)

 テレビを見る人はラジオを聴かない傾向が顕著であることがわかります。

 ラジオを聴く人はテレビを見ない傾向にあることもうかがえます。

    

 odds(オッズ)は競馬等でよく耳にしますが、単純化していうと
「当たりの確率」÷「外れの確率」で算出するようです。

 pythonのfisher検定のodds比算出方法は、もっと複雑かもしれませんが……

 上記のクロス集計表に即していうと、下のような計算です。

 1a = 8.0 / 29.0  # 1列目を縦にたどる
 2b = 37.0 / 19.0  # 2列目
 3odds = a / b
 4print(odds)  # 0.14165890028
 5
 6x = 8.0 / 37.0  # 1行目を横にたどる
 7y = 29.0 / 19.0  # 2行目
 8odds = x / y
 9print(odds)  # 0.14165890028 (行と列を入れ替えて算出しても同じ)

 上の単純な計算での結果は、fisher_exact() のodds比とごく近い値です。

 というか、この場合は同じといっていいようにおもいます。

    

 複数回答の集計は、これで終了です。

 ここでは選択肢を分解して集計しましたが、
「前編」で作った集計表をコレスポンデンス分析などにかけた方が
全体像を見通しやすいかもしれません。

 ただ、分解方式は、単純な分 わかりやすいとはいえるとおもいます。

〜 以上 〜

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