【データ前処理】全角空白で区切られた日本語名詞を分割した話【Python】

# はじめに

データ分析のコンペティションに参加したとき，
下記のように全角空白で区切られた日本語の固有名詞のテキストがありました．

全角空白で区切られた日本語の固有名詞の例：
 - ほげ　ふが　ほげら太郎　42
 - ほげ　ふが23　ぴよ　ほげら花子　1729
 - ほげ　ふが23　ぴよ　ほげら花子　1984

このようなテキストデータを分割するプログラムをPythonで作りました．

この記事ではPythonのプログラム全体を掲載してから，
分割対象のデータとプログラムについて解説します．
同様のことをSQLでもやってみました．
その後，作ったプログラムの拡張例を示します．

データの前処理に困っている人のヒントになることを願ってこの記事を投稿します．

この記事にあるプログラムは，GitHub( [https://github.com/nnbk2k/sample-jpn-noun-split](https://github.com/nnbk2k/sample-jpn-noun-split) )にもあります．


# 作ったプログラム

`pandas`と`japanize_matplotlib`は
分割した結果を確認しやすくしたり可視化したりするために使用しています．

```python:bunkatsu.ipynb
import pandas as pd
import japanize_matplotlib

texts = [
    'ほげ　ふが　ほげら太郎　42',
    'ほげ　ふが23　ぴよ　ほげら花子　1729',
    'ほげ　ふが23　ぴよ　ほげら花子　1984',
]

texts_splited = {
    "hoge" : [],
    "fuga" : [],
    "piyo" : [],
    "hogera" : [],
    "hogehoge" : [],
}
for text in texts:
    spl = text.split('　')
    spl_len = len(spl)
    if spl_len == 5:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append(spl[2])
        texts_splited["hogera"].append(spl[3])
        texts_splited["hogehoge"].append(spl[4])
    elif spl_len == 4:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append('None')
        texts_splited["hogera"].append(spl[2])
        texts_splited["hogehoge"].append(spl[3])

df = pd.DataFrame(texts_splited, columns=texts_splited.keys())
```




# データの特徴

分割対象のテキストは，主に日本語で構成されています．

全角空白で区切られた日本語の固有名詞の例：
 - ほげ　ふが　ほげら太郎　42
 - ほげ　ふが23　ぴよ　ほげら花子　1729
 - ほげ　ふが23　ぴよ　ほげら花子　1984

このようなテキストの特徴は以下です．
- 全角空白で区切られている
- 全角空白が3つの場合と4つの場合がある
- 名前（固有名詞）が含まれている
    - 例：ほげ，ほげら太郎
- 左から1番目は「ほげ」のような単語
- 左から2番目は「ふが」や「ふが23」のような単語
- 右から2番目は「ほげら太郎」や「ほげら花子」のような単語
- 右から1番目は数字
    - 例：42，1729
- 「ぴよ」があるテキストとないテキストがある
    - 「ぴよ」あり：ほげ　ふが23　ぴよ　ほげら花子　1729
    - 「ぴよ」なし：ほげ　ふが　ほげら太郎　42


これらの特徴を持つテキストを分割していきます．


# 環境

実行環境と使用したライブラリのバージョンです．
- OS ：Debian GNU/Linux 11 (bullseye)
- Python ：3.8.18
- pandas 2.0.3




# プログラムの解説

作ったプログラムを解説します．

```python
import pandas as pd
import japanize_matplotlib
```

分割した結果は`dict`型になるので，
結果を見やすくするために`pandas.DataFrame`を使用します．
可視化するときに，日本語を表示したいので`japanize_matplotlib`をインポートします．

```python
texts = [
    'ほげ　ふが　ほげら太郎　42',
    'ほげ　ふが23　ぴよ　ほげら花子　1729',
    'ほげ　ふが23　ぴよ　ほげら花子　1984',
]
```

解説で扱うサンプルデータはこの3つのテキストです．
`list`型の変数`texts`に分割対象のテキストを複数格納しています．
実践するときには，`pandas.DataFrame`型の変数`df`を用意して，
`df['列名'].unique()`の結果を変数`texts`の変わりに使うことになると思います．


```python
texts_splited = {
    "hoge" : [],
    "fuga" : [],
    "piyo" : [],
    "hogera" : [],
    "hogehoge" : [],
}
```

`dict`型の変数`texts_splited`に分割した結果を格納します．
この辞書のバリューは`list`型で，この`list`型に分割した結果を追加します．
分割した対象がない場合は`'None'`を追加します．
この`dict`型の変数`texts_splited`の前提は，バリューに次のような値を格納することです．
- キー`hoge`は変数`texts`にある「ほげ」を追加
- キー`fuga`は変数`texts`にある「ふが」または「ふが23」を追加
- キー`piyo`は変数`texts`にある「ぴよ」を追加
- キー`hogera`は変数`texts`にある「ほげら太郎」または「ほげら花子」を追加
- キー`hogehoge`は変数`texts`にある「42」または「1729」を追加

```python
for text in texts:
    spl = text.split('　')
    spl_len = len(spl)
    if spl_len == 5:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append(spl[2])
        texts_splited["hogera"].append(spl[3])
        texts_splited["hogehoge"].append(spl[4])
    elif spl_len == 4:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append('None')
        texts_splited["hogera"].append(spl[2])
        texts_splited["hogehoge"].append(spl[3])
```

続いて，forループを解説します．

```python
text = texts[0]
text
# 'ほげ\u3000ふが\u3000ほげら太郎\u300042'
```

変数`text`に`'ほげ\u3000ふが\u3000ほげら太郎\u300042'`が代入されている状況で説明します．

```python
spl = text.split('　')
spl
# ['ほげ', 'ふが', 'ほげら太郎', '42']
```

`str`型の`split()`メソッドを使って，`'ほげ\u3000ふが\u3000ほげら太郎\u300042'`を全角空白で分割します．
分割した結果は`list`型になっていて，変数`spl`に代入します．

```python
spl_len = len(spl)
spl_len
# 4
```

`len()`関数の戻り値は変数`spl_len`に代入します．
`spl_len`は分割した結果の変数`spl`の長さ 4 を保持しています．

```python
if spl_len == 5:
```

このif文は変数`spl_len`の値で処理を分岐しています．
変数`spl_len`の値は4だから`False`になります．
よって，このif文のブロックは実行されないです．

```python
elif spl_len == 4:
```

`if spl_len == 5:`のif文が`False`だったので，このelif文の条件を満たすかを判定します．
変数`spl_len`の値は4だから`True`となり，このelif文のブロックが実行されます．

```python
texts_splited["hoge"].append(spl[0])
texts_splited
# {'hoge': ['ほげ'], 'fuga': [], 'piyo': [], 'hogera': [], 'hogehoge': []}
```

`dict`型の変数`texts_splited`において，キーが`hoge`の`list`型のバリューに分割した結果の変数`spl`の添え字が0の要素`'ほげ'`を追加しています．

```python
texts_splited["fuga"].append(spl[1])
texts_splited
# {'hoge': ['ほげ'],
#  'fuga': ['ふが'], 
#  'piyo': [], 
#  'hogera': [], 
#  'hogehoge': []}
```

同様にして，分割した結果の変数`spl`の添え字が1の要素を`dict`型の変数`texts_splited`のキーが`fuga`の`list`型のバリューに追加します．

```python
texts_splited["piyo"].append('None')
texts_splited
# {'hoge': ['ほげ'],
#  'fuga': ['ふが'],
#  'piyo': ['None'],
#  'hogera': [],
#  'hogehoge': []}
```

ここでは`'None'`を追加しています．
変数`spl_len`の値は4になっており`texts_splited["piyo"]`に追加できるテキストがないからです．

:::note info
`'ほげ　ふが23　ぴよ　ほげら花子　1729'`を全角空白で分割する場合

分割した結果の変数`spl`の長さは 5 になります．
`ぴよ`を`texts_splited["piyo"]`に追加します．
:::


```python
texts_splited["hogera"].append(spl[2])
texts_splited["hogehoge"].append(spl[3])
texts_splited
# {'hoge': ['ほげ'],
#  'fuga': ['ふが'],
#  'piyo': ['None'],
#  'hogera': ['ほげら太郎'],
#  'hogehoge': ['42']}
```

`texts_splited["hogera"]`と`texts_splited["hogehoge"]`は追加できるテキストがあるので，それぞれ`ほげら太郎`と`42`を追加しています．


for文のブロックを解説しました．
このfor文を実行することで，下記のような結果になります．

```python
texts_splited
# {'hoge': ['ほげ', 'ほげ', 'ほげ'],
#  'fuga': ['ふが', 'ふが23', 'ふが23'],
#  'piyo': ['None', 'ぴよ', 'ぴよ'],
#  'hogera': ['ほげら太郎', 'ほげら花子', 'ほげら花子'],
#  'hogehoge': ['42', '1729', '1984']}
```

全角空白で分割した結果を`dict`型の変数`texts_splited`に格納した結果が得られました．
この結果を利用しやすくするために，`pandas.DataFrame`型にします．

```python
df = pd.DataFrame(texts_splited, columns=texts_splited.keys())
```

`pandas.DataFrame`型の変数`df`は下記のようになります．

|  | hoge | fuga | piyo | hogera | hogehoge |
| --: | --: | --: | --: | --: | --: |
| 0 | ほげ | ふが | None | ほげら太郎 | 42 |
| 1 | ほげ | ふが23 | ぴよ | ほげら花子 | 1729 |
| 2 | ほげ | ふが23 | ぴよ | ほげら花子 | 1984 |


`pandas.DataFrame`型にすることで，結果を確認しやすくなったと思います．
こうすることで，次のように可視化も行えます．

```python
df['fuga'].value_counts().plot(kind='bar')
```

![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3803260/16787e7a-0360-4f4c-6a49-b75aba10efe0.png)
**可視化の結果：fuga列にあるテキストの数**


このプログラムの課題は，`str`型の`split()`メソッドで分割した結果の`list`型に含む要素が4または5になるという制約です．
プログラムを作成したときの分割対象のデータでは全角空白の数は4また5だったので，
この制約になりました．

前処理は分析対象のデータに合わせて調整するしかないかなぁと思います．
詳しく調べたいデータをこのプログラムで処理する，という方法も考えられると思います．
私の場合はデータ全体の一部のテキストに対して空白の分割を実行しました．



# SQLで分割する

参考として，SQLで分割を試みたことを掲載します．
SUBSTRING_INDEX関数を使って分割しました．
空白の数がすべてのレコードで同じであれば下記のクエリで意図した通りに分割できます．

```sql:bunkatsu.sql
-- テキストを分割 (空白の数がすべてのレコードで同じではない場合)
SELECT texts AS name,
        SUBSTRING_INDEX(texts, '　', 1) AS name1,
        SUBSTRING_INDEX(SUBSTRING_INDEX(texts, '　', 2), '　', -1) AS name2,
        SUBSTRING_INDEX(SUBSTRING_INDEX(texts, '　', 3), '　', -1) AS name3,
        SUBSTRING_INDEX(SUBSTRING_INDEX(texts, '　', -2), '　', 1) AS name4,
        SUBSTRING_INDEX(texts, '　', -1) AS name5
    FROM sample_data;

-- +-----------------------------------------------------+--------+----------+-----------------+-----------------+-------+
-- | name                                                | name1  | name2    | name3           | name4           | name5 |
-- +-----------------------------------------------------+--------+----------+-----------------+-----------------+-------+
-- | ほげ　ふが　ほげら太郎　42                          | ほげ   | ふが     | ほげら太郎      | ほげら太郎      | 42    |
-- | ほげ　ふが23　ぴよ　ほげら花子　1729                | ほげ   | ふが23   | ぴよ            | ほげら花子      | 1729  |
-- | ほげ　ふが23　ぴよ　ほげら花子　1984                | ほげ   | ふが23   | ぴよ            | ほげら花子      | 1984  |
-- +-----------------------------------------------------+--------+----------+-----------------+-----------------+-------+
-- 3 rows in set (0.000 sec)
```

全角空白が4つあることを前提として作成しました．
`ほげ　ふが23　ぴよ　ほげら花子　1729`のような全角空白が4つあるテキストは
意図した通りに分割できました．

また，意図していない結果の例として
`ほげ　ふが　ほげら太郎　42`を分割したときの結果があります．
name3列とname4列に`ほげら太郎`が格納されました．


sqlファイルは，GitHub( [https://github.com/nnbk2k/sample-jpn-noun-split](https://github.com/nnbk2k/sample-jpn-noun-split) )にありますので，興味があればご覧ください．
サンプルデータのcsvファイルとデータベースの作成からテーブルへのデータの追加まで記述した`nnbk2k/sample-jpn-noun-split/SQL/setup.sql`があります．
テキストの分割は，`nnbk2k/sample-jpn-noun-split/SQL/sample_jpn_noun_split.sql`にあります．



# プログラムの拡張例

分割対象には下記のような制約がありました．


**制約**
- 全角空白が3つまたは4つのデータのみが存在している
   - 全角空白が2つの`'ほげ　ふが　ほげら太郎'`や全角空白が5つある`'ほげ　ふが23　ぴよ　ほげら花子　1984　fuga23'`のようなテキストがない
- 全角空白で分割したときに，左側2つと右側2つは必ず存在している
   - `'ほげ　ふが　ほげら太郎　42'`のとき，
      ほげ / ふが / ほげら太郎 / 42
      のように分割されて，左側2つには「ほげ」と「ふが」が存在し，右側2つには「ほげら太郎」と「42」が存在している
   - `'ほげ　ふが23　ぴよ　ほげら花子　1729'`のとき， 
      ほげ / ふが23 / ぴよ / ほげら花子 / 1729 
      のように分割されて，左側2つには「ほげ」と「ふが23」が存在し，右側2つには「ほげら花子」と「1729」が存在している
   - `'ほげ　ふが　42'` や `'ほげら太郎　42'` ，`'ほげ　ふが'` のようなデータは存在しない


このような制約を少しでも減らせると思う方法を紹介します．


```python
texts = [
    'ほげ　ふが　ほげら太郎　42',
    'ほげ　ふが23　ぴよ　ほげら花子　1729',
    'ほげ　ふが23　ぴよ　ほげら花子　1984',
    'ほげ　ふが23　ぴよ　ほげら花子　1984　fuga23',
    'ほげ　ふが23　ぴよ　ほげら花子　1984　fuga42',
    ]
```

これらのテキストを分割します．
変数`texts`に，

```python
    'ほげ　ふが23　ぴよ　ほげら花子　1984　fuga23',
    'ほげ　ふが23　ぴよ　ほげら花子　1984　fuga42',
```

の2行を追加しました．
この2行には，全角空白が5つあります．

全角空白が5つある場合を処理するには，次のようにプログラムを拡張します．

```python:bunkatsu.ipynb

texts_splited = {
    "hoge" : [],
    "fuga" : [],
    "piyo" : [],
    "hogera" : [],
    "hogehoge" : [],
    "fugafuga" : [],  # 追加
}
for text in texts:
    spl = text.split('　')
    spl_len = len(spl)
    if spl_len == 5:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append(spl[2])
        texts_splited["hogera"].append(spl[3])
        texts_splited["hogehoge"].append(spl[4])
   
        texts_splited["fugafuga"].append('None')  # 追加

    elif spl_len == 4:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append('None')
        texts_splited["hogera"].append(spl[2])
        texts_splited["hogehoge"].append(spl[3])
   
        texts_splited["fugafuga"].append('None')  # 追加

    # 追加
    elif spl_len == 6:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append(spl[2])
        texts_splited["hogera"].append(spl[3])
        texts_splited["hogehoge"].append(spl[4])
        texts_splited["fugafuga"].append(spl[5])

df = pd.DataFrame(texts_splited, columns=texts_splited.keys())
```

コメントで「# 追加」とある行が，全角空白が5つの場合に対応するために追加した行です．

```python
texts_splited = {
    "hoge" : [],
    "fuga" : [],
    "piyo" : [],
    "hogera" : [],
    "hogehoge" : [],
    "fugafuga" : [],  # 追加
}
```

変数`texts_splited`にキー`fugafuga`を追加しました．
このキーのバリューに「fuga23」や「fuga42」のようなテキストを格納します．


```python
        texts_splited["fugafuga"].append('None')  # 追加
```

`if spl_len == 5:`と`elif spl_len == 4:`のブロックに，
`'None'`を`texts_splited["fugafuga"]`のバリューに追加する行を新しく追加しました．
`'None'`を追加しなければ，前処理したデータの利用時にバグが出ると思います．

```python
    # 追加
    elif spl_len == 6:
        texts_splited["hoge"].append(spl[0])
        texts_splited["fuga"].append(spl[1])
        texts_splited["piyo"].append(spl[2])
        texts_splited["hogera"].append(spl[3])
        texts_splited["hogehoge"].append(spl[4])
        texts_splited["fugafuga"].append(spl[5])
```
`elif spl_len == 6:`のelif文で，全角空白が5つある場合に対応します．
このブロックで，`texts_splited`のバリューに`list`型の変数`spl`が
格納している値を追加します．


このプログラムの実行結果は下記のようになりました．

|  | hoge | fuga | piyo | hogera | hogehoge | fugafuga |
| --: | --: | --: | --: | --: | --: | --: |
| 0 | ほげ | ふが | None | ほげら太郎 | 42 | None |
| 1 | ほげ | ふが23 | ぴよ | ほげら花子 | 1729 | None |
| 2 | ほげ | ふが23 | ぴよ | ほげら花子 | 1984 | None |
| 3 | ほげ | ふが23 | ぴよ | ほげら花子 | 1984 | fuga23 |
| 4 | ほげ | ふが23 | ぴよ | ほげら花子 | 1984 | fuga42 |


全角空白が5つある場合に対応できるようにプログラムを拡張しました．



# おわりに

全角空白をPythonとSQLで分割した方法を共有しました．
この方法がデータ前処理の助けになれば幸いです．