Pandasでネストした多対多データのjsonファイルを扱う

ネストした多対多データのjsonファイルを扱う中で、行の対象を変えることがあります。
Pandasを用いてそれを行う方法を紹介します。

多対多のデータの例として、ファッションアイテムとコーディネートのデータを取り上げます。
[WEAR](https://wear.jp/)のようなコーディネート投稿サイトでは、1つのコーディネートに対し複数のアイテムが使われ、逆に、1つのアイテムが複数のコーデに使われることがあります。
なので、コーディネートとアイテムは多対多の関係にあります。


このようなデータにおいて、行の対象をコーディネートからアイテム、アイテムからコーディネートにする方法を紹介します。

具体的には、以下のような2つの形のDataFrameを行ったり来たりする変形を行います。

以下、行がコーディネートのDataFrame

<img width="407" alt="スクリーンショット 2018-12-10 14.34.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/154068/b32afaf9-b103-25be-e1f3-f6e54bcaf41d.png">


以下、行がアイテムのDataFrame

<img width="569" alt="スクリーンショット 2018-12-10 15.13.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/154068/53bdf436-3417-a639-c23e-96291c7149e1.png">


先にコードだけ載せます。

コーデ→アイテム

```python
import pandas as pd
from pandas import json_normalize

df_items = json_normalize(df.to_dict("records"),
               "items", "set_id")
               
df_items.sort_values("item_id")
```

アイテム→コーデ

```python
df_items.groupby("set_id")\
    [df_items.columns.difference(df.columns)]\
    .apply(lambda df_group: df_group.to_dict("records")) \
    .rename("items")\
    .reset_index()
```



## データの確認

jupyter notebook で実行していきます。
`sample.json` にデータがあるとし、読み込みます。
初めは、行がコーディネートの形だとします。

```python
import pandas as pd

json_file = "./sample.json"
df = pd.read_json(json_file)

df
```


<img width="407" alt="スクリーンショット 2018-12-10 14.34.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/154068/1b477ac6-639c-f055-5a84-61fafe9dda06.png">



`items` カラムの各行には、そのコーデで使っているアイテムのリストが入っています。
アイテムは辞書で表現されているので、辞書のリストが各行に入っています。

具体的に、 `items` カラムの1行目を見てみます。
このコーデは5つのアイテムを使っていることがわかります。


```python
df["items"].iloc[1]
```

```json
\[{'index': 1,
  'name': '黒のテーラードジャケット',
  'price': 2735.0,
  'likes': 1864,
  'image': 'http://example.com/06.jpg',
  'categoryid': 3},
 {'index': 2,
  'name': '赤のセーター',
  'price': 8.69,
  'likes': 416,
  'image': 'http://example.com/07.jpg',
  'categoryid': 62},
 {'index': 3,
  'name': '白シャツ',
  'price': 1794.0,
  'likes': 156,
  'image': 'http://example.com/03.jpg',
  'categoryid': 35},
 {'index': 4,
  'name': '黒スキニー',
  'price': -1,
  'likes': 21,
  'image': 'http://example.com/08.jpg',
  'categoryid': 4240},
 {'index': 5,
  'name': '○ld sk○○l',
  'price': 28.0,
  'likes': 417,
  'image': 'http://example.com/08.jpg',
  'categoryid': 3336}\]
```


## 行をコーディネートからアイテムにする

上記のような **辞書のリスト** のカラムに対し、リストの各要素であるアイテム（辞書）を行とするDataFrameを作るには `pandas.io.json.json_normalize` を用います。

```python
from pandas.io.json import json_normalize

df_items = json_normalize(df.to_dict("records"),
               "items", "set_id")
               
df_items.sort_values("item_id")
```

上記のコードを実行すると、以下のように行がアイテムのDataFrameを得られます。


<img width="569" alt="スクリーンショット 2018-12-10 15.13.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/154068/d6404c00-cd34-d109-8c53-87346166e684.png">


上記のコードの詳細を追っていきます。


### .to_dict("records")

```python
json_normalize(df.to_dict("records"),
               "items", "set_id")
```

`df.to_dict("records")` はDataFrameを辞書（のリスト）にするのですが、方法がいくつかあり、`orient` という引数で指定します。
`orient="records"` では以下のように、各行を辞書としたリストが作成されます。各辞書のキーは「カラム名」、バリューは「その行、列での値」になります。


```json
[
    {
        "items": [
            {
                "item_id": 10,
                "name": "モッズコート",
                "price": 135.0,
                "likes": 7,
                "image": "http://example.com/10.jpg",
                "categoryid": 4
            },
            {
                "item_id": 11,
                "name": "黒のタートルネック",
                "price": 845.0,
                "likes": 1996,
                "image": "http://example.com/11.jpg",
                "categoryid": 46
            },
            {
                "item_id": 8,
                "name": "黒スキニー",
                "price": -1,
                "likes": 21,
                "image": "http://example.com/08.jpg",
                "categoryid": 4240
            },
            {
                "item_id": 12,
                "name": "Dr.マーチソ（8ホール）",
                "price": 62.0,
                "likes": 1,
                "image": "http://example.com/12.jpg",
                "categoryid": 106
            }
        ],
        "likes": 408,
        "name": "寒い冬も怖くない、ドラゲナイコーデ",
        "set_id": 202407512,
        "views": 2554
    },
    ...
]
```

### json.json_normalize

```python
json_normalize(df.to_dict("records"),
               "items", "set_id")
```

- `data=df.to_dict("records")`：変形したいDataFrameを辞書のリストにしたもの
- `record_path="items"`：要素を展開したい **辞書のリスト** のカラム名
- `meta="set_id"`：展開後も残しておきたい元のDataFrameのカラム名

アイテムの辞書に `set_id` は含まれていません。
もし、 `meta` 引数がなければ、変形後に `set_id` は持ち越されません。


また、 持ち越したいカラムが複数あれば、`meta` 引数をカラム名の **リストで指定する** こともできます。


上記以外にも引数があり、より複雑な整形ができます。
詳しくは[公式ドキュメント](https://pandas.pydata.org/pandas-docs/version/0.22/generated/pandas.io.json.json_normalize.html)で確認できます。


## 行をアイテムからコーディネートに戻す

今度は逆に、行をアイテムからコーディネートに戻します。

```python
df_new = df_items.groupby("set_id")\
    [df_items.columns.difference(df.columns)]\
    .apply(lambda df_group: df_group.to_dict("records")) \
    .rename("items")\
    .reset_index()

df_new
```

上記のコードを実行すると、以下のように行がコーディネートのDataFrameに戻すことができます。
ただし、 `df_items` 作成時に落としたカラムは戻せません。


<img width="163" alt="スクリーンショット 2018-12-10 15.25.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/154068/7c13390a-0fac-a05d-2bdc-4e5cf2731443.png">


上記のコードの詳細を追っていきます。


### .groupby("set_id")

```python
df_items.groupby("set_id")\
    [df_items.columns.difference(df.columns)]\
    .apply(lambda df_group: df_group.to_dict("records")) \
    .rename("items")\
    .reset_index()
```


`groupby` を行うと、引数で指定したカラムが同じ値を持つ行どうしをグループとしてまとめます。
結果として、 **DataFrameを要素に持つSeriesのようなもの** ができます。
各要素のDataFrameはグループを表し、指定したカラムは同じ値で統一されています。

今回は、コーデのidである `set_id` が同じアイテムのグループが作られます。


### [df_items.columns.difference(df.columns)]

```python
df_items.groupby("set_id")\
    [df_items.columns.difference(df.columns)]\
    .apply(lambda df_group: df_group.to_dict("records")) \
    .rename("items")\
    .reset_index()
```


この記述では、変形後のアイテムの辞書に `set_id` が入り込まないようにしています。

`groupby` の後も、DataFrameと同様に `[[カラム名]]` で使いたいカラムに絞ることができます。

`DataFrame.columns` には `difference` という演算が備わっており、 `df1.columns.difference(df2.columns)` とすることで、 `df1` のうち `df2` に含まれないカラム名を指定することができます。

今、 `df_items` のうち、 `df` と共有しているカラムは含めたくないので、 `df_items.columns.difference.(df.columns)` としています。
こうすることで、 `json_normalize` で `meta` を複数指定した場合にも対応できます。


### .apply(lambda df_group: df_group.to_dict("records"))

```python
df_items.groupby("set_id")\
    [df_items.columns.difference(df.columns)]\
    .apply(lambda df_group: df_group.to_dict("records")) \
    .rename("items")\
    .reset_index()
```


Series や DataFrame 同様、 `groupby` の結果にも `apply` を施すことができます。
`apply` で指定した関数の引数は、 各グループを表すDataFrameになります。

今回の例だと、同じコーデでまとめたアイテムを行とするDataFrameです。
これを最初の形と同じく辞書にして、ほぼ完成です。

ただ、このままだと、コーデのid `set_id` を index に持つSeriesができるだけです。
元の形にするには、DataFrameにします。


### .rename("items").reset_index()

```python
df_items.groupby("set_id")\
    [df_items.columns.difference(df.columns)]\
    .apply(lambda df_group: df_group.to_dict("records")) \
    .rename("items")\
    .reset_index()
```


`reset_index` は index を振り直す以外に、**Series を DataFrameにする** ことにも使えます。
元の値とindexをカラムに持つDataFrameができます。
また、単純に振り直す場合は、 `reset_index(drop=True)` を使います。

ただ、これだけだと、できあがったDataFrameのカラムが `[["set_id", 0]]` となってしまいます。
なので、DataFrameにする前にSeriesの名前を `rename` で `"items"` にしておくことで、 `[["set_id", "items"]]` とすることができます。


## まとめ

Pandasを用いて、ネストした多対多データのjsonファイルを読み込んだDataFrameの行の対象を変える方法を紹介しました。

例としてファッションのデータを使い、行をコーディネート→アイテム→コーディネートと変形するコードを記載しました。

コーディネート→アイテムの場合は `json_normalize` 、 アイテム→コーディネートの場合は `groupby` と `apply(lambda df:df.to_dict("records"))` がキーポイントでした。

それでは皆さん、良いお年を！