Pandas でありがちな df = df[df['都道府県']=='北海道']] みたいな除外処理を代替するモジュール

# 二行まとめ
- Pandas.DataFrame でありがちな `df = df[df['都道府県']=='北海道']`みたいな処理を代替するモジュールを作成した
- 自作モジュールにしては便利なので紹介する

# 背景
Python でデータの前処理をする際に、ほぼ毎回 Pandas にお世話になっている。
しかし、時には少し面倒だと感じる処理もある。私にとっては以下がその例だ。

```python
df = df[df['都道府県']=='北海道'] # ここの df は pandas.DataFrame のインスタンスとする
```
つまり、`pandas.DataFrame`のある列の値を参照してデータの絞り込みをする場面だ。
こういう処理は本当に頻繁に必要になるのだが、煩雑な点がいくつかある。
- 一回の除外処理のために`df`を 3 回も書く必要があり、コードが冗長になる
- データが何件除外されたかを知るには前後で`print(len(df))`などを使う必要があり、面倒

なんとかしたい。

# なんとかした

たとえば、日本の市区町村のデータ[^1]から北海道のデータを絞り込むと、除外された件数が自動で`print`される。[^3]
[^3]:画像は Visual Studio Code の Interactive Window で実行したもの


[^1]:データの出典： [独立行政法人統計センター](https://www.nstac.go.jp/use/literacy/ssdse/) のSSDSE-市区町村（SSDSE-A）
データの前処理については以下の記事を参照
https://qiita.com/guneco/items/c3e60e04bdc2e8dced08

```python
import pandas as pd
import removerows as rmrows

df = pd.read_csv('SSDSE-市区町村データ前処理済み.csv')
df = rmrows.select_by_word(df, col='都道府県', word='北海道')
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602439/d132601d-8ae7-dcb8-839f-c43aa50e81bf.png)



この`removerows`が今回の主題であり、実際のコードは記事末尾に記載する。[^2]
[^2]:日本語にしか対応させていないので、今のところ pip などで配布の予定はない。

# おまけ
興味を持ってくれた方のために、他にどんな機能があるかも示しておく。


特に理由はないが、東北地方の市区町村で「〇〇町」がいくつあるかを調べてみる.

```python
df = pd.read_csv('SSDSE-市区町村データ前処理済み.csv')

df = rmrows.select_by_word(df, '項目名', '総人口') # 文字列による除外
df = rmrows.select_by_date(df, '年度', '>=', '2020-01-01') # 日付による除外
df = rmrows.select_by_number(df, '人口', '>', 0) # 数字と不等号による除外

touhoku = ['青森県', '秋田県', '岩手県', '宮城県', '山形県', '福島県']

df = rmrows.select_by_list(df, '都道府県', touhoku, title='東北地方') # list にある項目を残す
df = rmrows.select_by_regexp(df, '市区町村', '町$') # 正規表現による除外

df.groupby('都道府県').agg({'市区町村': 'count'}).rename(columns={'市区町村': '町の数'})
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602439/4c531925-30da-d2f3-87e5-73e2a7e38bfe.png)

除外条件ごとに弾かれる件数がわかるので、重ねて除外を行う際には特に便利である。[^5]
[^5]:当初人口が 0 人の市区町村が存在する想定はしていなかったが、実際に人口が 0 人の町が存在することをこのとき知りました。[Wikipedia - 双葉町](https://ja.wikipedia.org/wiki/%E5%8F%8C%E8%91%89%E7%94%BA)


今回はデータの欠損、重複がないデータセットだったが、
データに不備がないかどうかを明示的に確認することもできる。

```python
df = rmrows.remove_isna(df, '市区町村')
df = rmrows.remove_dupulicated(df, ['都道府県', '市区町村'])
df = rmrows.remove_by_regexp(df, '市区町村', '市$')

# 除外前の行数: 115
# [市区町村]の列について、値が入っていない行を除外する
# 除外した行数: 0
# 除外後の行数: 115

# 除外前の行数: 115
# [['都道府県', '市区町村']]列の重複を除外する（NaNの行は除外しない）
# 除外した行数: 0
# 除外後の行数: 115

# 除外前の行数: 115
# [市区町村]の列について、[市$]のパターンに一致する行を除外する
# 除外した行数: 0
# 除外後の行数: 115
```

便利ですね〜。


# removerows.py

書き方が拙い部分もあると思いますが、個人用として特に不便がなければよしとしてます。
利用は自己責任でお願いします。

```python
import re
from datetime import datetime

import pandas as pd

def check_len_removed_rows(func):
    """除外した行数確認用のデコレータ関数"""
    def wrapper(*args, **kwargs):
        if 'df' in kwargs.keys():
            len_before = len(kwargs['df'])
        elif isinstance(args[0], pd.DataFrame):
            len_before = len(args[0])
        else:
            raise Exception('args[0] must be pandas.Dataframe')
        print(f'除外前の行数: {len_before}')
        result = func(*args, **kwargs)
        print(f'除外した行数: {len_before-len(result)}\n除外後の行数: {len(result)}\n')
        return result
    return wrapper


@check_len_removed_rows
def select_by_word(df:pd.DataFrame, col:str, word:str):
    print(f'[{col}]列が[{word}]と一致する行を残し、他を全て除外する')
    return df[df[col]==word]


@check_len_removed_rows
def remove_by_word(df:pd.DataFrame, col:str, word:str,):
    print(f'[{col}]列が[{word}]と一致する行を除外する')
    return df[df[col]!=word]


@check_len_removed_rows
def select_by_bool(df:pd.DataFrame, col:str, bool_:bool):
    print(f'[{col}]の列について、{bool_}に一致する行を残し、他の行を除外する')
    return df[df[col]==bool_]


@check_len_removed_rows
def remove_dupulicated(
    df: pd.DataFrame, col: str | list[str], 
    keep='first',sort_col=None, ascending=True
    ):
    print(f'[{col}]列の重複を除外する（NaNの行は除外しない）')
    if sort_col:
        asc_text = '昇順' if ascending else '降順'
        keep_text_dict = {
            'first':'最初の行を残して、それ以外',
            'last':'最後の行を残して、それ以外',
            False:'全て'
            }
        print(f'[{sort_col}]の値を{asc_text}に並び替え、{keep_text_dict[keep]}を除外する')
        df = df.sort_values(sort_col, ascending=ascending)
    if isinstance(col, str):
        return df[~(df.duplicated(subset=col, keep=keep)) | (df[col].isna())]
    elif isinstance(col, list):
        return df[~(df.duplicated(subset=col, keep=keep)) | (df[col].isna().all(axis=1))]
   
@check_len_removed_rows
def select_by_list(df:pd.DataFrame, col:str, list_:list, title:str=''):
    print(f'[{col}]の列について、{title}リストに含まれる行を残し、他の行を除外する')
    return df[df[col].apply(lambda x: True if x in list_ else False)]


@check_len_removed_rows
def remove_by_list(df:pd.DataFrame, col:str, list_:list, title:str=''):
    print(f'[{col}]の列について、{title}リストに含まれる行を除外する')
    return df[df[col].apply(lambda x: False if x in list_ else True)]


@check_len_removed_rows
def remove_notna(df:pd.DataFrame, col:str):
    print(f'[{col}]の列について、値が入っている行を除外する')
    return df[df[col].isna()]


@check_len_removed_rows
def remove_isna(df:pd.DataFrame, col:str):
    print(f'[{col}]の列について、値が入っていない行を除外する')
    return df[df[col].notna()]


@check_len_removed_rows
def select_by_number(
    df:pd.DataFrame, col:str, symbol:str, number:float,
    ):
    symbol_dict = {'>':'より大きい', '>=':'以上の', '=':'に等しい', '<=':'以下の', '<':'より小さい'}
    print(f'[{col}]の列について、{number} {symbol_dict[symbol]}行のみに絞る')
    if symbol == '>':
        df = df[df[col] > number]
    elif symbol == '>=':
        df = df[df[col] >= number]
    elif symbol == '=':
        df = df[df[col] == number]
    elif symbol == '<=':
        df = df[df[col] <= number]
    elif symbol == '<':
        df = df[df[col] < number]
    return df


@check_len_removed_rows
def select_by_regexp(df:pd.DataFrame, col:str, pattarn:str):
    print(f'[{col}]の列について、[{pattarn}]のパターンに一致する行を残し、それ以外を除外する')
    df = df.copy()
    df = df[df[col].apply(
        lambda x: True if re.search(pattern=pattarn, string=x) else False
        )]
    return df


@check_len_removed_rows
def remove_by_regexp(df:pd.DataFrame, col:str, pattarn:str):
    print(f'[{col}]の列について、[{pattarn}]のパターンに一致する行を除外する')
    df = df.copy()
    df = df[df[col].apply(
        lambda x: False if re.search(pattern=pattarn, string=x) else True
        )]
    return df


@check_len_removed_rows
def select_by_date(df:pd.DataFrame, col:str, symbol:str, date: str):
    df = df.copy()
    symbol_dict = {'>':'より後の', '>=':'以降の', '=':'に等しい', '<=':'以前の', '<':'より前の'}
    df[col] = pd.to_datetime(df[col])
    print(f'[{col}]の列について、{date} {symbol_dict[symbol]}行のみに絞る')
    date = datetime.strptime(date, '%Y-%m-%d')
    if symbol == '>':
        df = df[df[col] > date]
    elif symbol == '>=':
        df = df[df[col] >= date]
    elif symbol == '=':
        df = df[df[col] == date]
    elif symbol == '<=':
        df = df[df[col] <= date]
    elif symbol == '<':
        df = df[df[col] < date]
    return df


@check_len_removed_rows
def anti_join(df, anti_df, on, title=''):
    right_data = f'[{title}]' if title else '右の Dataframe '
    print(f'[{on}]列をキーにして{right_data}と共通する行を除外する')
    anti_df = anti_df[on]
    merged_df = df.merge(anti_df, on=on, how='left', indicator=True)
    anti_joined = merged_df[merged_df['_merge']=='left_only'].copy() # エラー回避のcopy() c.f. https://qiita.com/FukuharaYohei/items/b3aa7113d08858676910
    anti_joined.drop('_merge', axis=1, inplace=True)
    return anti_joined

```