Polarsのリスト型に対するよくある操作

Polarsにはリスト型があります。
一見ややこしい処理も、Polarsならではの直感的な操作が使えて便利です。

最近使うことが多かったので、ややニッチかとは思いますが備忘がてら紹介します。



# 準備
```
!wget https://jaqket.s3.ap-northeast-1.amazonaws.com/data/aio_01/dev2_questions.json
```

```python
import polars as pl
pl.Config(fmt_str_lengths=50)
df = pl.read_ndjson('/content/dev2_questions.json')
```

今回は日本語のクイズのデータを使って紹介します[^data][^ref]。
クイズの択問題の選択肢がリスト列として格納されています。

[^data]: JAQKET: クイズを題材にした日本語QAデータセット。https://www.nlp.ecei.tohoku.ac.jp/projects/jaqket/
[^ref]: 鈴木正敏, 鈴木潤, 松田耕史, ⻄田京介, 井之上直也. “JAQKET:クイズを題材にした日本語QAデータセットの構築”. 言語処理学会第26回年次大会(NLP2020) 発表論文集


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/25c613b1-3d0b-0af3-4b6b-ec0e870558e8.png)


# 基本的なlist操作
## 要素数

```python
df.select(
    pl.col('answer_candidates'),
    # リストの要素数（選択肢の数）
    pl.col('answer_candidates').list.len().alias('answer_candidates_len')
).head(2)
```
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/361c4082-326b-395d-262f-334f2e70cc63.png">

20択問題とは、、選択肢が多いですね笑


## 文字列に結合

```python
df.select(
    pl.col('answer_candidates').list.join('/')
).head(2)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/da8bce00-666f-81f2-7756-0dc5e21caf80.png)


## 縦に展開

```python
df.select('question','answer_candidates').explode('answer_candidates').head(3)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/3faa692b-466f-a696-5136-cd8e34932b68.png)


本記事で紹介する操作のなかだと、これだけはデータフレームのshapeが変わる操作です。

あとexplodeの標準的な日本語が分かりません。



# 要素の抽出
いくつかやり方があるので、それぞれ記載します。

## n番目の要素を抽出

```python
df.select(
    pl.col('answer_candidates').list.get(0).alias('get'),
    pl.col('answer_candidates').list.first().alias('first'),
    pl.col('answer_candidates').list[0].alias('slice'),
).head(2)
```
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/fbb68ef2-c87b-81b8-e8e9-644dcaf061b9.png">


## 部分リストを抽出

```python
df.select(
    # 先頭から抽出
    pl.col('answer_candidates').list.head(3).alias('head'),
    # 任意の箇所から抽出
    pl.col('answer_candidates').list.slice(1,3).alias('slice'),
).head(2)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/d20ea561-62f2-e7a0-7f70-c430a1fc86c6.png)

`slice`は、start_indexと取得する長さを指定します。

なお、特定の条件に該当する要素だけを抽出する方法に関しては、次節で解説しています。


# 各要素に対する処理

```python
df.select(
    # ある要素を含むか
    pl.col('answer_candidates').list.contains('タコブネ').alias('タコブネ'),
    pl.col('answer_candidates').list.contains('タコブ').alias('タコブ'),
    # 各要素について、ある文字列（タコブ）を含むか
    pl.col('answer_candidates').list.eval(pl.element().str.contains('タコブ')).alias('タコブ_each'),
    # ある文字列（タコブ）を含む要素が一つでも存在するか
    pl.col('answer_candidates').list.eval(pl.element().str.contains('タコブ')).list.any().alias('タコブ_any'),
).head(2)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/e855589b-2ccf-8af0-3b02-16f79d187d91.png)

`eval` と `pl.element()` を組み合わせて使うことで、それぞれの要素に対する処理をほぼなんでも実現できます。

、、、ここまでくるとタコブネがどんな生き物なのか気になってきますよね。こいつらしいです[^wiki]。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/ad51cb97-da19-f830-14ea-f814bcdb3f5b.png)

[^wiki]: 画像はWikipediaより引用。https://ja.wikipedia.org/wiki/%E3%82%BF%E3%82%B3%E3%83%96%E3%83%8D


## 該当する要素だけを抽出

```python
df.select(
    # ひらがなを含む選択肢があるか
    pl.col('answer_candidates').list.eval(
        pl.element().str.contains('[あ-ん]')
    ).list.any().alias('ひらがな'),
    # ひらがなを含む選択肢を抽出
    pl.col('answer_candidates').list.eval(
        pl.element().filter(pl.element().str.contains('[あ-ん]'))
    )
).head(5)
```
<img width="350" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/d9dfda5c-1666-c6cc-c1b9-1c8a6d134890.png">

普通のExpressionと似た感じで `filter` が使えます。


# ネストされたリストに対する操作
少し複雑な処理として、リストの要素がリストである場合の操作も同じ考え方で実現できます[^nest]。

[^nest]:複雑なので、このやりかたが良いかは微妙なところです。さっさとexplodeして処理するといった方法もきっとあります。

特に意味を持つ例ではないですが、

- 清浄の園 → [清浄、園]
- 宇宙の終焉 → [宇宙、終焉]

みたいに「の」で分割したものを対象に操作してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/8a78a585-761b-fa61-2bfb-141d895589d0.png)

```python
df.with_columns(
    # 「の」でリストの各要素をリストに分解
    pl.col('answer_candidates').list.eval(
        pl.element().str.split('の')
    )
).select(
    pl.col('question'),
    # 要素数が２以上で、２つ目の要素が一文字の選択肢のみ抽出
    # 「〇〇の✕」に相当する選択肢
    pl.col('answer_candidates').list.eval(
        pl.element().filter(
            (pl.element().list.len() > 1) & 
            (pl.element().list.get(1).str.len_chars() == 1)
        )
    )
).slice(40, 5)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/8c1fff1e-9fbd-0472-a4b5-769eeee8d5d8.png)

この処理自体は特段listを使わなくても実現できるものですが、このように
`pl.element().list` …と書くことで、ネストされたリストに対しても同じ考え方で処理を書き続けることが可能になります。


----

ということで、いかがだったでしょうか。

`filter` や `when` など、通常のExpressionと似た感覚で操作でき、慣れるハードルは低そうです。Polars最高！！

----

# 参考
今回はリストの中身が文字列のケースを見ていましたが、 `diff` や `mean` など数値に対する処理も豊富にあります。そのあたり詳細はドキュメントをぜひ。

https://docs.pola.rs/py-polars/html/reference/expressions/list.html

https://docs.pola.rs/user-guide/expressions/lists/