【第8回】実験データの集約

# はじめに

本記事は，「PsychoPy Coderによる心理学実験作成チュートリアル」の第8回の記事です。[第7回](https://qiita.com/snishym/items/3c4b801c25bb98dd0d4e)で実験プログラムを完成させました。今回は，実験が終わった後の実験データの集約を簡単に紹介します。

このチュートリアルシリーズの目的・概要等が気になった方はこちらの[全体のまとめ](https://qiita.com/snishym/items/8b52db0d901cf5744463)をご一読ください。

番外編にしてもっとも盛りだくさんになってしまった気がします。

# 心理学実験データの分析

実験が終了すると，結果を分析する必要があります。ほとんど（すべて？）の実験心理学の研究では，結果を統計的検定にかけて，自分の研究仮説が支持されるかどうか検討します。

今回のチュートリアルの実験を実施すると，参加者の数だけ`参加者ID_simon.csv`という結果ファイルが出力されます[^2]。それでは，**個々のデータをどのようにして統計的検定を行える形に整えればいいでしょうか**。例えば，今回のサイモン課題は参加者内1要因2水準（一致，不一致）の実験デザインなので，正反応率や平均反応時間に対して対応のあるt検定を行うことになります[^3]。したがって，得られたデータファイルから条件ごとに正反応数の平均を算出したり，正反応だった試行の平均反応時間を計算する必要があります。場合によっては，極端に反応時間が短い（長い）データは除外したりするということも必要でしょう。これらのデータ処理を**参加者ごとに**行う必要があります。

[^2]: これはBuilderでも同じです。

[^3]: もっといい分析方法があるなどは本記事の趣旨ではないので，一旦，脇に置いていただけると幸いです。

csvファイルはエクセルで開いて個々のデータを確認・編集することができる（[第5回](https://qiita.com/snishym/items/f80607cbe462f8a4e1d4)）ので，エクセル上でポチポチ上記の指標を計算することができます。しかし，個人的にはおすすめしません。なぜなら，実際の実験では20人，30人の参加者がいるので，それらの作業をミスなくこなすのはあまりにもハードだからです。ミスが発覚してもその場所を発見することも困難です。

PsychoPy Coderで鍛えたプログラミングスキルをここで活かしましょう。本記事のメインの内容は以下の2つです。

- Pythonで参加者一人ひとりのデータを一つに集約する
- 各参加者の条件ごとの平均成績を算出して，新しいファイルとして保存する

統計的検定までは扱いません。単純に，私自身がPythonで統計的検定をしたことがないからです。また，各参加者の条件ごとの平均成績のデータが有れば，好みの解析ソフトでt検定や分散分析を実施することができるからです。あくまでも，**統計解析のためのデータ処理を手作業でしなくて済むようになることが本記事の目的です**。Pythonでの統計解析には様々な書籍が出版されていますので，それらをご覧ください。

# 実験データの集約

データの集約までの手順は以下のとおりです。

1. 個々のcsvファイルのパスを取得する
2. 個々のcsvファイルをデータフレーム（表みたいな）形式で読み込む
3. 縦方向に連結する

### ファイルパスの取得

ファイルを読み込むために，まず，全参加者のcsvファイルのパスを一気に取得します。実験参加者のファイルパスについては[第5回](https://qiita.com/snishym/items/f80607cbe462f8a4e1d4)を参照してください。ファイルの保存の際と同じく，`pathlib`パッケージを使用します。

```python:get_filepath.py
import pathlib

current_folder = pathlib.Path(__file__).parent # 実行しているプログラムのフォルダを取得
data_folder = current_folder/"data" # プログラムが保存されているフォルダ内にあるはずのdataフォルダのファイルパスを作成

datafiles = data_folder.glob("*_simon.csv") # dataフォルダ内にあってファイル名に"_simon.csv"を含むファイルのパスを取得

print(list(datafiles))
```

`data`フォルダ内に保存されている`..._simon.csv`という名前のファイルのリストが表示されます。実際のパスが`WindowsPath()`というもので括られていますが気にしないで大丈夫です。`data_folder = current_folder/"data"`までの処理は第5回で紹介しました。

最後から2つ目の処理`data_folder.glob("*_simon.csv")`について，`.glob()`は()内で指定したファイル名を，フォルダ内から探し，そのファイルパスを取得するということをします。注目したいのは，ファイル名を指定する際に，ワイルドカード`*`を使用できることです。`*`の部分にはあらゆる文字列が許容されます。したがって，`_simon.csv`を名前に含むファイルがフォルダ内で検索されます。

よく見ると，最終的に完成した実験ファイル（第7回では`simon_exp.py`）で生成された以外のファイルもこのリストに含まれているはずです。具体的には第5回で生成した`data_sample_simon.csv`や第6回で参加者名を反映したファイル名`{参加者ID}_simon.csv`です。ちなみに，第7回でも`{参加者ID}_simon.csv`でデータを出力しているようにしていました。実は**この状態は次以降の節で非常に問題ですので，一旦dataフォルダ内のデータを全部削除して，`simon_exp.py`を何度か実行し直して，dataフォルダ内が`simon_exp.py`で生成されたデータファイルだけになるようにしてください。**なぜ問題なのかについてはこの脚注[^4]をご覧ください。

[^4]: 第5, 6回で生成したデータと`simon_exp.py`で生成されるデータでは列の数が異なります。後で読み込んだデータをまとめて連結させるときに，この列数の不一致が予期せぬ挙動を生みます。

### ファイルの読み込み・連結

ファイルを表形式で読み込むためには`pandas`というモジュールを使用します。その中の`.read_csv()`という関数を使用します。

```python:import_datafile.py
import pathlib
import pandas as pd # pandasをpdという名前で利用

current_folder = pathlib.Path(__file__).parent
datafile = current_folder/"data"/"1_simon.csv" # "1_simon.csv"というデータファイルのパスを指定

df = pd.read_csv(datafile, encoding = "shift-jis") # ファイルの読み込み。encodingについては後述

print(df)
```

いい感じの表っぽいものが出力されたと思います。こういう形式のデータはデータフレーム(**d**ata **f**rame)と呼ばれます。そのため，読み込んだデータの名前には`df`とつけることが多いです。

さて，`pd.read_csv()`の機能は名前のとおり，csvファイルを（データフレーム形式で）読み込む(read)というものです。ややこしいのは，`encoding = "shift-jis"`です。これは，ファイルを読み込む際に`shift-jis`という文字コード形式で読み込むように指定しています。Macでは指定する必要がありません。`pd.read_csv(datafile)`で問題なく動作します。むしろ指定するとエラーになります。このあたりの理由については脚注[^5]を参照してください。

[^5]: 文字コードとは，1と0でしか処理していないコンピュータが人間の使う文字を処理できるように，「人間の文字 <--> １と0」の変換に使用される変換形式のことです。なぜかこの世には大量の文字コードが存在しており，保存するときと開くときで指定する文字コードが違うと，うまく人間の文字に変換できず，文字化けを起こします。実験データを保存する際に使用した標準のファイル操作`open()`は，(日本の)Windows上で実行されると，`shift-jis`が文字コードとして使用されます。一方で，`pd.read_csv`では指定しなければ`utf-8`という文字コードが使用されます。そのままではうまく文字を変換できずにエラーになるので，`encoding = "shift-jis"`と文字コードを指定します。Mac上で`open()`が実行されると`utf-8`でファイルが保存されます。そのため，`read_csv()`でわざわざ文字コードを指定する必要がありません。これらの理由で，Windowsで作成されたファイルをMacで開くと文字化けを起こすので（逆もしかり），喧嘩になることがあります。

ということで，複数のファイルをフォルダから拾ってきて読み込み，縦に連結します。

```python:gather_all.py
import pathlib
import pandas as pd

current_folder = pathlib.Path(__file__).parent
data_folder = current_folder/"data"

datafiles = data_folder.glob("*_simon.csv")

df_list = [] # データフレーム用の空のリストを作成
for datafile in datafiles:
    df = pd.read_csv(datafile, encoding = "shift-jis")
    df_list.append(df) # データフレームをリストに入れる

df_all = pd.concat(df_list) # リスト内のdfをすべて縦に連結

print(df_all)
```

`simon_exp.py`で生成した複数のファイルをまとめたファイルが出力されたはずです。ただし，データ数が多いと出力は途中省略されています。

上記のコードのポイントは以下の3つです。

- `.glob`で取得された複数のファイルのパス`datafiles`に対してfor文を使って，ファイルパスをひとつひとつ取り出して，`read_csv`で読み込みます。
- 読み込んだデータはあとで連結するように，予め用意しておいたリスト`df_list`に追加します（`.append()`）。
- 最後に`pd.concat(df_list)`でリスト内のデータフレームを縦に連結して一つのまとまったデータフレームを作成します。

`simon_exp.py`だけで生成されたcsvファイルになるようにdataフォルダを整理するように先程書きましたが，もし他の回のファイルが有ると，列の数が違うので，ここでエラーが生じます。

次節では`df_all`からそれぞれの指標の平均を出して，その結果をcsvファイルとして保存します。

# 参加者・条件ごとに平均を算出する

さっそくdf_allを使って平均正反応時間や正反応率を計算していきたいところですが，`simon_exp.py`が保存するファイルには各試行の条件（一致or不一致）や反応の正誤が含まれていません。しかし，すでに保存されているデータからこれらの値を導出することが可能です。`numpy.where()`という関数を使います。`numpy.where()`はデータフレームに対して使うif文のようなもので，`numpy.where(条件, 真の場合の値, 偽の場合の値)`と使えます。また，`df_all["データ（列名）"]`とすることで，ある指標のデータにアクセスできるようになります。もし指定した列名がまだないものであれば，新しく追加されます。

これらを組み合わせることで，条件の列を追加したり，すでにあるデータを変更したりすることができます。例えば，
`df_all["pos_converted"] = np.where(df_all["位置"] == -0.3, "L", "R")`
とすれば，`df_all`に新しく`pos_converted`という列を追加します。その行の値は，`位置`列の値が`-0.3`ならば`"L"`をそうでなければ（つまり0.3なら）`"R"`になります。行は各試行のことなので，ある試行で刺激が提示された（横方向の）位置が左（-0.3）か右（0.3）かをL，Rで表現し直していることになります。この変換は，直後に各試行の条件を導出する際の`numpy.where`の条件式を簡素にするためにしています。下記のコードでは同様に，集約のために必要な列の追加・変換をしています。コメントを参考に処理をイメージしてみてください。

```python:summarize_data.py
import pathlib
import pandas as pd
import numpy as np # numpyをnpという名前で利用

current_folder = pathlib.Path(__file__).parent
data_folder = current_folder/"data"

datafiles = data_folder.glob("*_simon.csv")

df_list = []
for datafile in datafiles:
    df = pd.read_csv(datafile, encoding = "shift-jis")
    df_list.append(df)

df_all = pd.concat(df_list)

# pos_convertedという列を作成し，位置が-0.3ならL，そうでないならRにする
df_all["pos_converted"] = np.where(df_all["位置"] == -0.3, "L", "R")

# conditionという列を作成し，刺激と位置が一致していればcong，そうでなければincongとする
df_all["condition"] = np.where(df_all["刺激"] == df_all["pos_converted"], "cong", "incong")

# 提示された刺激と反応キーが一致していた1(正答)とする（詳細は後述）
c1 = (df_all["刺激"] == "L") & (df_all["反応キー"] == "left")
c2 = (df_all["刺激"] == "R") & (df_all["反応キー"] == "right")
df_all["correct"] = np.where(c1|c2, 1, 0)

# 正反応なら反応時間をそのまま残し，誤反応なら欠損値np.nanに変換
df_all["反応時間"] = np.where(df_all["correct"] == 1, df_all["反応時間"], np.nan)

print(df_all)
```

`pos_converted`,`condition`,`correct`の3つの列が増えているのが確認できます。最後の誤反応の反応時間を欠損値に変換できたかについては`print(df_all.loc[df_all["反応時間"].isnull(), :])`で確認できます。もし誤反応がなければ何も出力されません。

1点補足です。反応の正誤を導出している`numpy.where`の処理について，処理自体の記述を見やすくするために条件式をその直前2行で`c1`，`c2`と定義して使っています。`c1|c2`の`|`は「または」(`OR`)の演算子です。

追加・変換の処理が終わったら，いよいよ平均正反応時間や正反応率を算出しています。`.loc[]`を使って必要なデータの行と列だけを取り出しています。`.loc[行,列名のリスト]`で抽出します。なお，`simon_exp.py`では練習課題の結果も保存しているので，行の部分に`df_all["フェイズ"] == "main"`と条件式を入れることで本番の試行にあたる行だけを取り出しています。あとは各行のコメントにある通りです。読みながら処理をイメージしてみてください。先程のコードの続きに書いて実行してください。

```python:summarize_data.py（続き）
# 参加者・条件ごとに，正反応率を算出する
df_summarized = (
    df_all
    # 本番のデータの，参加者ID，条件，反応時間，correct を取り出す
    .loc[df_all["フェイズ"] == "main", ["参加者ID", "condition", "反応時間", "correct"]]
    .groupby(["参加者ID", "condition"]) # 参加者IDと条件でグループ化する
    .mean() # 平均正反応時間，correctの平均（=正反応率）を算出
    .rename(columns = {"反応時間":"平均正反応時間", "correct":"正反応率"}) # 列名を変更
)

print(df_summarized)
```

参加者・条件ごとの平均正反応時間，正反応率に集約されました。

注目してほしいのは，処理を`()`でくくっている点です。こうすることで処理ごとに改行が可能となります。これがなければ，一連の処理を
`df_all.loc[df_all["フェイズ"] == "main", ["参加者ID", "condition", "反応時間", "correct"]].groupby(["参加者ID", "condition"]).mean().reset_index().merge().rename(columns = {"反応時間":"平均正反応時間", "correct":"正反応率"})`
と書く必要があります。この文の可読性はかなり低いです。`()`を上記コードのように使うことで防げます。また，各処理にコメントを付記することができます。

なお，`.mean()`は，デフォルトで欠損値が除外されて**データ列ごとに**平均が計算されるようになっています。実際の実験のデータ分析ではさまざまな欠損値除外の方法があるので，注意してください。

さて，本記事では，**統計ソフトにそのまま利用できるデータの作成を目指します**。先ほど出力された結果では，同じ参加者のデータが2行にわかれて出力されていたと思います。これは統計ソフトでの利用に向きません。統計ソフトでは，ある参加者のデータは1行にまとめられている必要があります[^6]。2行に分かれたデータを1行にまとめるために，`.unstack()`を使います。 `.unstack(level = 横向きにする列)`という使い方ができます。今回は参加者IDは縦向きに置いておいたまま，条件を横向きにしたいので，`.unstack(level = "condition")`とします。

[^6]: そうでないソフトもあるかもしれません。

```python:summarize_data.py（続き）
df_summarized = df_summarized.unstack(level = "condition")
print(df_summarized)
```

以下のような出力が得られるはずです。

```
           平均正反応時間        正反応率       
condition  cong      incong    cong  incong
参加者ID                                    
1          0.416993  0.446769  1.0   0.95
2          0.400285  0.444931  1.0   1.00
3          0.450427  0.498872  1.0   1.00
```

これで，各参加者のデータが1行になりました。とても見やすいのですが，このデータフレームは列名が2段になっています。統計ソフトで利用するためには，列を一段にする必要があります。`正反応率_cong`というように各段の列名を結合して，1段にしましょう。説明は後でするので，とりあえず，以下のコードを書き足して実行してみてください。

```python:summarize_data.py（続き）
df_summarized.columns = ["_".join(col) for col in df_summarized.columns] # 指標_条件という列名に変更

print(df_summarized)
```

以下のような出力が得られるはずです。

```
参加者ID  平均正反応時間_cong  平均正反応時間_incong  正反応率_cong  正反応率_incong
1        0.416993           0.446769            1.0           0.95
2        0.400285           0.444931            1.0           1.00
3        0.450427           0.498872            1.0           1.00
```

できてしまいましたね。これを出力すればOKですが，その前に，先のコードの説明をします。

`["_".join(col) for col in df_summarized.columns]`はリスト内包表記と呼ばれ，以下の処理と等価です。

```python
colnames = []
for col in df_summarized.columns:
    new_colname = "_".join(col)
    colnames.append(new_colname)
```

さらに，`for col in df_summarized.columns`とすると，`col`には1段目と2段目の列名の組み合わせが入ります。具体的には，

```
('平均正反応時間', 'cong')
('平均正反応時間', 'incong')
('正反応率', 'cong')
('正反応率', 'incong')
```

の4つです[^7]。この4つの組み合わせに対してforループで順に，`"_".join(col)`の処理を行います。これは，`()`内の要素を`_`でつなげて一つの文字列，例えば`'正反応率_cong'`を生成します。こうして生成された新しい列名のリストを`df_summarized.columns = [新しい列名のリスト]`とすることで元のデータフレームの列名を上書きしています。

[^7]: このように`()`で複数の要素が括られたデータ型をタプルと呼びます。リストの親戚で，ほぼ同様に扱うことができます。

ということで，統計ソフトでの使用にマッチするデータフレームを作ることができました。ついに，csvで保存するときが来ました。

# csv ファイルとして出力する

最後にデータフレームをcsvとして保存します。保存には`to_csv`を使用します。以下のコードの最終行にかかれています。なお，先ほどのデータフレームではせっかく収集した参加者データがなくなっていたので，以下のコードでは`df_all`から参加者情報だけを取り出した`df_subjInfo`というデータフレームを作成し，`df_summarized`と結合させています。

```python:summarize_data_to_csv.py
import pathlib
import pandas as pd
import numpy as np

current_folder = pathlib.Path(__file__).parent
data_folder = current_folder/"data"

datafiles = data_folder.glob("*_simon.csv")

df_list = []
for datafile in datafiles:
    df = pd.read_csv(datafile, encoding = "shift-jis")
    df_list.append(df)

df_all = pd.concat(df_list)

df_all["pos_converted"] = np.where(df_all["位置"] == -0.3, "L", "R")

df_all["condition"] = np.where(df_all["刺激"] == df_all["pos_converted"], "cong", "incong")

c1 = (df_all["刺激"] == "L") & (df_all["反応キー"] == "left")
c2 = (df_all["刺激"] == "R") & (df_all["反応キー"] == "right")
df_all["correct"] = np.where(c1|c2, 1, 0)

df_all["反応時間"] = np.where(df_all["correct"] == 1, df_all["反応時間"], np.nan)

# 参加者の情報だけを取り出す
df_subjInfo = (
    df_all
    .loc[:, ["参加者ID","年齢","性別"]] # 参加者ID，年齢，性別の列を取り出す
    .drop_duplicates() # データの重複をなくす
)

# 参加者・条件ごとに，正反応率を算出する
df_summarized = (
    df_all
    .loc[df_all["フェイズ"] == "main", ["参加者ID", "condition", "反応時間", "correct"]]
    .groupby(["参加者ID", "condition"])
    .mean()
    .rename(columns = {"反応時間":"平均正反応時間", "correct":"正反応率"})
    .unstack(level = "condition") # 条件で横に伸ばす
)

df_summarized.columns = ["_".join(col) for col in df_summarized.columns]

# df_subjInfoをdf_summarizedに結合
df_summarized = (
    df_summarized
    .reset_index() # グループ化を解除する
    .merge(df_subjInfo, on = "参加者ID") # 参加者IDで紐付けて結合する
)

new_filepath = current_folder/"all_data_simon.csv" # 保存先は練習用フォルダ
df_summarized.to_csv(new_filepath, index = False, encoding = "shift-jis") # csvとして保存
```

5点補足です。まず，`df_subjInfo`を作成する際に，該当の列を取り出したあと，`.drop_duplicates()`を使っています。これは，重複をなくして各参加者の情報が1行だけになるようにしています。もともとdf_allはすべての試行が1行ずつ並んだかなり縦長のデータフレームで，各試行の参加者情報は同じ参加者であればすべて同じ，つまり，重複しています。重複をなくさずに結合させると，保存されたデータは大変なことになります。

次に，`.unstack()`は`.rename()`のあとにまとめて書きました。

3つ目に，データフレームを結合するために，まず，`.reset_index()`をしています。これは，グループ化のために特殊な状態となっていた`参加者ID`列を通常の列に戻しています。その次の`.merge()`では，`on = "参加者ID"`として，参加者IDで紐付けて結合しています。

4つ目に，ファイルの保存先を練習用のフォルダにしています。これは，再度このコードを実行する際に，`.glob(*_simon.csv)`がまとめファイルを拾ってしまうことを防ぐためです。そもそも，コード例で`all_data_simon.csv`という名前で保存しているのが悪いのですが，個人的にはファイル名の末尾に実験名をつけておきたいので，例のようなファイル名にして，`data`フォルダ外に保存するようにしました。

最後に，`.to_csv`内に，`index = False`という指定があります。これは，pandasのデータフレームが自動的にふっている行番号（`index`）がcsvに保存されるのを防いでいます。`index = False`を指定しないと，順に番号が並んだだけで特に意味のない列がcsvファイルに保存されてしまいます。

# おわりに

今回は心理学実験で得られた個々のデータファイルを，統計的検定に向けてPythonで集約する方法を紹介しました。ここで紹介した処理の実行時間は，心理実験で扱うデータ量の範囲では，その量に左右されず，ものの数秒だと思います。そしてミスを見逃す可能性も下がり，ミスがあってもその発見・修正は容易になります。パッと身につくスキルではないですが，ご自身のデータでもこのコードを改変しながら進めれば，20人，30人のデータ処理を手作業でする時間で十分身につけられるはずです。そして一旦身につければ，次にデータ処理が必要になった機会には，手こずることなくスッと統計解析に進めます。ぜひ，がんばってください。

サイモン課題は刺激に対してキー反応をするだけの単純なものでしたが，一つの実験として完成させるには割と多くの知識が必要だということを勝手ながら実感しました。（少なくとも自分の周囲では）プログラミングに対する忌避感を持っている人が少なくないのも納得できます。実験に限らず研究活動においてプログラミングはかなり有用だと私は思っております。このシリーズを通してプログラミングに取り組む方が増えることを願っております。