pandasでfor文を使わない日本語テキスト前処理


pandasで、テキストに対して処理する時、「よくわからないからfor文使うか」とならないための備忘録。

日本語テキストの前処理を目的として、情報をまとめる。


もっと良い処理方法があれば教えていただけると幸いです。


実行環境
- macOS Catalina
- Python 3.7.4
> pandas             0.25.3


# TL;DR

- 簡単な処理は `df["カラム名"].str` のメソッドに実装されている
- pandasに実装されていない処理をしたい場合 `df["カラム名"].apply()` 


# サンプルデータ

HPからスクレイピングしてきたレディースのファッションブランドの店舗情報。
csvには企業名・ブランド名・店名・住所が保存されている。

複数のHPからスクレイピングしているため、半角全角だったり、空白など統一されていない。郵便番号が含まれていたりいなかったりもする。

下記の表はデータの一例。
このデータを例にして実行結果を併記する。



| company   | brand            | location                                | address                                                      |
|-----------|------------------|-----------------------------------------|--------------------------------------------------------------|
| pal       | BONbazaar        | ボンバザール 東京ドームシティラクーア店 | 東京都文京区春日1丁目1-1 ラクーアビル 2F                     |
| world     | index            | エミオスタイル                          | 東京都新宿区高田馬場1-35-3　エミオスタイル　1F               |
| pal       | Whim Gazette     | 丸の内店                                | 〒100-6390 東京都千代田区丸の内2-4-1 丸の内ﾋﾞﾙﾃﾞｨﾝｸﾞB1F      |
| stripe    | SEVENDAYS=SUNDAY | イオンモール浦和美園 2F                 | 埼玉県さいたま市緑区美園5丁目50番地1 イオンモール浦和美園    |
| pal       | mystic           | 船橋店                                  | 〒273-0012千葉県船橋市浜町2-1-1 ららぽｰとTOKYO-BAYららぽｰと3 |
| pal       | pual ce cin      | 大船ルミネウイング店                    | 〒247-0056 神奈川県鎌倉市大船1-4-1 ルミネウイング4F          |
| stripe    | Green Parks      | sara シャポー小岩店                     | 東京都江戸川区南小岩7-24-15シャポー小岩 1F                   |
| pal       | Discoat          | Discoat Petit 池袋ショッピングパーク    | 〒171-8532東京都豊島区南池袋1-29-1池袋SP B1F                 |
| adastoria | niko and...      | アトレ川越                              | 埼玉県川越市脇田町 105 アトレ川越4F                          |
| pal       | CIAOPANIC TYPY   | 亀有店                                  | 〒125-0061東京都葛飾区亀有3-49-3アリオ亀有2F                 |


ほぼ 1つのカラム (`address` カラム) しか説明に使わないため、紹介するのはほぼSeriesの処理だが、DataFrameでの処理も出来る限り併記する。


# 処理

基本的には、`Series.str` で、[pythonの文字列メソッド](https://docs.python.org/ja/3/library/stdtypes.html#string-methods)や[正規表現操作](https://docs.python.org/ja/3/library/re.html)を呼び出せるので、これらを使えば良い。

- よく使うものだけ抜粋している
- 全てのメソッドを確認したい場合は公式ドキュメントを参考のこと。
	- 参考: [Series.strの検索結果](https://pandas.pydata.org/pandas-docs/stable/search.html?q=Series.str)
	- [Working with text data — pandas 1.0.1 documentation](https://pandas.pydata.org/pandas-docs/stable/user_guide/text.html)



## 文字列メソッド系

#### strip

[Series.str.strip](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.strip.html)

文字列の先頭・末尾の空白文字を削除する。

```python
df['strip']=df['address'].str.strip()
```

もちろん、先頭だけ削除する `Series.str.rstrip` と末尾だけ削除する`lstrip` も実装されている。


#### split, rsplit

[Series.str.split](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.split.html)

- 文字列を指定したセパレータで分割して *リスト* で返す
- `expand=True` にすると、複数のカラムに分割できる
- 一番splitされたカラムに数を合わせるので注意
	- 下の例の場合、3つのカラムにわかれているが、3つに分割できないテキストの場合は `None` が挿入されている


```python
df['address'].str.split(expand=True)
```

| 0                                            | 1                            | 2                  |
|----------------------------------------------|------------------------------|--------------------|
| 東京都文京区春日1丁目1-1                     | ラクーアビル                 | 2F                 |
| 東京都新宿区高田馬場1-35-3                   | エミオスタイル               | 1F                 |
| 〒100-6390                                   | 東京都千代田区丸の内2-4-1    | 丸の内ﾋﾞﾙﾃﾞｨﾝｸﾞB1F |
| 埼玉県さいたま市緑区美園5丁目50番地1         | イオンモール浦和美園         | None               |
| 〒273-0012千葉県船橋市浜町2-1-1              | ららぽｰとTOKYO-BAYららぽｰと3 | None               |
| 〒247-0056                                   | 神奈川県鎌倉市大船1-4-1      | ルミネウイング4F   |
| 東京都江戸川区南小岩7-24-15シャポー小岩      | 1F                           | None               |
| 〒171-8532東京都豊島区南池袋1-29-1池袋SP     | B1F                          | None               |
| 埼玉県川越市脇田町                           | 105                          | アトレ川越4F       |
| 〒125-0061東京都葛飾区亀有3-49-3アリオ亀有2F | None                         | None               |


-  `rsplit`
	- 右側から分割する
	- `n` を指定することで、分割の数を調整できる
	- 例えば `n=1` にすれば、1回しか分割しないため、カラム数は2つで固定になる


```python
df['address'].str.rsplit(expand=True, n=1)
```

| 0                                            | 1                            |
|----------------------------------------------|------------------------------|
| 東京都文京区春日1丁目1-1 ラクーアビル        | 2F                           |
| 東京都新宿区高田馬場1-35-3　エミオスタイル   | 1F                           |
| 〒100-6390 東京都千代田区丸の内2-4-1         | 丸の内ﾋﾞﾙﾃﾞｨﾝｸﾞB1F           |
| 埼玉県さいたま市緑区美園5丁目50番地1         | イオンモール浦和美園         |
| 〒273-0012千葉県船橋市浜町2-1-1              | ららぽｰとTOKYO-BAYららぽｰと3 |
| 〒247-0056 神奈川県鎌倉市大船1-4-1           | ルミネウイング4F             |
| 東京都江戸川区南小岩7-24-15シャポー小岩      | 1F                           |
| 〒171-8532東京都豊島区南池袋1-29-1池袋SP     | B1F                          |
| 埼玉県川越市脇田町 105                       | アトレ川越4F                 |
| 〒125-0061東京都葛飾区亀有3-49-3アリオ亀有2F | None                         |


#### find

[Series.str.find](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.find.html)

- `str.find` と同じ機能
- 含まれている場合は文字列の位置 、含まれていない場合は **-1** を返す

```python
df['address'].str.find('東京都')
>> 0    0
1    0
2    10
3    -1
4    -1
5    -1
6    0
7    9
8    -1
9    9
```

- 返り値がbool値ではないため、以下のような使い方でデータの絞り込みはでき **ない**
	- `df[df['address'].str.find('東京都')]` 
	- `df.query('address.str.find("東京都")')`
- `hoge in hogehoge` のように、文字列が含まれているかどうかだけ判定するのであれば、 `contains` を使った方がよい

```python
# 一応"東京都"が含まれていないカラムを指定することも可能
df.query('address.str.find("東京都")!=-1')
```


#### normalize
[Series.str.normalize](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.normalize.html)

- 文字の正規化
	- [`unicodedata.normalize`](https://docs.python.org/ja/3/library/unicodedata.html#unicodedata.normalize) と等価

主には全角数字・記号を半角に変換し、半角カタカナは全角に変換する。formには'NFKC' (正規形 KC) を指定する。

```python
# string
import unicodedata
unicodedata.normalize('NFKC', '１２３！？＠＃ﾊﾝｶｸｶﾀｶﾅ')
>> '123!?@#ハンカクカタカナ '

# pandas
df['normalize'] = df['address'].str.normalize(form='NFKC')
```


参考:  [Pythonのunicodedata.normalize('NFKC', x)で正規化される文字一覧](https://expectorate.hatenadiary.org/entry/20131230/1388433282) 


*日本語テキストの場合、neologdnを使った方が楽(後述)*


## 正規表現系


#### findall

[Series.str.findall](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.findall.html)

- `re.findall()` と等価
- `str.find()` とは異なり、こっちは正規表現が使える
- 一致した単語全てを返す

```python
df['address'].str.findall('(.{2}区)')
>> 0    [文京区]
1    [新宿区]
2    [代田区]
3    [市緑区]
4       []
5       []
6    [戸川区]
7    [豊島区]
8       []
9    [葛飾区]
```


#### contains

[Series.str.contains](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.contains.html)

- contain **s** なので注意
- 機能的には `re.match()`
	- bool値が返ってくるため、データの絞り込みに利用できる

```python
df['address'].str.contains('.{2}区')
>> 0     True
1     True
2     True
3     True
4    False
5    False
6     True
7     True
8    False
9     True

#  "○○区" を含むデータだけ表示
df.query('address.str.contains(".{2}区")')['address']
>> 0                     東京都文京区春日1丁目1-1 ラクーアビル 2F
1                  東京都新宿区高田馬場1-35-3　エミオスタイル　1F
2    〒100-6390 東京都千代田区丸の内2-4-1 丸の内ﾋﾞﾙﾃﾞｨﾝｸﾞB1F
3              埼玉県さいたま市緑区美園5丁目50番地1 イオンモール浦和美園
6                   東京都江戸川区南小岩7-24-15シャポー小岩 1F
7             〒171-8532東京都豊島区南池袋1-29-1池袋SP B1F
9               〒125-0061東京都葛飾区亀有3-49-3アリオ亀有2F
```

##### 文字列を含む列にフラグを立てる

bool値をint値に変換すれば、ある文字列を含むデータに `1` を立てたカラムを作成できる。
特徴量を作成する、特徴量エンジニアリング時に便利。

```python
# 東京都を含むデータフラグ tokyo_flgを作成
df['tokyo_flg'] = df['address'].str.contains("東京都").astype(int)
df['tokyo_flg']
>> 0    1
1    1
2    1
3    0
4    0
5    0
6    1
7    1
8    0
9    1
```

#### extract
[Series.str.extract](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.extract.html)

- matchしたパターンを返す。
	- パターンがない場合 `None` を返すので、必要ない場合は `dropna()` する
- 名前付きグループ名を設定すれば、その名前がそのままカラム名になる
	- 名前付きグループ名とは `(?P<name>...)` のこと

```python
df['address'].str.extract('(東京都|神奈川県)([^区市]+[区市])').dropna()

df['address'].str.extract('(?P<pref>東京都|神奈川県)(?P<city>[^区市]+[区市])').dropna()
```

|  | pref     | city     |
|---|----------|----------|
| 0 | 東京都   | 文京区   |
| 1 | 東京都   | 新宿区   |
| 2 | 東京都   | 千代田区 |
| 5 | 神奈川県 | 鎌倉市   |
| 6 | 東京都   | 江戸川区 |
| 7 | 東京都   | 豊島区   |
| 9 | 東京都   | 葛飾区   |

東京都○○区と神奈川県○○市のテーブルを作成できる。

#### replace

[Series.str.replace](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.replace.html)

- `re.sub()` と等価
	- `Series.str.replace(pat, repl)` で`pat` にマッチする文字列を `repl` に変換する
- ルールベースで余分な文字列を削除したいときによく使う

```python
# 郵便番号を削除する
df['address'] = df['address'].str.replace("〒[0-9]{3}\-[0-9]{4}", "")
```

ちなみに、[Series.replace](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.replace.html)は`Series.str.replace` と異なり、辞書形式で渡すことができる



## pandasにない処理をする

自作関数や、パッケージの関数 (neologdn, mecabなど) を使いたい場合

[Series.apply](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.apply.html) を使う。
`Series.apply(func)` のように関数を渡すことで、Seriesのデータに対してその関数の処理を実行できる。lambda関数も渡せる。


```python
# テキストの最初に '住所 ' を挿入
df['address'].apply(lambda x:  '住所 ' + x)
```

以降、実際のテキスト前処理についてまとめる。

### neologdn (テキスト前処理)

- https://github.com/ikegami-yukino/neologdn 

>  neologdn           0.4

標準ライブラリの normalize だけでは処理しきれない、長音やチルダなども正規化できる日本語テキストの正規化パッケージ。

mecabで解析する前はもちろん、
正規表現で文字列を取得する前に使うと、書くべき正規表現も簡潔になるので、日本語テキストに対して最初に実行すると良い。


```python
import neologdn
df['neologdn'] = df['address'].apply(neologdn.normalize)

# lambda関数を使えばDataFrame.applyでもできる 
df['neologdn'] = df.apply(lambda x: neologdn.normalize(x['address']), axis=1)
```

### 分かち書き

#### mecab-python3 

- https://github.com/SamuraiT/mecab-python3 

> mecab-python3      0.996.3 

分かち書き結果だけ欲しい場合は、 `-Owakati` を指定する。

```python
import MeCab

# `-d` で辞書のパスを指定
tagger = MeCab.Tagger('-Owakati -d /usr/local/lib/mecab/dic/ipadic/')
df['neologdn'].apply(tagger.parse)
```

これだと最後の改行 `\n` もついてくるので、取り除きたい場合は、自作関数を定義したり、lambda文で対応する。

```python
tagger = MeCab.Tagger('-Owakati -d /usr/local/lib/mecab/dic/ipadic/')

# 関数を定義
def my_parser(text):
    res = tagger.parse(text)
    return res.strip()

df['neologdn'].apply(my_parser)


# lambda 関数を使えば関数を宣言する必要はない
df['neologdn'].apply(lambda x : tagger.parse(x).strip())
>> 0                  東京 都 文京 区 春日 1 丁目 1 - 1 ラクーアビル 2 F
1               東京 都 新宿 区 高田馬場 1 - 35 - 3 エミオスタイル 1 F
2           東京 都 千代田 区 丸の内 2 - 4 - 1 丸の内 ビルディング B 1 F
3       埼玉 県 さいたま 市 緑 区 美園 5 丁目 50 番地 1 イオン モール 浦和 美園
4    千葉 県 船橋 市 浜町 2 - 1 - 1 ららぽーと TOKYO - BAY ららぽーと 3
5                 神奈川 県 鎌倉 市 大船 1 - 4 - 1 ルミネウイング 4 F
6              東京 都 江戸川 区 南小岩 7 - 24 - 15 シャポー 小岩 1 F
7                東京 都 豊島 区 南池袋 1 - 29 - 1 池袋 SP B 1 F
8                       埼玉 県 川越 市 脇田 町 105 アトレ 川越 4 F
9                  東京 都 葛飾 区 亀有 3 - 49 - 3 アリオ 亀有 2 F
```


#### Sudachipy

- https://github.com/WorksApplications/SudachiPy 

>  SudachiDict-core   20190718  
>  SudachiPy          0.4.2     


SudachiPyで、上記mecabのような分かち書き結果を取得したい場合、解析結果のオブジェクトから表層だけを返すような関数をつくる必要がある。


```python
from sudachipy import tokenizer
from sudachipy import dictionary


tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C

def sudachi_tokenize(text):
    res = tokenizer_obj.tokenize(text, mode)
    return ' '.join([m.surface() for m in res])

df['address'].apply(sudachi_tokenize)
>> 0                  東京都文京区春日 1 丁目 1 - 1 ラクーアビル 2 F
1               東京都新宿区高田馬場 1 - 35 - 3 エミオスタイル 1 F
2           東京都千代田区丸の内 2 - 4 - 1 丸の内 ビルディング B 1 F
3          埼玉県さいたま市緑区美園 5 丁目 50 番地 1 イオンモール 浦和 美園
4    千葉県船橋市浜町 2 - 1 - 1 ららぽーと TOKYO - BAY ららぽーと 3
5                神奈川県鎌倉市大船 1 - 4 - 1 ルミネ ウイング 4 F
6              東京都江戸川区南小岩 7 - 24 - 15 シャポー 小岩 1 F
7              東京都豊島区南池袋 1 - 29 - 1 池袋 SP   B 1 F
8                        埼玉県川越市脇田町 105 アトレ 川越 4 F
9                  東京都葛飾区亀有 3 - 49 - 3 アリオ 亀有 2 F
```

ちなみにmecabのipadicとは異なり、Sudachiの`SplitMode.C` だと住所をひとまとめにする様子。
(都道府県+市区町村を固有表現として扱っている？)

##### mode を引数で渡す

上記結果以外にも、
Sudachiは分割単位が3つあるので ([分割モード](https://github.com/WorksApplications/Sudachi#the-modes-of-splitting))、モードを指定できるように先ほどの関数 `sudachi_tokenize` をカスタマイズしてみる。

Series.applyは可変長引数を渡すことができるので、関数側に引数を追加し、apply側で引数を指定すればよい。

```python
def sudachi_tokenize_with_mode(text, mode):
    res = tokenizer_obj.tokenize(text, mode)
    return ' '.join([m.surface() for m in res])

df['address'].apply(sudachi_tokenize_with_mode, mode=tokenizer.Tokenizer.SplitMode.A)
>> 0                  東京 都 文京 区 春日 1 丁目 1 - 1 ラクーアビル 2 F
1               東京 都 新宿 区 高田馬場 1 - 35 - 3 エミオスタイル 1 F
2           東京 都 千代田 区 丸の内 2 - 4 - 1 丸の内 ビルディング B 1 F
3       埼玉 県 さいたま 市 緑 区 美園 5 丁目 50 番地 1 イオン モール 浦和 美園
4    千葉 県 船橋 市 浜町 2 - 1 - 1 ららぽーと TOKYO - BAY ららぽーと 3
5                神奈川 県 鎌倉 市 大船 1 - 4 - 1 ルミネ ウイング 4 F
6              東京 都 江戸川 区 南小岩 7 - 24 - 15 シャポー 小岩 1 F
7              東京 都 豊島 区 南池袋 1 - 29 - 1 池袋 SP   B 1 F
8                        埼玉 県 川越 市 脇田町 105 アトレ 川越 4 F
9                  東京 都 葛飾 区 亀有 3 - 49 - 3 アリオ 亀有 2 F
```

`SplitMode.A` にすると、 mecabの結果とほぼ同様の細かい分割になった。

##### expand を使う

Sudachiには *シュミレーション* を *シミュレーション* に修正するような、Normalization 機能がある。

`normalized_form` も同時に返し、DataFrameにすることを考えてみる。

`Series.apply` には expand機能がないため、`DataFrame.apply` で `result_type='expand'`を指定して実行してみる。

```python
def sudachi_tokenize_multi(text):
    res = tokenizer_obj.tokenize(text, mode)
    return ' '.join([m.surface() for m in res]), ' '.join([m.normalized_form() for m in res])

df.apply(lambda x: sudachi_tokenize_multi(x['neologdn']), axis=1, result_type='expand')
```

|   | 0                                                                | 1                                                                    |
|---|------------------------------------------------------------------|----------------------------------------------------------------------|
| 0 | 東京都文京区春日 1 丁目 1 - 1 ラクーアビル 2 F                   | 東京都文京区春日 1 丁目 1 - 1 ラクーアビル 2 f                       |
| 1 | 東京都新宿区高田馬場 1 - 35 - 3 エミオスタイル 1 F               | 東京都新宿区高田馬場 1 - 35 - 3 エミオスタイル 1 f                   |
| 2 | 東京都千代田区丸の内 2 - 4 - 1 丸の内 ビルディング B 1 F         | 東京都千代田区丸の内 2 - 4 - 1 丸の内 ビルディング b 1 f             |
| 3 | 埼玉県さいたま市緑区美園 5 丁目 50 番地 1 イオンモール 浦和 美園 | 埼玉県さいたま市緑区美園 5 丁目 50 番地 1 イオンモール 浦和 美園     |
| 4 | 千葉県船橋市浜町 2 - 1 - 1 ららぽーと TOKYO - BAY ららぽーと 3   | 千葉県船橋市浜町 2 - 1 - 1 ららぽーと トウキョウ - ベイ ららぽーと 3 |
| 5 | 神奈川県鎌倉市大船 1 - 4 - 1 ルミネ ウイング 4 F                 | 神奈川県鎌倉市大船 1 - 4 - 1 ルミネ ウイング 4 f                     |
| 6 | 東京都江戸川区南小岩 7 - 24 - 15 シャポー 小岩 1 F               | 東京都江戸川区南小岩 7 - 24 - 15 シャッポ 小岩 1 f                   |
| 7 | 東京都豊島区南池袋 1 - 29 - 1 池袋 SP   B 1 F                    | 東京都豊島区南池袋 1 - 29 - 1 池袋 SP   b 1 f                        |
| 8 | 埼玉県川越市脇田町 105 アトレ 川越 4 F                           | 埼玉県川越市脇田町 105 アトレ 川越 4 f                               |
| 9 | 東京都葛飾区亀有 3 - 49 - 3 アリオ 亀有 2 F                      | 東京都葛飾区亀有 3 - 49 - 3 アリオ 亀有 2 f                          |

住所の場合、特に影響がないかと思ったが、 *シャポー* が *シャッポ* に、 *TOKYO - BAY* が *トウキョウ - ベイ* に変換された。F のような英字もなぜか小文字になっている。


### その他

個人的に前処理する順番としては、

1. neologdn
1. 正規表現で不必要な文字列を削除したり、欠損値を補完
1. mecabで分かち書き

だが、SudachiPyを利用する場合は、正規化機能もついているので、先にトークナイズしてから文字の削除や補完をしてもいいかもしれない。


最初にも書きましたが、もっと良い処理方法があれば教えていただけると幸いです。