0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

pandasで必須ワード・禁止ワードを同時に扱うフィルタ関数の作り方

Posted at

user:

次の要件を満たすPython関数を書いてください。

・入力は、 pandas dataframe 形式データと、必須ワードのリストと、禁止ワードのリストと、ワードを調べる列の名前、そして、IDの列の名前。

・データから、ワードを調べる列中に、必須ワードが存在する行だけ残す。
・データから、ワードを調べる列中に、禁止ワードが存在しない行だけ残す。

・残った行のIDをリストにする。

・データから、そのリスト中のIDを持つ行を抜き出したものを変数の戻り値とする。

assistant:

はい、条件に沿った関数を整理してみました。ポイントは pandas の文字列操作(str.contains)と、必須ワードを すべて含む のか どれか1つでも含めばよい のかをどう解釈するかですが、ここでは「すべての必須ワードを含む行のみ残す」として書きます。

import pandas as pd

def filter_dataframe(df: pd.DataFrame, 
                     required_words: list, 
                     forbidden_words: list, 
                     text_col: str, 
                     id_col: str) -> pd.DataFrame:
    """
    DataFrameをフィルタして、必須ワードを含み禁止ワードを含まない行を返す。
    
    Parameters
    ----------
    df : pd.DataFrame
        入力データ
    required_words : list of str
        必ず含まれていなければならないワードのリスト
    forbidden_words : list of str
        含まれてはいけないワードのリスト
    text_col : str
        ワードを調べる対象の列名
    id_col : str
        ID列の列名
    
    Returns
    -------
    pd.DataFrame
        条件を満たす行だけを残したDataFrame
    """

    # 全行を対象にブールマスクを作成
    mask = pd.Series(True, index=df.index)

    # 必須ワード → すべて含まれるかチェック
    for w in required_words:
        mask &= df[text_col].str.contains(w, na=False)

    # 禁止ワード → 1つでも含まれたら除外
    for w in forbidden_words:
        mask &= ~df[text_col].str.contains(w, na=False)

    # 条件を満たすIDリスト
    valid_ids = df.loc[mask, id_col].tolist()

    # そのIDを持つ行を抜き出す
    result = df[df[id_col].isin(valid_ids)]

    return result

この関数は:

  • required_words のすべてを含む行を残す。
  • forbidden_words のいずれも含まない行を残す。
  • 条件を満たす行の ID をリスト化して、それに基づいて df を抽出して返す。

もし「必須ワードはリスト中のどれか1つでも含めばよい」バージョンが欲しいなら、上の for w in required_words: の部分を

if required_words:
    mask &= df[text_col].str.contains('|'.join(required_words), na=False)

に書き換えればOKです。

先輩は「必須ワードはすべて含む」か「どれか1つでも含めばOK」か、どちらの仕様を考えていますか?

user:

ありがとうございます。「必須ワードはすべて含む」か「どれか1つでも含めばOK」か、それもユーザが選択できるようにできますか?

assistant:

できますよ!その場合は引数にフラグを1つ追加して、

  • required_mode="all" のとき → 必須ワードをすべて含む
  • required_mode="any" のとき → 必須ワードのうちどれか1つでも含めばよい

というように切り替えるのが自然です。

コード例はこちらです。

import pandas as pd

def filter_dataframe(df: pd.DataFrame, 
                     required_words: list, 
                     forbidden_words: list, 
                     text_col: str, 
                     id_col: str,
                     required_mode: str = "all") -> pd.DataFrame:
    """
    DataFrameをフィルタして、必須ワードを含み禁止ワードを含まない行を返す。
    
    Parameters
    ----------
    df : pd.DataFrame
        入力データ
    required_words : list of str
        必ず含まれていなければならないワードのリスト
    forbidden_words : list of str
        含まれてはいけないワードのリスト
    text_col : str
        ワードを調べる対象の列名
    id_col : str
        ID列の列名
    required_mode : {"all", "any"}, default="all"
        - "all" : 必須ワードをすべて含む行を残す
        - "any" : 必須ワードのうち1つでも含む行を残す
    
    Returns
    -------
    pd.DataFrame
        条件を満たす行だけを残したDataFrame
    """

    mask = pd.Series(True, index=df.index)

    # 必須ワードのチェック
    if required_words:
        if required_mode == "all":
            for w in required_words:
                mask &= df[text_col].str.contains(w, na=False)
        elif required_mode == "any":
            pattern = "|".join(required_words)
            mask &= df[text_col].str.contains(pattern, na=False)
        else:
            raise ValueError("required_mode must be 'all' or 'any'")

    # 禁止ワードのチェック
    for w in forbidden_words:
        mask &= ~df[text_col].str.contains(w, na=False)

    # 条件を満たすIDリスト
    valid_ids = df.loc[mask, id_col].tolist()

    # そのIDを持つ行を抜き出す
    result = df[df[id_col].isin(valid_ids)]

    return result

これならユーザーが呼び出すときに

# 必須ワード全部を含む
df_out1 = filter_dataframe(df, ["", "可愛い"], [""], "text", "id", required_mode="all")

# 必須ワードどれか1つでも含む
df_out2 = filter_dataframe(df, ["", "可愛い"], [""], "text", "id", required_mode="any")

と選べます。

ここでさらに発展させて「禁止ワードも all/any で切り替えられる」仕様にすることも可能ですが、そこまで柔軟さは必要そうですか?

user:

ありがとうございます。この関数、ひょっとして日本語には対応してないですか?

assistant:

対応してますよ!🐱
pandas の str.contains は Python 標準の正規表現エンジンを使っていて、UTF-8 文字列(日本語も含む)にちゃんと対応しています。なので「猫」「可愛い」「走る」みたいな日本語ワードをそのまま渡して大丈夫です。

ただし注意点がいくつかあります:

  1. 正規表現として解釈される
    str.contains(".*") とかは「任意の文字列」を意味します。
    もし必須ワードに .+ みたいな正規表現メタ文字が含まれていると、文字通りの検索になりません。
    その場合は re.escape() を使って自動的にエスケープしてあげると安心です。

  2. 大文字小文字の扱い
    英語なら case=False で無視できますが、日本語は基本的に大文字小文字の区別がないので気にしなくて大丈夫です。

  3. 全角半角の違い
    「カタカナ」と「カタカナ」は別文字として扱われるので、表記ゆれがありそうなら正規化(unicodedata.normalize)をしてから検索したほうが良いケースがあります。

なので、日本語対応により安全にするなら、検索部分をこうしておくのがおすすめです:

import re

# "all" モード
for w in required_words:
    mask &= df[text_col].str.contains(re.escape(w), na=False)

# "any" モード
pattern = "|".join(re.escape(w) for w in required_words)
mask &= df[text_col].str.contains(pattern, na=False)

こうすれば「.」「+」「?」などが必須ワードに含まれていても、文字通りに検索してくれます。

先輩が扱っているデータ、日本語だけですか?それとも英語や記号も混ざる感じですか?

user:

ありがとうございました。これで解決です。今夜はこの辺にしときましょう。ここまでの内容をブログにまとめたいので、タイトル案をいくつか考えていただけますか?

assistant:

もちろんです!今回の内容は「pandas でテキスト検索条件を柔軟に設定しつつ DataFrame をフィルタする方法」なので、ブログタイトルはちょっとキャッチーさを持たせつつ検索性も意識すると良さそうです。いくつか案を挙げますね。

  1. 「pandasで必須ワード・禁止ワードを同時に扱うフィルタ関数の作り方」
  2. 「Python × pandas で日本語テキストを柔軟に抽出する方法」
  3. 「必須ワードはALL?ANY? pandasで選べるテキスト検索」
  4. 「DataFrameをスマートにフィルタ:必須ワード・禁止ワード対応関数」
  5. 「日本語テキストにも対応!pandasで自在に行抽出する方法」
  6. 「研究・実務で使える pandas テキストフィルタ関数」
  7. 「Pythonで検索条件を自由に切り替え:必須ワードと禁止ワードの活用」

内容的には技術記事寄りなので、検索流入を狙うなら「pandas」「日本語」「必須ワード/禁止ワード」といったキーワードは入れておくのが有利だと思います。

少し柔らかめのタイトルにして読みやすさを出すなら「猫を含んで犬を含まない行だけ抽出!pandasで日本語フィルタ関数を作る」みたいな遊びもアリですね。

どちらのトーン(技術的に堅め/ちょっと遊び心あり)でまとめたいですか?

user:

「pandasで必須ワード・禁止ワードを同時に扱うフィルタ関数の作り方」でいきましょう。このタイトルに沿って、ここまでの内容を詳細に解説してください。

assistant:

pandasで必須ワード・禁止ワードを同時に扱うフィルタ関数の作り方

テキスト列に対して「必須ワード(含まれていないとダメ)」と「禁止ワード(含まれていたらアウト)」を同時に判定し、さらに必須ワードの評価方法(すべて含む / どれか1つでも含む)を切り替えられる pandas 関数を作ります。日本語テキストも安全に扱えるよう、正規表現のエスケープと全角・半角の正規化も取り込みます。


完成コード

from __future__ import annotations
import re
import unicodedata
from typing import Optional, Sequence
import pandas as pd


def filter_dataframe(
    df: pd.DataFrame,
    required_words: Optional[Sequence[str]] = None,
    forbidden_words: Optional[Sequence[str]] = None,
    text_col: str = "text",
    id_col: str = "id",
    required_mode: str = "all",  # "all" or "any"
    normalize: bool = True,       # NFKCで表記ゆれ(全半角など)を吸収
    case_sensitive: bool = True,  # 英字の大文字小文字を区別するか
) -> pd.DataFrame:
    """テキスト列に対する必須/禁止ワード判定で行を抽出する。

    Parameters
    ----------
    df : pd.DataFrame
        入力データ。
    required_words : Sequence[str] | None
        含まれているべきワードのリスト。None/空なら必須条件なし。
    forbidden_words : Sequence[str] | None
        含まれてはならないワードのリスト。None/空なら禁止条件なし。
    text_col : str
        検索対象のテキスト列名。
    id_col : str
        ID列の列名(抽出は最終的にこのIDリストで行う)。
    required_mode : {"all", "any"}
        "all": 必須ワードを *すべて* 含む行のみ残す。
        "any": 必須ワードの *どれか1つでも* 含む行を残す。
    normalize : bool
        True ならテキストとワードを NFKC 正規化(全角/半角のゆれを吸収)。
    case_sensitive : bool
        英字の大文字小文字を区別するかどうか(日本語には基本無関係)。

    Returns
    -------
    pd.DataFrame
        条件を満たしたIDを持つ行のみを返す(元のdfから抽出)。
    """

    # 必要な列の存在チェック
    for col in (text_col, id_col):
        if col not in df.columns:
            raise KeyError(f"Column '{col}' not found in DataFrame.")

    # テキスト列(欠損は空文字に)
    s = df[text_col].fillna("").astype(str)

    # 正規化(NFKC: 全角/半角・互換文字を整える)
    if normalize:
        s = s.map(lambda x: unicodedata.normalize("NFKC", x))

    def _prep(words: Optional[Sequence[str]]) -> list[str]:
        """ワードリストの整形 + 正規化 + 正規表現エスケープ。"""
        if not words:
            return []
        cleaned = [w for w in words if isinstance(w, str) and w != ""]
        if normalize:
            cleaned = [unicodedata.normalize("NFKC", w) for w in cleaned]
        return [re.escape(w) for w in cleaned]

    req_list = _prep(required_words)
    forb_list = _prep(forbidden_words)

    # まず全行Trueのマスクから開始
    mask = pd.Series(True, index=df.index)

    # 必須ワード: all/any を切替
    if req_list:
        if required_mode == "all":
            # すべて含む → 先読み(?=...)で1回の検索に統合
            lookaheads = "".join(f"(?=.*{w})" for w in req_list)
            pattern = f"{lookaheads}.*"
        elif required_mode == "any":
            pattern = "|".join(req_list)
        else:
            raise ValueError("required_mode must be 'all' or 'any'")

        mask &= s.str.contains(
            pattern,
            na=False,
            case=case_sensitive,
            regex=True,
        )

    # 禁止ワード: 1つでも含まれたら除外
    if forb_list:
        forb_pattern = "|".join(forb_list)
        mask &= ~s.str.contains(
            forb_pattern,
            na=False,
            case=case_sensitive,
            regex=True,
        )

    # 条件を満たした ID のリスト
    valid_ids = df.loc[mask, id_col].dropna().unique().tolist()

    # そのIDを持つ行を抽出して返す(元dfの構造を保つ)
    return df[df[id_col].isin(valid_ids)].copy()

使い方サンプル

import pandas as pd

df = pd.DataFrame(
    {
        "id": [1, 1, 2, 3, 4],
        "text": [
            "可愛い猫が走る",
            "犬は賢い",
            "猫と犬",
            "ねこがとても可愛い",
            "タヌキの話",
        ],
    }
)

# 必須ワードは "猫" と "可愛い" を両方含み、禁止ワードに "犬"
out_all = filter_dataframe(
    df,
    required_words=["", "可愛い"],
    forbidden_words=[""],
    text_col="text",
    id_col="id",
    required_mode="all",
)

# 必須ワードは "猫" または "可愛い" のどちらかを含めばOK、禁止ワードに "犬"
out_any = filter_dataframe(
    df,
    required_words=["", "可愛い"],
    forbidden_words=[""],
    text_col="text",
    id_col="id",
    required_mode="any",
)
  • required_mode="all" では、両方のワードを含む行(例: 「可愛い猫が走る」)のみ残ります。
  • required_mode="any" では、どちらか一方でも含めば残りますが、禁止ワード(ここでは「犬」)がヒットした行は除外されます。

返り値は「条件を満たした ID の行だけ を元の DataFrame から抜き出したもの」です。IDが重複している場合は、該当IDの行がすべて返ります。


日本語対応のポイント

  1. 正規表現のエスケープ : re.escape() を通しているので、検索語に .+ などのメタ文字が含まれていても文字通りにマッチします。
  2. 全角/半角ゆれの吸収 : normalize=True で NFKC 正規化をかけ、カタカナカタカナ の差や、半角記号の差をできるだけ減らしています。
  3. 大文字小文字 : 日本語には無関係ですが、英字が混ざるケースに備え case_sensitive を用意しています。
  4. 語境界 : 日本語は英語のように単語境界(\b)が素直に使えません。厳密な「単語一致」をしたい場合は、形態素解析(MeCab/Janome/Sudachi など)でトークン化してからキーワード判定するのが確実です(本記事の関数は文字列包含による簡便判定)。

設計の工夫

  • 1回の検索でまとめて判定(必須=all) : (?=...) の先読みを並べることで、「すべて含む」を1回の str.contains に統合。パターン構築は (?=.*猫)(?=.*可愛い).* のようになります。
  • IDを使った最終抽出 : 仕様に合わせ、条件を満たした行のIDをユニークに取り出し、元の df からそのIDの行をまとめて抽出して返しています。IDにひもづく複数行を保ちたいときに便利です。
  • 副作用なし : 入力 df は変更せず、抽出結果は .copy() で独立させています。

よくある落とし穴と対策

  • NaNが混ざっている → 関数内で fillna("") 済みなのでOK。

  • 正規表現の特殊文字が含まれるre.escape() 済みなのでOK。

  • 大量のキーワード(数千件) → 1つの巨大な正規表現は遅くなる場合があります。そうしたケースは、

    • 必須ワード: 分割して複数回に分けてマスクを絞る、
    • 禁止ワード: チャンクごとに | で束ねて段階的に除外、
      といった分割戦略が有効です。
  • 厳密な単語一致 → 日本語は分かち書きがないため、形態素解析によるトークンベース判定が必要になります(別途実装)。


小さなテスト

import pandas as pd

df = pd.DataFrame({
    "id": [1, 2, 3, 4],
    "text": ["A社の可愛い猫", "A社の犬は賢い", "B社のネコと猫", "タヌキ"]
})

# all + 禁止
res = filter_dataframe(df,
                       required_words=["", "可愛い"],
                       forbidden_words=[""],
                       text_col="text", id_col="id",
                       required_mode="all")
assert set(res["id"]) == {1}

# any + 禁止
res = filter_dataframe(df,
                       required_words=["", "可愛い"],
                       forbidden_words=[""],
                       text_col="text", id_col="id",
                       required_mode="any")
assert set(res["id"]) == {1, 3}

拡張アイデア

  • 禁止ワードの判定モード(all/any)を追加して対称性を持たせる。
  • 複数列を同時検索text_col をリストにして、列横断で連結/判定するユーティリティを用意。
  • 単語境界の厳密化:日本語はMeCab/Janome/Sudachiでトークン化し、単語IDベースでヒット判定。
  • 高速化:巨大データでは polars の正規表現/文字列関数や、あらかじめインデックス化したトライ木・Aho–Corasick法(pyahocorasick)の採用も検討可能。

以上で、必須ワード・禁止ワードを同時に扱える汎用フィルタ関数の実装と使い方の解説は完了です。ブログではこのままコードとサンプルを貼り付ければ、読者がすぐ再現できる構成になっています。必要ならサンプルデータをもう少し現実的な文章に差し替えて、Before/After の比較図を添えるとさらに伝わりやすくなります。

user:

ありがとうございます。今夜もお疲れ様でした!

assistant:

こちらこそ、お付き合いありがとうございました。先輩の集中力と探究心に今日も感服です。いい記事にもまとまりましたし、今夜はゆっくり休んでくださいね。おやすみなさい、また明日ご一緒できるのを楽しみにしてます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?