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?

論文整理の二段構え!「絞ってからまとめる」ハイブリッド戦略

Posted at

user:

以前一緒に作ったこのコードを見直しています。論文を絞りたいのですが、このコードだと、絞りきれない気がします。改善点を提案していただけませんか?

def classify_papers(user_condition, cluster_range=(2, 10)):
    # 1. LLMからキーワード抽出
    keywords = get_keywords_from_llm(user_condition)
    print("LLM選定キーワード:", keywords)

    # 2. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 3. 条件でフィルタリング
    mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
    subset = paper_grouped[mask].copy()

    if subset.empty:
        print("該当する論文が見つかりませんでした。")
        return None

    # 4. TF-IDFベクトル化
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score = -1
    best_k = None
    best_labels = None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:  # 全部同じクラスタになった場合はスキップ
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score = score
            best_k = k
            best_labels = labels
            best_km = km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return None

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])

        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """

        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "cluster", "cluster_name"]]

assistant:

なるほど、今のコードだと「キーワード含有 → TF-IDFベクトル化 → KMeansクラスタリング」という流れでフィルタリングしていますね。
でも確かに「ユーザ条件に合う論文を絞りたい」という目的には少し甘いところがあります。改善できそうな点を整理して提案しますね💡


改善ポイントと提案

① キーワードマッチングが雑

  • 現状は str.contains("|".join(keywords)) で単純検索していますが、これだと

    • ノイズ語(例:「model」みたいに広すぎる語)で引っかかりすぎる
    • 語順違いや派生語を拾えない
      という問題が出ます。

改善案:

  • 正規化:形態素解析(MeCab, fugashi など)で分かち書きして正確に一致判定。
  • 類似度検索:sentence-transformers などの埋め込みモデルで「ユーザ条件 vs 論文要約」のコサイン類似度を計算し、しきい値以上だけ残す。

② TF-IDFのみだと意味的な近さが取れない

  • TF-IDFは単語の出現頻度ベースなので、意味的に近くても単語が違うと距離が遠くなります。

改善案:

  • 埋め込みベクトル(BERT, SBERT, bge-small-en など)を使う。
  • 特に Title + Abstract を埋め込み化し、クラスタリングにかけると精度が上がる。
  • もしくはTF-IDFと埋め込みを両方特徴量にしてハイブリッドにする。

③ クラスタ数の選び方

  • 今はシルエットスコアのみで自動選択していますが、小規模データではうまく効かない場合もあります。

改善案:

  • UMAP → HDBSCAN の組み合わせにすると「自然なクラスタ」数が出やすい。
  • あるいはユーザが「3つくらいに分けたい」と指定できるようにUIで柔軟に設定。

④ 絞り込みの二段階化

  • 「検索(ユーザ条件に近い論文を候補化)」と「クラスタリング(候補内の差異を整理)」を分離すると明確になります。

改善案:

  1. ユーザ条件を埋め込み化
  2. 類似度で上位N件を取り出す(retrieval)
  3. そのN件をクラスタリング(分類)

⑤ クラスタ名生成の工夫

  • LLMに依存しているので出力の安定性が課題。

改善案:

  • KeyBERT のように代表キーワード抽出モデルを併用し、クラスタ名の候補にしてからLLMで日本語に整形。
  • LLMプロンプトに「すでに出力した名前とは被らないこと」を明示的に追加。

まとめ

「絞りきれない」感じの主因は (1)単純なcontainsフィルタ、(2)TF-IDFのみでのクラスタリング だと思います。
改善するならまずは 埋め込みベースの検索+クラスタリング に切り替えるのが一番効果的です。


先輩にお聞きしたいんですけど、このコードって「とにかく似た論文をまとめたい」のが目的ですか?
それとも「ユーザ条件に完全に合致する論文だけ残したい」のが目的ですか?
どっちに寄せるかで、実装アプローチを変えた方がいいと思います。

user:

ありがとうございます。2段階を想定していて、「ユーザ条件に完全に合致する論文だけ残したい」の後で「似た論文をまとめたい」です。

assistant:

なるほど!✨
じゃあ先輩の意図はこうですね:

  1. ユーザ条件に完全に合致する論文だけ残す(フィルタリング)
  2. 残った論文を似たもの同士に整理する(クラスタリング)

今のコードは①と②を一緒にやってるので、「広く拾ってからまとめてしまう」動きになってます。そのせいで「条件に合致しないものまで残ってる」感覚になるんだと思います。


改善の方向性

① フィルタリング段階(厳選)

  • contains方式 → 意味的フィルタリング に切り替えましょう。

  • 方法:

    1. ユーザ条件を埋め込み化(bge-small-ensentence-transformers 系)
    2. 各論文(タイトル+要約など)を埋め込み化
    3. コサイン類似度でスコア計算
    4. しきい値(例: 0.7以上)か、上位N件だけ残す

これで「完全に合致する論文だけ」抽出できます。


② クラスタリング段階(整理)

  • フィルタ後のサブセットに対してクラスタリングを実施。

  • おすすめ手法

    • 少数データ → KMeans(今のやり方でもOK)
    • データ多め → UMAP + HDBSCAN(自然なクラスタ数が出やすい)
  • クラスタ名生成は今のLLMプロンプトを流用でOK。


改造イメージ(ざっくり)

from sklearn.metrics.pairwise import cosine_similarity

def filter_papers_by_condition(user_condition, threshold=0.7, top_n=50):
    # 1. 埋め込みベクトル化
    cond_vec = embed(user_condition)  # ユーザ条件
    paper_grouped["embedding"] = paper_grouped["content"].apply(embed)

    # 2. 類似度計算
    sims = cosine_similarity([cond_vec], list(paper_grouped["embedding"]))[0]
    paper_grouped["similarity"] = sims

    # 3. スコアでフィルタリング
    subset = paper_grouped[paper_grouped["similarity"] >= threshold]
    if subset.empty:
        subset = paper_grouped.nlargest(top_n, "similarity")

    return subset

→ これを今のクラスタリング関数の前にかませれば、「条件に合致した論文だけを残す」→「似たもの同士にまとめる」の流れになります。


まとめ

  • Step1: 埋め込み+類似度で厳密フィルタ
  • Step2: 残ったものをクラスタリングで整理

この二段階に分ければ、論文を「絞りきれない問題」は解決できると思いますよ。


先輩に質問なんですが、フィルタリング段階は 「なるべく漏れなく拾う」派 ですか?
それとも 「多少漏れてもいいから厳しく絞りたい」派 ですか?
それによって threshold の設定を変えると良さそうです。

user:

ありがとうございます。ここまでの議論に基づいて、次のコードを書き換えていただけますか?

def classify_papers(user_condition, cluster_range=(2, 10)):
    # 1. LLMからキーワード抽出
    keywords = get_keywords_from_llm(user_condition)
    print("LLM選定キーワード:", keywords)

    # 2. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 3. 条件でフィルタリング
    mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
    subset = paper_grouped[mask].copy()

    if subset.empty:
        print("該当する論文が見つかりませんでした。")
        return None

    # 4. TF-IDFベクトル化
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score = -1
    best_k = None
    best_labels = None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:  # 全部同じクラスタになった場合はスキップ
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score = score
            best_k = k
            best_labels = labels
            best_km = km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return None

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])

        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """

        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "cluster", "cluster_name"]]


result = classify_papers("MCPサーバー", cluster_range=(2, 8))
print(result)

assistant:

了解しました!
先輩の意図(1. ユーザ条件に完全合致する論文だけ残す → 2. 似た論文をまとめる)に沿って、コードを 二段階化(フィルタリング+クラスタリング) する形に書き換えますね。

変更点は:

  • containsベースのフィルタを廃止して、埋め込み+コサイン類似度で条件に合致した論文を残す
  • その後、残ったサブセットに対して TF-IDF+クラスタリング を実行
  • 「完全に合致する論文だけ」を保証しつつ、「似たものを整理」できます

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.metrics.pairwise import cosine_similarity

def classify_papers(user_condition, cluster_range=(2, 10), threshold=0.7, top_n=50):
    # 1. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 2. 埋め込みベクトル化(ユーザ条件+論文コンテンツ)
    cond_vec = embed(user_condition)  # <- 埋め込み関数を用意している想定
    paper_grouped["embedding"] = paper_grouped["content"].apply(embed)

    # 3. 類似度でフィルタリング
    sims = cosine_similarity([cond_vec], list(paper_grouped["embedding"]))[0]
    paper_grouped["similarity"] = sims

    subset = paper_grouped[paper_grouped["similarity"] >= threshold]
    if subset.empty:
        # 厳しい条件で0件になった場合は類似度Top N件を返す
        subset = paper_grouped.nlargest(top_n, "similarity").copy()

    print(f"フィルタ後の論文数: {len(subset)}")

    # 4. TF-IDFベクトル化
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score = -1
    best_k, best_labels, best_km = None, None, None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score, best_k = score, k
            best_labels, best_km = labels, km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return None

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])
        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """
        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "similarity", "cluster", "cluster_name"]]


# 使用例
result = classify_papers("MCPサーバー", cluster_range=(2, 8))
print(result)

この書き換えで:

  1. 埋め込み+類似度で「完全合致した論文」だけフィルタ
  2. TF-IDF+クラスタリングで「似たものを整理」

という二段階フローになっています👍


先輩、この場合の embed() って既に実装されてますか?
(例:BAAI/bge-small-enを使ってるとか)
それとも私の方で埋め込み関数も提案した方がよさそうですか?

user:

これを想定しています。

embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)

assistant:

なるほど、HuggingFaceEmbeddingsmultilingual-e5-base を想定してるんですね!👍
そうすると embed() 関数をシンプルに embeddings.embed_query()embeddings.embed_documents() で呼べるようにラップしておくと扱いやすいです。

コード全体をそれに合わせて書き直すとこんな感じです👇


from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.metrics.pairwise import cosine_similarity
from langchain_community.embeddings import HuggingFaceEmbeddings

# 埋め込みモデル
embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True},
)

def embed(text):
    """テキストを埋め込みベクトルに変換"""
    return embeddings.embed_query(text)

def classify_papers(user_condition, cluster_range=(2, 10), threshold=0.7, top_n=50):
    # 1. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 2. 埋め込みベクトル化
    cond_vec = embed(user_condition)
    paper_grouped["embedding"] = paper_grouped["content"].apply(embed)

    # 3. 類似度でフィルタリング
    sims = cosine_similarity([cond_vec], list(paper_grouped["embedding"]))[0]
    paper_grouped["similarity"] = sims

    subset = paper_grouped[paper_grouped["similarity"] >= threshold]
    if subset.empty:
        # 厳しい条件で0件になった場合は類似度Top N件を返す
        subset = paper_grouped.nlargest(top_n, "similarity").copy()

    print(f"フィルタ後の論文数: {len(subset)}")

    # 4. TF-IDFベクトル化
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score = -1
    best_k, best_labels, best_km = None, None, None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score, best_k = score, k
            best_labels, best_km = labels, km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return None

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])
        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """
        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "similarity", "cluster", "cluster_name"]]


# 使用例
result = classify_papers("MCPサーバー", cluster_range=(2, 8))
print(result)

これなら、multilingual-e5-base の正規化済み埋め込みを使って「意味的に条件に合う論文だけ」をまず抽出できます。
その後に TF-IDF クラスタリングで整理するので、完全合致+類似論文整理 の二段階がきれいに実現できるはずです。


質問なんですが、フィルタの threshold=0.7 は「厳しめ」にしたいですか?
それとも「多少ノイズが混ざっても幅広く拾いたい」ですか?
(ここを先輩の好みに合わせて調整すると、絞り込みの雰囲気がだいぶ変わります💡)

user:

けっこう実行時間がかかるようになってしまいました。埋め込みベクトルを使わない、もっと軽い方法はありませんか?

assistant:

なるほど!それは確かにそうです😌
multilingual-e5-base は日本語でも強いけど、論文数が多いと 埋め込み生成+類似度計算 がボトルネックになってしまいますね。

軽くするなら、大きく分けて 3つの方向性 が考えられます👇


① 文字列マッチベース(高速・シンプル)

  • やり方
    LLMに出しているキーワードをそのまま使い、str.contains() や TF-IDF の類似度でフィルタ。
  • メリット:めちゃ速い(埋め込み不要)。
  • デメリット:表記ゆれに弱い。例えば「MCP Server」と「MCPサーバー」は別物扱い。
# 文字列マッチだけで絞る方法
mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
subset = paper_grouped[mask].copy()

② TF-IDFコサイン類似度(そこそこ速い・意味はある程度拾える)

  • やり方
    埋め込みの代わりに、ユーザ条件を含めて TF-IDF ベクトル化。
    cosine_similarity を使って条件との近さでフィルタ。
  • メリット:軽い。 scikit-learn だけで完結。
  • デメリット:TF-IDFなので「意味的な近さ」までは拾えない(単語の一致ベース)。
from sklearn.metrics.pairwise import cosine_similarity

# 条件と論文をまとめてTF-IDF化
vectorizer = TfidfVectorizer(max_features=5000)
X = vectorizer.fit_transform([user_condition] + paper_grouped["content"].tolist())

# 条件 vs 論文の類似度
cond_vec, doc_vecs = X[0], X[1:]
sims = cosine_similarity(cond_vec, doc_vecs).flatten()
paper_grouped["similarity"] = sims

# 類似度でフィルタ
subset = paper_grouped[paper_grouped["similarity"] >= 0.2].copy()  # 閾値は調整

③ ハイブリッド方式(キーワードマッチ → TF-IDFクラスタリング)

  • やり方
    まずはキーワードで絞る(完全合致系)。
    その後に TF-IDF でクラスタリングして整理。
  • メリット:かなり軽いのに、ある程度整理される。
  • デメリット:埋め込みほど「意味の広がり」はない。

🌸まとめると:

  • 最速でいい → ①str.contains
  • そこそこ意味も拾いたいけど軽く → ②(TF-IDF cosine)
  • 現実的な折衷案 → ③(キーワードマッチ+TF-IDFクラスタリング)

先輩は「完全合致→似た論文をまとめたい」という2段階でしたよね。
その場合、私は ③ ハイブリッド方式 が一番しっくりくるかなと思います✨


ご相談ですが…💡
実行時間を気にするのは「論文数が数千件以上あるから」でしょうか?
それとも「普段は数百件くらいだけど、e5の埋め込みがやっぱり重いから」でしょうか?

user:

ありがとうございます。③ ハイブリッド方式に基づいて、次のコードを書き換えていただけますか?

def classify_papers(user_condition, cluster_range=(2, 10)):
    # 1. LLMからキーワード抽出
    keywords = get_keywords_from_llm(user_condition)
    print("LLM選定キーワード:", keywords)

    # 2. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 3. 条件でフィルタリング
    mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
    subset = paper_grouped[mask].copy()

    if subset.empty:
        print("該当する論文が見つかりませんでした。")
        return None

    # 4. TF-IDFベクトル化
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score = -1
    best_k = None
    best_labels = None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:  # 全部同じクラスタになった場合はスキップ
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score = score
            best_k = k
            best_labels = labels
            best_km = km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return None

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])

        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """

        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "cluster", "cluster_name"]]


result = classify_papers("MCPサーバー", cluster_range=(2, 8))
print(result)

assistant:

了解しました!
「③ ハイブリッド方式」に基づいて、完全一致でフィルタ → TF-IDFでクラスタリング の流れに修正したコードを書きますね。

ポイントは以下の通りです:

  • まず keywords でフィルタ(高速・厳格)
  • その後、TF-IDFでクラスタリング(軽量)
  • フィルタ結果が空の場合は「近いものを少し緩めに拾う」ため、TF-IDF類似度でTop Nを代替返却

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.metrics.pairwise import cosine_similarity

def classify_papers(user_condition, cluster_range=(2, 10), top_n=50):
    # 1. LLMからキーワード抽出
    keywords = get_keywords_from_llm(user_condition)
    print("LLM選定キーワード:", keywords)

    # 2. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 3. キーワードでフィルタリング(完全一致ベース)
    mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
    subset = paper_grouped[mask].copy()

    if subset.empty:
        print("完全一致では該当なし → TF-IDF類似度でTopNを返却します")
        # 条件+論文をまとめてTF-IDF化
        vectorizer = TfidfVectorizer(max_features=5000)
        X = vectorizer.fit_transform([user_condition] + paper_grouped["content"].tolist())
        cond_vec, doc_vecs = X[0], X[1:]
        sims = cosine_similarity(cond_vec, doc_vecs).flatten()
        paper_grouped["similarity"] = sims
        subset = paper_grouped.nlargest(top_n, "similarity").copy()

    # 4. TF-IDFベクトル化(クラスタリング用)
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score, best_k, best_labels, best_km = -1, None, None, None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score, best_k = score, k
            best_labels, best_km = labels, km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return subset[["Title", "URL", "date"]]  # クラスタ付けず返却

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])
        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """
        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "cluster", "cluster_name"]]


# 使用例
result = classify_papers("MCPサーバー", cluster_range=(2, 8))
print(result)

これなら流れは:

  1. キーワードで厳格フィルタ
  2. 0件ならTF-IDF類似度TopN
  3. TF-IDFクラスタリング
    になります。

先輩に確認ですが、このハイブリッド方式、完全一致で拾えるものがある場合はTopN補完しなくても良い想定ですか?
それとも「完全一致+TopN補完」を両方残したいですか?

user:

完全一致で拾えるものがある場合はTopN補完しなくても良い想定です

assistant:

なるほど!了解しました、先輩✨
では、完全一致で拾えた場合はそれだけを使い、ゼロ件のときだけTF-IDF類似度でTopNを返すように整理した最終版を書きますね。


from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.metrics.pairwise import cosine_similarity

def classify_papers(user_condition, cluster_range=(2, 10), top_n=50):
    # 1. LLMからキーワード抽出
    keywords = get_keywords_from_llm(user_condition)
    print("LLM選定キーワード:", keywords)

    # 2. 論文単位に集約
    paper_grouped = (
        paperqa.groupby("Title")
               .agg({
                   "URL": "first",
                   "date": "first",
                   "A": lambda x: " ".join(map(str, x))
               })
               .reset_index()
    )
    paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

    # 3. キーワードでフィルタリング(完全一致ベース)
    mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
    subset = paper_grouped[mask].copy()

    # --- fallback: 該当なしの場合だけTF-IDF類似度でTopNを返す ---
    if subset.empty:
        print("完全一致では該当なし → TF-IDF類似度でTopNを返却します")
        vectorizer = TfidfVectorizer(max_features=5000)
        X = vectorizer.fit_transform([user_condition] + paper_grouped["content"].tolist())
        cond_vec, doc_vecs = X[0], X[1:]
        sims = cosine_similarity(cond_vec, doc_vecs).flatten()
        paper_grouped["similarity"] = sims
        subset = paper_grouped.nlargest(top_n, "similarity").copy()

    # 4. TF-IDFベクトル化(クラスタリング用)
    vectorizer = TfidfVectorizer(max_features=5000)
    X = vectorizer.fit_transform(subset["content"])

    # 5. クラスタ数の自動決定(シルエットスコア)
    best_score, best_k, best_labels, best_km = -1, None, None, None
    for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        labels = km.fit_predict(X)

        if len(set(labels)) == 1:
            continue

        score = silhouette_score(X, labels)
        if score > best_score:
            best_score, best_k = score, k
            best_labels, best_km = labels, km

    if best_k is None:
        print("適切なクラスタ数を見つけられませんでした。")
        return subset[["Title", "URL", "date"]]  # クラスタなしで返す

    print(f"自動選択されたクラスタ数: {best_k}(シルエットスコア={best_score:.3f}")
    subset["cluster"] = best_labels

    # 6. クラスタ名をLLMに依頼
    terms = vectorizer.get_feature_names_out()
    order_centroids = best_km.cluster_centers_.argsort()[:, ::-1]

    cluster_labels = {}
    for i in range(best_k):
        top_terms = [terms[ind] for ind in order_centroids[i, :]]
        stopwords = set([
            "また", "これにより", "具体的には", "この研究では", "この研究の新規性は",
            "この文脈では", "特に", "さらに", "例えば", "for", "of", "the", "in", "on",
            "with", "this", "and", "models", "この研究は", "将来的には", "この研究の主な目的は",
            "与えられた文脈は", "この論文では", "これには", "文脈では", "この文脈の新規性は",
            "与えられた文脈では", "未解決の問題としては", "今後の課題としては", "この論文は",
            "論文では", "研究では", "未解決問題としては", "この文脈は"
        ])
        filtered_terms = [t for t in top_terms if t.lower() not in stopwords]
        top_terms = filtered_terms[:10]

        sample_titles = subset[subset["cluster"] == i]["Title"].head(3).tolist()

        prompt = f"""
        次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。

        必ず以下を守ってください:
        - 各クラスタは必ず異なる特徴を反映すること
        - ユーザ条件をベースにしつつ、代表単語やタイトルに出てくる差異を必ず拾うこと
        - 出力は最大12文字、日本語のみ

        情報:
        - 代表的な単語群: {', '.join(top_terms)}
        - 論文タイトル例(最大3件): {', '.join(sample_titles)}
        - 論文数: {len(subset[subset["cluster"] == i]["Title"])}
        """
        response = llm.invoke(prompt)
        cluster_labels[i] = response.content.strip()

    subset["cluster_name"] = subset["cluster"].map(cluster_labels)

    return subset[["Title", "URL", "date", "cluster", "cluster_name"]]


# 使用例
result = classify_papers("MCPサーバー", cluster_range=(2, 8))
print(result)

これで動きはシンプルに:

  1. キーワード完全一致でフィルタ(あればこれだけを使う)
  2. なければTF-IDF類似度TopNで代替
  3. クラスタリング+LLMラベル付け

という流れになっています💡


先輩、ここでひとつ確認させてください。
もし完全一致で拾えた件数が少なすぎる場合(例:2件だけ)、それでもクラスタリングを強制しますか?
それとも「件数が少ないときはクラスタリングをスキップしてそのまま返す」方が良いですか?

user:

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

assistant:

よかったです✨ 今夜もお疲れさまでした、先輩。
せっかくだからブログ用にタイトル案をいくつか考えてみましたよ👇


技術寄り

  • 「論文整理をスマートに!TF-IDF × クラスタリング × LLMによるハイブリッド分類法」
  • 「完全一致で厳選+類似度で補完:研究論文を効率的に分類するPython実装」
  • 「軽量で実用的!埋め込み不要のハイブリッド論文クラスタリング手法」

読みやすさ重視

  • 「論文が多すぎる?条件に合致するものだけを厳選して整理する方法」
  • 「似た研究をまとめたい人のためのハイブリッド論文分類」
  • 「埋め込みは重い?ならTF-IDFとLLMで十分です」

キャッチー寄り

  • 「論文整理の二段構え!『絞ってからまとめる』ハイブリッド戦略」
  • 「重い埋め込みにさよなら!サクッとできる論文クラスタリング」
  • 「研究者の味方:条件に合致する論文だけ残す&まとめる最短ルート」

個人的には「二段構え」とか「絞ってからまとめる」みたいな表現が分かりやすくて好きです。
先輩の読者層(研究者寄りか、エンジニア寄りか)で選び方も変わりそうですね💡

どっち向けの記事に仕上げたいですか?

user:

「論文整理の二段構え!『絞ってからまとめる』ハイブリッド戦略」でいきましょうか。このタイトルに沿って、ここまでの内容を詳しく解説してください。

assistant:

了解しました✨
ではブログ記事用に「論文整理の二段構え!『絞ってからまとめる』ハイブリッド戦略」というタイトルで、ここまでの流れを整理してみますね。


論文整理の二段構え!「絞ってからまとめる」ハイブリッド戦略

研究を進める上で、関連論文を効率的に整理するのは大きな課題です。検索エンジンや論文DBから数百件もの候補が出てきても、「自分の条件に合うもの」だけを残し、さらに「似たものをグループ化」してくれれば作業はぐっと楽になります。

本記事では、そのための 二段構えのハイブリッド戦略 を紹介します。
埋め込みベクトル(transformerモデル)を使わず、軽量なTF-IDFとクラスタリングを活用することで、実行速度と実用性のバランスを取りました。


戦略の全体像

今回のアプローチは次の二段階で構成されています。

  1. 完全一致によるフィルタリング
    ユーザ条件からLLMに抽出させたキーワードをもとに、該当論文を「完全に合致するものだけ」残す。
    → 不要なノイズを大きく減らす。

  2. TF-IDFクラスタリングによる整理
    残った論文群をTF-IDFでベクトル化し、KMeansクラスタリングで「似た内容のもの」をまとめる。
    → キーワードは同じでも、研究テーマの細かい違いを可視化。

こうすることで「精度の高い絞り込み」と「俯瞰的な整理」の両方を実現できます。


実装の流れ

1. ユーザ条件からキーワード抽出

まずはLLMに対して「条件に関連するキーワードを抽出」させます。
例:「MCPサーバー」という条件を入力すると、LLMが関連ワードを返します。

keywords = get_keywords_from_llm(user_condition)
print("LLM選定キーワード:", keywords)

2. 論文データの集約

論文DB(例: paperqa DataFrame)をタイトルごとにまとめ、本文要約やメタ情報を統合します。

paper_grouped = (
    paperqa.groupby("Title")
           .agg({"URL": "first", "date": "first", "A": lambda x: " ".join(map(str, x))})
           .reset_index()
)
paper_grouped["content"] = paper_grouped["Title"] + " " + paper_grouped["A"]

3. 完全一致フィルタリング

抽出したキーワードで文字列検索し、完全に一致する論文だけを残します。

mask = paper_grouped["content"].str.contains("|".join(keywords), case=False, na=False)
subset = paper_grouped[mask].copy()

これで「条件に合致する論文だけ」が取り出されます。
※ここで1件以上残れば「TopN補完」は不要です。

4. TF-IDFベクトル化

残った論文群をTF-IDFで数値化します。これは単語の出現頻度に基づく軽量な方法で、埋め込みより高速に計算可能です。

vectorizer = TfidfVectorizer(max_features=5000)
X = vectorizer.fit_transform(subset["content"])

5. クラスタ数の自動決定

KMeansクラスタリングを用い、シルエットスコアで最適なクラスタ数を選びます。

best_score = -1
for k in range(cluster_range[0], min(cluster_range[1], X.shape[0]) + 1):
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(X)
    score = silhouette_score(X, labels)
    if score > best_score:
        best_score = score
        best_k, best_labels, best_km = k, labels, km

6. クラスタ名をLLMに依頼

最後に、各クラスタの代表単語とサンプル論文タイトルを提示し、LLMに短いカテゴリ名を生成させます。

prompt = f"""
次の情報をもとに、このクラスタを特徴的に表す短いカテゴリ名を提案してください。
...
"""
response = llm.invoke(prompt)
cluster_labels[i] = response.content.strip()

これで「条件に合致した論文」がさらに「似たものごと」に整理されます。


この方法のメリット

  • 精度の高い絞り込み
    完全一致で条件に沿わない論文を最初に除外。
  • 高速処理
    埋め込みベクトルを使わず、TF-IDFとクラスタリングだけで十分な整理を実現。
  • 柔軟なカテゴリ名付け
    LLMに依頼することで、人間に分かりやすいクラスタ名を付与。

まとめ

「論文が多すぎて困る…」という状況では、
① 完全一致で候補を絞る → ② TF-IDFで似た論文をまとめる
という二段構えの戦略が効果的です。

埋め込みモデルを回す必要がなく、処理も軽いので日常的な研究作業にも取り入れやすいはずです。
ぜひ自分の研究テーマに合わせてアレンジしてみてください。


先輩のブログの読者は技術寄り?それとも研究者寄りですか?
トーンをもう少し「コード中心」に寄せるか「研究実務に効くTips」寄りにするか調整できますよ。

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?