見出し画像

総合特許情報分析システム | Saturn V:技術の発掘と研究探索を導く戦略分析可視化ツール

2025/11/13 バグを修正し、公開コードを更新しました。


1. はじめに

特許情報分析には、大きく分けて二つの側面が存在する。一つは、特許群が内包する技術的な意味内容を読み解き、技術の全体像や関連性を把握する「セマンティック(意味)分析」である。もう一つは、出願人や出願年、技術分類といった書誌情報を集計し、競合他社の動向や技術の盛衰を把握する「統計分析(特許マップ)」である。

従来、これらの分析は分断されがちであった。セマンティックな可視化ツールは統計分析に弱く、逆に伝統的な特許マップ(パテントマップ)ツールは、技術の「意味」のレベルで文書を精密に分類することが難しかった。

筆者はこれまで、前者の「意味分析」に特化したツールとして「PATI Radar」を開発・公開してきた。PATI Radarは、テキストマイニングにより文書間の意味的な近接性を計算し、2Dマップ上に可視化するものであった。

今回開発した「Saturn V」は、このPATI Radarの思想を後継し、その中核であるセマンティック分析能力を第一段階(1st Stage)としつつ、そこからシームレスに「統計分析」フェーズへと移行できる多段階分析が可能な「総合特許情報分析システム」として設計したものである。本記事では、このSaturn Vの設計思想と、その多段階分析がもたらす価値について詳述する。

2. Saturn V:多段階分析というコンセプト

Saturn Vの名称は「Strategic Analytics for Technology Uncovering & Research Navigation Visualizer」の頭文字に由来する。

この名称は、かつて人類を月へと到達させた多段階式ロケット「Saturn V」にちなんでいる。巨大なロケットが、不要な部分を切り離しながら高度を上げていくように、本システムもまた、分析のフェーズに応じて手法を切り替える「多段階分析」をコンセプトとしている。

  • 第一段階(1st Stage: 俯瞰): まず、分析対象の特許群全体を「セマンティック・マップ」として2D空間に配置する。これは、SBERT(後述)という言語モデルを用いて、技術全体の大まかな大陸と島々(=技術クラスタ)を俯瞰する工程である。

  • 第二段階(2nd Stage: 詳細化): 次に、第一段階で特定した関心のあるクラスタ(例えば「二次電池」という巨大な島)に対して「ドリルダウン分析」を実行する。これは、その島に属するデータだけを抽出し、再度マッピングを行うことで、島の内部構造(例:「正極材」「負極材」「電解液」といった村)を詳細に可視化する工程である。

  • 第三段階(3rd Stage: 定量化): 最後に、第一段階で特定した「メインクラスタ」を対象として「特許マップ(統計分析)」機能に送る。これにより、「その主要分野では、誰が、いつから活発なのか」を時系列グラフや出願人ランキングといった定量的なデータで裏付ける工程である。

この「俯瞰 → 詳細化 → 定量化」という一連の分析フローを、Google Colabという単一のUI上で実行できる点が、Saturn Vの設計上の核心である。

3. ハイブリッド・パイプライン

Saturn Vの分析精度は、「ハイブリッド・パイプライン」と呼ぶ設計思想によって支えられている。これは、マップ作成における「位置決め」「クラスタ生成」「名前付け」というタスクに対し、それぞれ最適なアルゴリズムを採用するものである。

(a) 位置決め:SBERT + IPC

マップ上の特許の位置(座標)は、その特許が持つ「意味」によって決定されねばならない。Saturn Vでは、このために「Sentence-Transformer (SBERT)」という高性能な言語モデルを採用している。

SBERTは、単語の表面的な一致ではなく、文脈全体を理解して文書を高次元の「意味ベクトル」に変換する。これにより、例えば「EV」という単語しか含まない特許と、「電気自動車」という単語しか含まない特許であっても、両者が「意味的に近い」と判断し、マップ上で近傍に配置することが可能となる。

さらにSaturn Vでは、このSBERTの入力テキストに、「発明の名称」「要約」「請求項」といった自然言語のテキストだけでなく、特許分類コードである「IPC」も加えて学習させる。これにより、テキストだけでは表現しきれない技術的な含意も座標計算に反映させ、より精度の高いマッピングを実現している。

(b) クラスタ生成:HDBSCANと2つの主要パラメータ

SBERTが生成した高次元の意味ベクトルは、UMAPによって2次元に削減され、マップ上に配置される。しかし、この時点では特許は「点」として散らばっているにすぎない。Saturn Vは、これらの「点」を意味のある「島(クラスタ)」に分類するためにHDBSCANというアルゴリズムを採用している。

HDBSCANは、密度ベースのクラスタリング手法であり、その最大の利点は「ノイズ」を自動で識別できる点にある。この挙動を制御するのが、「最小クラスタサイズ」と「最小サンプル数」である。これらは特許情報ストラテジストの分析意図を反映させるための、最も重要な調整レバーとなる。

最小クラスタサイズ (min_cluster_size)

これは「一つの独立したクラスタ(島)として認識するために必要な、最低限の特許件数」を定義する。

  • 値を大きく設定した場合(例:30): 小さな特許群はノイズとして扱われるか、より大きなクラスタに吸収される。結果として、マップ全体は「主要な技術大陸」とでも言うべき、少数の巨大なクラスタに分割される。これは、技術分野の「全体像」や「本流」をマクロに把握したい場合に有効である。

  • 値を小さく設定した場合(例:5): わずか5件の特許群であっても、それが独立した密度を持っていれば一つのクラスタとして認識される。結果として、マップは多数の小さなクラスタに細分化される。これは、大手企業が見落としている「ニッチ技術」や、始まったばかりの「萌芽的技術」を発見したい場合に極めて有効である。

分析者の観点では、この値は分析の「解像度」である。

最小サンプル数 (min_samples)

これは「クラスタの『核(コア)』と見なされるために必要な、周辺の密度」を定義する。技術的には「ある点がコア点であると見なされるための近傍点の数」を意味する。

この値は、「最小クラスタサイズ」よりも直感的ではないが、HDBSCANの「ノイズ耐性」を決定する重要なパラメータである。

  • 値を大きく設定した場合(例:15): よほど密度の高い特許群でなければ「核」とは見なされず、多くの特許が「ノイズ(cluster_id = -1)」として分類される。これは、分析対象データに無関係な特許(例:検索ノイズ)が非常に多い場合や、極めて安定的で強固な技術テーマの「中心地」だけを抽出したい場合に有効な、保守的な設定である。

  • 値を小さく設定した場合(例:5): 比較的まばらな領域でもクラスタの「核」として認識されやすくなり、ノイズとして分類される特許が減少する。特許分析においては、min_cluster_sizeよりも小さい値(例:5〜10)に設定し、アルゴリズムが技術の「核」を柔軟に発見できるようにするのが一般的である。

分析者の観点では、これは分析の「保守性」あるいは「ノイズフィルタの強度」を調整するレバーである。これらファクタを調整し、ノイズの量とクラスタの粒度が分析目的に合致するよう試行錯誤することが、Saturn Vを使いこなす鍵となる。

(c) 名前付け:TF-IDF(名詞限定)

SBERTが意味の「近さ」を測るのに優れている一方で、「クラスタの命名」には不向きな側面がある。例えば、あるクラスタに「電池」に関する特許と「蓄電」に関する特許が混在している場合、SBERTは両者を「近い意味」として扱うため、どちらがクラスタの「名前」としてふさわしいか区別がつきにくい。

ラベリングに必要なのは「意味が近い単語」ではなく、「そのクラスタを最も象徴する特徴的な単語」である。このタスクには、古典的だが強力な「TF-IDF」が適している。

Saturn Vでは、ラベリング専用のパイプラインとしてTF-IDFを採用し、さらにラベルの品質を向上させるため、以下の処理を行っている。

  1. IPCの除外:ラベリングの入力テキストからはIPCを除外する(IPCがラベルとして表示されても解読不能なため)。

  2. 名詞への限定:Janome(形態素解析ライブラリ)を用い、テキストから「名詞」のみを抽出する。「示す」「有する」といった動詞や助詞はノイズとして除去する。

この結果、SBERTが文書の「意味」に基づいて位置を決定し、HDBSCANが分析者の意図で「分類」し、TF-IDFがそのクラスタの「特徴」を名詞で的確に表現するという、アルゴリズムの長所を活かした高精度な分析が実現される。

4. 実行環境と主な機能

Saturn Vは、Google Colab上で動作する。その核心技術であるSBERTによる意味ベクトルの計算は、非常に高い計算負荷を要求する。CPUランタイムでも動作は可能だが、千件程度の特許であっても処理に数十分を要する場合がある。快適な分析のため、Colabの「ランタイム > ランタイムのタイプを変更」メニューから、ハードウェアアクセラレータとして「T4 GPU」を選択することを強く推奨する。

以下に、GPU環境での実行を前提とした主な機能を示す。

(a) E. セマンティック・マップ(メイン)

システムの中心となるSBERTベースのメインマップである。「B. フィルタリング設定」ウィジェット群と動的に連動している。 例えば、出願人フィルタで「A社」と「B社」を選択すると、マップ上の全特許のうち、A社とB社の特許のみがハイライト(または色分け)表示される。これにより、「A社はマップの左側(例:モータ制御)に集中しているが、B社はマップの右下(例:電池材料)に集中している」といった、各社の技術的領土を直感的に把握できる。

(b) F. クラスタ ドリルダウン分析

メインマップ(第一段階)で発見したクラスタが、依然として大きすぎる(例:500件の特許が「制御システム」という一つの島になっている)場合に使用する。 Fセクションのドロップダウンから対象クラスタを選択し「再マップ」を実行すると、そのクラスタに属する特許データのみを対象として、再度SBERTベクトルのスライス、UMAPによる次元削減、HDBSCANによるクラスタリングが実行される。 これにより、「制御システム」という一つの巨大な島が、「姿勢制御」「モータ制御」「熱制御」といった、より詳細な技術の群島に分解され、分析の解像度を一段階深めることができる。

(c) G, F-4. ラベル編集機能

TF-IDFによる自動ラベリングは強力であるが、完璧ではない。時には「[1] デバイス, 半導体, 層」のような、やや一般的すぎるラベルが生成されることもある。 Saturn Vでは、分析者のドメイン知識を最終結果に反映させるため、メインマップ(G)とドリルダウンマップ(F-4)の両方で、自動生成されたラベルを上書き編集できるUIを備えている。分析者が「GaNパワー半導体」のように具体的な名称に変更することで、最終的な分析レポートの説得力を高めることが可能である。この作業は、対応するクラスタの要約などを生成AIに読み込ませ、ラベリング名を考えてもらい手法が有用だろう。

(d) H. 特許マップ(統計分析)

Saturn Vの第三段階であり、セマンティック分析と統計分析を橋渡しする機能である。 Bセクション(期間・出願人)とHセクション(クラスタ)で分析対象を絞り込むと、Matplotlibによって以下の3つのグラフが自動描画される。

  • H-1: 出願件数時系列推移

  • H-2: 出願人ランキング

  • H-3: 出願年別出願人動向

注意点として、本セクションの集計対象は「3. 描画」で作成された「メインクラスタ」であり、Fセクションでドリルダウンした後の「サブクラスタ」を直接集計対象として選択することはできない。

(e) フィルタの分離設計

Saturn Vの操作性における重要な点として、セマンティック・マップ(E)のクラスタフィルタと、特許マップ(H)のクラスタフィルタは、意図的に分離・独立させている。 これにより、例えば「セマンティック・マップ上ではクラスタAとBのみを表示」させつつ、「特許マップでは全クラスタを対象に出願人ランキングを集計する」といった、柔軟な分析操作が可能となっている。

5. Saturn V 簡易チュートリアル

Saturn Vの基本的な分析フローを以下に示す。

  1. 【環境設定】Google Colabを開き、「ランタイム > ランタイムのタイプを変更」で「T4 GPU」を選択する。

  2. 【コード実行】Saturn Vのコードをすべて実行する。UIが表示される。

  3. 【データ投入】「A. ファイル設定」の「1. CSVをアップロード」ボタンを押し、分析対象の特許リスト(CSV)をアップロードする。

  4. 【カラム設定】アップロードが完了すると、ドロップダウンが更新される。分析に必要なカラム(発明の名称、要約、請求項、IPC、出願日、出願人など)をそれぞれ正しく指定する。

  5. 【前処理(SBERT計算)】「2. 前処理 & フィルタ準備」ボタンを押す。SBERTによるベクトル計算が開始される(GPU環境でも、データ量によっては数分を要する)。

  6. 【Stage 1: メインマップ描画】「C. クラスタリング実行」にて、「最小クラスタサイズ」(例:15)と「最小サンプル数」(例:10)を分析意図に合わせて設定する。 「3. 描画 (再計算)」ボタンを押す。「E. 分析結果」にメインマップが描画される。

  7. 【Stage 2: ドリルダウン】(オプション)「F. クラスタ ドリルダウン分析」セクションで、分析したいメインクラスタをドロップダウンから選択し、「6. 選択クラスタで再マップ」ボタンを押す。Fセクション内にサブクラスタ・マップが描画される。

  8. 【Stage 3: 統計分析】「H. 特許マップ(統計分析)」セクションに進む。

    • (a) 「集計対象クラスタ」で、分析したいメインクラスタを選択する(デフォルトは全クラスタ)。

    • (b) 「(フィルタ結果から期間を自動設定)」ボタンを押し、選択したクラスタの出願期間を「開始年」「終了年」に自動反映させる。その後、ご所望の期間を設定する。

    • (c) 「9. 特許マップを描画」ボタンを押す。H-1〜H-3のグラフが描画される。

6. コード

# 必要なライブラリをインストールします
!pip install janome scikit-learn pandas plotly umap-learn scipy hdbscan sentence-transformers japanize-matplotlib -q
print("✅ ライブラリの準備が完了しました。")

# サードパーティ製ウィジェットの実行を明示的に許可します
from google.colab import output
output.enable_custom_widget_manager()

import pandas as pd
import unicodedata
import re
import umap
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
import hdbscan # HDBSCANをインポート
from sentence_transformers import SentenceTransformer # SBERTをインポート

# 特許マップ用のライブラリ
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
import japanize_matplotlib # 日本語化

from google.colab import files, data_table
from IPython.display import display, clear_output
import ipywidgets as widgets
from ipywidgets import Layout, VBox, HBox, Dropdown, Text, FloatText, IntText, Button, Output, SelectMultiple, ColorPicker, Checkbox, Accordion
from io import BytesIO

# Colabのデータフレーム表示をリッチにします
data_table.enable_dataframe_formatter()

# --- 共通の関数 (クラスの外に定義) ---

t = Tokenizer()

# (注意: 以下のストップワードリストは最小限です。分析品質のため、実行時は完全なリストに差し替えてください)
stop_words = {
    "する","ある","なる","ため","こと","よう","もの","これ","それ","あれ","ここ","そこ","どれ","どの","この","その","当該","該","および","及び","または","また","例えば","例えばは","において","により","に対して","に関して","について","として","としては","場合","一方","他方","さらに","そして","ただし","なお","等","など","等々","いわゆる","所謂","同様","同時","前記","本","同","各","各種","所定","所望","一例","他","一部","一つ","複数","少なくとも","少なくとも一つ","上記","下記","前述","後述","既述","関する","基づく","用いる","使用","利用","有する","含む","備える","設ける","すなわち","従って","しかしながら","次に","特に","具体的に","詳細に","いずれ","うち","それぞれ","とき","かかる","かような","かかる場合","本件","本願","本出願","本明細書","これら","それら","各々","随時","適宜","任意","必ずしも","通常","一般に","典型的","代表的",
    "本発明","発明","実施例","実施形態","変形例","請求","請求項","図","図面","符号","符号の説明","図面の簡単な説明","発明の詳細な説明","技術分野","背景技術","従来技術","発明が解決しようとする課題","課題","解決手段","効果","要約","発明の効果","目的","手段","構成","構造","工程","処理","方法","手法","方式","システム","プログラム","記憶媒体","特徴","特徴とする","特徴部","ステップ","フロー","シーケンス","定義","関係","対応","整合", "実施の形態","実施の態様","態様","変形","修正例","図示","図示例","図示しない","参照","参照符号","段落","詳細説明","要旨","一実施形態","他の実施形態","一実施例","別の側面","付記","適用例","用語の定義","開示","本開示","開示内容",
    "上部","下部","内部","外部","内側","外側","表面","裏面","側面","上面","下面","端面","先端","基端","後端","一端","他端","中心","中央","周縁","周辺","近傍","方向","位置","空間","領域","範囲","間隔","距離","形状","形態","状態","種類","層","膜","部","部材","部位","部品","機構","装置","容器","組成","材料","用途","適用","適用例","片側","両側","左側","右側","前方","後方","上流","下流","隣接","近接","離間","間置","介在","重畳","概ね","略","略中央","固定側","可動側","伸長","収縮","係合","嵌合","取付","連結部","支持体","支持部","ガイド部",
    "データ","情報","信号","出力","入力","制御","演算","取得","送信","受信","表示","通知","設定","変更","更新","保存","削除","追加","実行","開始","終了","継続","停止","判定","判断","決定","選択","特定","抽出","検出","検知","測定","計測","移動","回転","変位","変形","固定","配置","生成","付与","供給","適用","照合","比較","算出","解析","同定","初期化","読出","書込","登録","記録","配信","連携","切替","起動","復帰","監視","通知処理","取得処理","演算処理",
    "良好","容易","簡便","適切","有利","有用","有効","効果的","高い","低い","大きい","小さい","新規","改良","改善","抑制","向上","低減","削減","増加","減少","可能","好適","好ましい","望ましい","優れる","優れた","高性能","高効率","低コスト","コスト","簡易","安定","安定性","耐久","耐久性","信頼性","簡素","簡略","単純","最適","最適化","汎用","汎用性","実現","達成","確保","維持","防止","回避","促進","不要","必要","高精度","省電力","省資源","高信頼","低負荷","高純度","高密度","高感度","迅速","円滑","簡略化","低価格","実効的","可能化","有効化","非必須","適合","互換",
    "出願","出願人","出願番号","出願日","出願書","出願公開","公開","公開番号","公開公報","公報","公報番号","特許","特許番号","特許文献","非特許文献","引用","引用文献","先行技術","審査","審査官","拒絶","意見書","補正書","優先","優先日","分割出願","継続出願","国内移行","国際出願","国際公開","PCT","登録","公開日","審査請求","拒絶理由","補正","訂正","無効審判","異議","取消","取下げ","事件番号","代理人","弁理士","係属","経過",
    "第","第一","第二","第三","第1","第2","第3","第1","第2","第3","一","二","三","四","五","六","七","八","九","零","数","複合","多数","少数","図1","図2","図3","図4","図5","図6","図7","図8","図9","表1","表2","表3","式1","式2","式3",
    "%","%","wt%","vol%","質量%","重量%","容量%","mol","mol%","mol/L","M","mm","cm","m","nm","μm","μ","rpm","Pa","kPa","MPa","GPa","N","W","V","A","mA","Hz","kHz","MHz","GHz","℃","°C","K","mL","L","g","kg","mg","wt","vol","h","hr","hrs","min","s","sec","ppm","ppb","bar","Ω","ohm","J","kJ","Wh","kWh",
    "株式会社","有限会社","合資会社","合名会社","合同会社","Inc","Inc.","Ltd","Ltd.","Co","Co.","Corp","Corp.","LLC", "GmbH","AG","BV","B.V.","S.A.","S.p.A.","(株)","㈱","(有)",
    "溶液","溶媒","触媒","反応","生成物","原料","成分","含有","含有量","配合","混合","混合物","濃度","温度","時間","割合","比率","基","官能基","化合物","組成物","樹脂","ポリマー","モノマー","基板","基材","フィルム","シート","粒子","粉末","比較例","参考例","試験","試料","評価","条件","実験","実験例","反応条件","反応時間","反応温度",
    "処理装置","端末","ユニット","モジュール","回路","素子","電源","電圧","電流","信号線","配線","端子","端部","接続", "接続部","演算部","記憶部","記憶装置","記録媒体","ユーザ","利用者","クライアント","サーバ","画面","UI","GUI","インターフェース","データベース","DB","ネットワーク","通信","要求","応答","リクエスト","レスポンス","パラメータ","引数","属性","プロパティ","フラグ","ID","ファイル","データ構造","テーブル","レコード",
    "軸","シャフト","ギア","モータ","エンジン","アクチュエータ","センサ","バルブ","ポンプ","筐体","ハウジング","フレーム","シャーシ","駆動","伝達","支持","連結"
}

# IPC抽出専用の関数
def extract_ipc(text, delimiter=';'):
    if not isinstance(text, str):
        return ""
    
    # NFKC正規化と小文字化
    text = unicodedata.normalize('NFKC', text).lower()
    
    # 括弧内の説明文を削除 (例: H01L 21/02 (semiconductor devices))
    text = re.sub(r'[\((][^)]*[\))]', ' ', text)
    
    ipc_codes = []
    parts = text.split(delimiter)
    
    for part in parts:
        part = part.strip()
        if not part:
            continue
            
        # IPCの形式 (例: h01l 21/02, g06f 17/30) にマッチさせる
        # スペースがあってもなくても (h01l21/02) 対応
        match = re.search(r'([a-z]\d{2}[a-z])\s*(\d{1,4}/\d{2,})', part)
        
        if match:
            # マッチした場合、スペースを除去して結合 (例: 'h01l21/02')
            ipc_code = match.group(1) + match.group(2)
            ipc_codes.append(ipc_code)
        else:
            # サブグループまでないIPC (例: H01L, G06F) も拾う
            # IPCの基本形式 (英1文字 + 数字2文字 + 英1文字)
            match_main = re.search(r'\b([a-z]\d{2}[a-z])\b', part)
            if match_main:
                ipc_codes.append(match_main.group(1))

    # 処理されたIPCコードをスペース区切りで返す
    return " ".join(ipc_codes)

# 自然言語処理用の関数(名詞のみ抽出)
def advanced_tokenize(text):
    if not isinstance(text, str): return ""
    text = unicodedata.normalize('NFKC', text).lower()
    text = re.sub(r'[\((][\w\s]+[\))]', ' ', text)
    
    text = re.sub(r'\b(図|fig|step|s)\s?\d+\b', ' ', text)
    text = re.sub(r'[!\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]', ' ', text) 
    tokens = list(t.tokenize(text))
    processed_tokens = []
    i = 0
    while i < len(tokens):
        token1 = tokens[i]
        if token1.base_form in stop_words or len(token1.base_form) < 2:
            i += 1
            continue
        if (i + 1) < len(tokens):
            token2 = tokens[i+1]
            pos1 = token1.part_of_speech.split(',')[0]
            pos2 = token2.part_of_speech.split(',')[0]
            if pos1 == '名詞' and pos2 == '名詞' and token2.base_form not in stop_words:
                compound_word = token1.base_form + token2.base_form
                processed_tokens.append(compound_word)
                i += 2
                continue
        pos = token1.part_of_speech.split(',')[0]
        # 名詞のみを抽出対象とする
        if pos in ['名詞']:
            processed_tokens.append(token1.base_form)
        i += 1
    return " ".join(processed_tokens)

# 特徴語抽出関数 (ゼロスコアを除外)
def get_top_tfidf_words(row_vector, feature_names, top_n=5):
    # 1. スコアとインデックスを取得
    scores = row_vector.toarray().flatten()
    indices = np.argsort(scores)[::-1]
    
    # 2. スコアが0より大きいインデックスのみを抽出
    non_zero_indices = [i for i in indices if scores[i] > 0]
    
    # 3. 0より大きいものの中から、top_n個を取得
    top_indices = non_zero_indices[:top_n]
    
    # 4. 単語に変換
    top_words = [feature_names[i] for i in top_indices]
    
    return ", ".join(top_words)


# --- UIロジックをクラスにカプセル化 ---
class SaturnVApp: 
    def __init__(self):
        """アプリケーションのウィジェットを初期化し、レイアウトを構築する"""
        # 状態を保持する変数
        self.df = pd.DataFrame()
        self.tfidf_matrix = None # ラベリング用に保持
        self.sbert_embeddings = None # SBERTベクトル用に新設
        self.feature_names = np.array([])
        self.embedding = None # UMAP後の2D/3Dベクトル
        self.cluster_labels_map = {} # 自動生成ラベルを保持
        self.label_widgets = {} # ラベル編集用ウィジェットを保持
        self.current_visible_df = pd.DataFrame() # 描画対象df
        self.current_faded_df = pd.DataFrame() # 背景df
        self.current_title = "" # 描画用タイトル

        # ドリルダウン専用の状態変数
        self.drill_df = pd.DataFrame() # エクスポート用
        self.current_drill_df = pd.DataFrame() # 描画・編集用
        self.drill_label_widgets = {} # ドリルダウン用ラベルウィジェット
        self.current_drill_labels_map = {} # ドリルダウン用自動生成ラベル
        
        # SBERTモデルのロード
        # GPUが利用可能なら自動で "cuda" を使用
        self.sbert_model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
        print("✅ Sentence-BERT モデル (multilingual) のロード完了。")
        print("ℹ️ Colabの「ランタイム > ランタイムのタイプを変更」でGPUを有効にすると高速化されます。")


        # レイアウト定義
        widget_layout = Layout(width='95%')
        col_box_layout = Layout(width='33%')
        style = {'description_width': 'initial'}

        # --- 1. ウィジェットの定義 ---
        
        # 1-1. ファイルアップロード
        self.upload_btn = Button(
            description='1. CSVをアップロード', button_style='primary',
            layout=Layout(width='auto'), icon='upload'
        )
        self.upload_output = Output()

        # 1-2. カラム選択
        self.title_col_w = Dropdown(description="発明の名称カラム:", options=[None], style=style, layout=widget_layout)
        self.abstract_col_w = Dropdown(description="要約カラム:", options=[None], style=style, layout=widget_layout)
        self.claim_col_w = Dropdown(description="請求の範囲カラム:", options=[None], style=style, layout=widget_layout)
        self.ipc_col_w = Dropdown(description="IPCカラム (オプション):", options=[None], style=style, layout=widget_layout)
        self.ipc_delimiter_w = Text(value=';', description="IPC区切り文字:", style=style, layout=widget_layout)
        
        self.date_col_w = Dropdown(description="出願日カラム (オプション):", options=[None], style=style, layout=widget_layout)
        self.applicant_col_w = Dropdown(description="出願人カラム (フィルタ用):", options=[None], style=style, layout=widget_layout)
        self.applicant_delimiter_w = Text(value=';', description="出願人区切り文字:", style=style, layout=widget_layout)
        self.number_col_w = Dropdown(description="[ホバー] 出願番号カラム:", options=[None], style=style, layout=widget_layout)
        
        # 1-3. フィルタリング設定 (メイン)
        self.bin_interval_w = Dropdown(description="出願期間の粒度:", options=[5, 3, 2], value=5, style=style, layout=widget_layout)
        self.date_bin_filter_w = Dropdown(description="表示期間 (フィルタ):", options=['(全期間)'], value='(全期間)', style=style, layout=widget_layout)
        self.applicant_filter_w = SelectMultiple(
            description="出願人 (フィルタ):",
            options=['(全出願人)'],
            value=['(全出願人)'], 
            style=style,
            layout=widget_layout,
            rows=5 
        )
        
        self.cluster_filter_w = SelectMultiple(
            description="マップ表示クラスタ (フィルタ):",
            options=['(全クラスタ)'],
            value=['(全クラスタ)'], 
            style=style,
            layout=widget_layout,
            rows=5 
        )

        self.submit_cols_btn = Button(description='2. 前処理 & フィルタ準備', button_style='info', layout=widget_layout)
        self.cols_output = Output()

        # 1-4. クラスタリング設定 (メイン)
        self.min_cluster_size_w = IntText(value=15, description='最小クラスタサイズ (推奨: 10-50):', style=style, layout=Layout(width='47%'))
        self.min_samples_w = IntText(value=10, description='最小サンプル数 (推奨: 5-20):', style=style, layout=Layout(width='47%'))
        
        self.label_top_n_w = IntText(value=3, description='クラスタラベル単語数 (推奨: 3):', style=style, layout=widget_layout)
        self.cluster_btn = Button(description='3. 描画 (再計算)', button_style='success', layout=widget_layout)
        self.cluster_output = Output() # メッセージ用のOutput

        # 1-5. エクスポート (メイン)
        self.filename_w = Text(value='patent_analysis_result.csv', description='エクスポートファイル名:', style=style, layout=widget_layout)
        self.export_btn = Button(description='5. CSVエクスポート', button_style='warning', layout=widget_layout)
        self.export_output = Output()
        
        # 1-6. グラフ描画用の「空の」ウィジェットを作成
        self.fig_output = Output()
        
        # 1-7. G. ラベル編集機能
        self.label_editor_widgets_box = VBox(
            [widgets.Label("「3. 描画 (再計算)」を実行すると、ここに編集UIが表示されます。")]
        ) 
        self.show_labels_chk = Checkbox(
            value=True,
            description='マップにラベルを表示する',
            indent=False,
            layout=Layout(margin='10px 0 0 0')
        )
        self.update_labels_btn = Button(
            description='4. ラベルを更新して再描画', 
            button_style='primary', 
            layout=widget_layout,
            icon='pencil'
        )
        self.label_editor_container = VBox(
            [
                widgets.HTML("<h3>G. ラベル編集</h3>"),
                self.label_editor_widgets_box,
                self.show_labels_chk,
                self.update_labels_btn
            ], 
            layout=Layout(border='1px dashed #000', padding='10px', margin='10px 0 10px 0', width='98%')
        )
        
        # 1-8. ドリルダウン分析ウィジェット
        self.cluster_select_w = Dropdown(
            description="分析対象クラスタ:", 
            options=['(メインマップで「3. 描画 (再計算)」を実行してください)'], 
            style=style, 
            layout=widget_layout
        )
        
        # F-2 (フィルタ)
        self.drill_bin_interval_w = Dropdown(description="[ドリルダウン] 期間の粒度:", options=[5, 3, 2], value=5, style=style, layout=widget_layout)
        self.drill_date_bin_filter_w = Dropdown(description="[ドリルダウン] 表示期間:", options=['(全期間)'], value='(全期間)', style=style, layout=widget_layout)
        self.drill_applicant_filter_w = SelectMultiple(
            description="[ドリルダウン] 出願人:",
            options=['(全出願人)'],
            value=['(全出願人)'], 
            style=style,
            layout=widget_layout,
            rows=5 
        )
        
        # F-3 (クラスタリング設定)
        self.drill_min_cluster_size_w = IntText(value=5, description='[ドリルダウン] 最小クラスタサイズ:', style=style, layout=Layout(width='47%'))
        self.drill_min_samples_w = IntText(value=5, description='[ドリルダウン] 最小サンプル数:', style=style, layout=Layout(width='47%'))
        self.drill_label_top_n_w = IntText(value=3, description='[ドリルダウン] ラベル単語数 (推奨: 3):', style=style, layout=Layout(width='47%'))
        
        self.drill_show_labels_chk = Checkbox(
            value=True,
            description='[ドリルダウン] マップにラベルを表示する',
            indent=False,
            layout=Layout(width='47%', margin='10px 0 0 0')
        )
        
        self.drilldown_btn = Button(description='6. 選択クラスタで再マップ (ドリルダウン)', button_style='primary', layout=widget_layout)
        self.drilldown_output = Output()
        
        # F-4 (ドリルダウン用ラベル編集)
        self.drill_label_editor_widgets_box = VBox(
            [widgets.Label("「6. ドリルダウン」を実行すると、ここに編集UIが表示されます。")]
        )
        self.update_drill_labels_btn = Button(
            description='7. サブクラスタ・ラベルを更新して再描画', 
            button_style='primary', 
            layout=widget_layout,
            icon='pencil'
        )
        self.drill_label_editor_container = VBox(
            [
                widgets.HTML("<h4>F-4. サブクラスタ・ラベル編集</h4>"),
                self.drill_label_editor_widgets_box,
                self.update_drill_labels_btn
            ], 
            layout=Layout(border='1px dashed #00F', padding='10px', margin='10px 0 10px 0', width='98%')
        )
        
        # F-5 (エクスポート)
        self.drill_filename_w = Text(value='drilldown_result.csv', description='ファイル名:', style=style, layout=Layout(width='47%'))
        self.drill_export_btn = Button(description='8. ドリルダウン結果をCSVエクスポート', button_style='warning', layout=Layout(width='47%'))
        self.drill_export_output = Output()
        
        
        # H. 特許マップ(統計分析)用ウィジェット
        self.stats_start_year_w = IntText(value=2010, description='開始年:', style=style, layout=Layout(width='47%'))
        self.stats_end_year_w = IntText(value=2024, description='終了年:', style=style, layout=Layout(width='47%'))
        
        self.stats_num_assignees_w = IntText(value=15, description='出願人 表示件数:', style=style, layout=Layout(width='47%'))
        
        self.stats_update_period_btn = Button(description='(フィルタ結果から期間を自動設定)', 
                                              button_style='info', 
                                              layout=Layout(width='47%'), 
                                              icon='history')
        
        self.stats_cluster_filter_w = SelectMultiple(
            description="集計対象クラスタ (フィルタ):",
            options=['(全クラスタ)'],
            value=['(全クラスタ)'], 
            style=style,
            layout=Layout(width='95%'), # 幅を広げる
            rows=5 
        )
        
        self.stats_run_btn = Button(description='9. 特許マップを描画', button_style='info', layout=widget_layout, icon='search')
        
        self.stats_output_1 = Output() # MAP 1
        self.stats_output_2 = Output() # MAP 2
        self.stats_output_3 = Output() # MAP 3
        
        self.stats_container = VBox(
            [
                widgets.HTML("<h3>H. 特許マップ(統計分析)</h3>"), 
                widgets.HTML("<p><b>(注意)</b> ここの<b>特許マップ</b>は、上記<b>「B. フィルタリング設定(期間・出願人)」</b>と、<b>すぐ下の「H. 特許マップ設定」</b>で絞り込まれたデータが対象です。</p>"),
                HBox([self.stats_start_year_w, self.stats_end_year_w]),
                HBox([self.stats_num_assignees_w, self.stats_update_period_btn]), 
                self.stats_cluster_filter_w, 
                self.stats_run_btn,
                widgets.HTML("<hr><h4>H-1: 出願件数時系列推移</h4>"),
                self.stats_output_1,
                widgets.HTML("<hr><h4>H-2: 出願人ランキング</h4>"),
                self.stats_output_2,
                widgets.HTML("<hr><h4>H-3: 出願年別出願人動向</h4>"),
                self.stats_output_3,
            ],
            layout=Layout(border='1px solid #008000', padding='10px', margin='10px 0 10px 0', width='98%')
        )

        
        # --- 2. イベントハンドラの登録 ---
        self.upload_btn.on_click(self.on_upload_clicked) 
        self.submit_cols_btn.on_click(self.on_submit_cols_clicked)
        self.cluster_btn.on_click(self.on_cluster_btn_clicked)
        self.export_btn.on_click(self.on_export_btn_clicked)
        
        # フィルタのイベントハンドラ
        self.bin_interval_w.observe(self.on_filter_changed, names='value')
        self.date_bin_filter_w.observe(self.on_filter_changed, names='value')
        self.applicant_filter_w.observe(self.on_filter_changed, names='value')
        self.cluster_filter_w.observe(self.on_filter_changed, names='value') 
        
        self.update_labels_btn.on_click(self.on_update_labels_clicked)
        self.show_labels_chk.observe(self.on_show_labels_changed, names='value')
        
        # ドリルダウン用イベント
        self.drilldown_btn.on_click(self.on_drilldown_clicked)
        self.update_drill_labels_btn.on_click(self.on_update_drill_labels_clicked)
        self.drill_show_labels_chk.observe(self.on_drill_show_labels_changed, names='value')
        
        self.drill_export_btn.on_click(self.on_drill_export_clicked) 
        self.cluster_select_w.observe(self.on_cluster_select_change, names='value')
        self.drill_bin_interval_w.observe(self.on_drill_interval_change, names='value')
        
        self.stats_update_period_btn.on_click(self.on_update_stats_period_clicked)
        self.stats_run_btn.on_click(self.on_stats_run_clicked)


        # --- 3. UIのレイアウト (シンプルなVBox) ---
        
        upload_box = HBox([self.upload_btn]) 
        
        analysis_cols_box = VBox([widgets.HTML("<b>分析対象カラム (必須)</b>"), self.title_col_w, self.abstract_col_w, self.claim_col_w], layout=col_box_layout)
        option_cols_box = VBox([widgets.HTML("<b>オプションカラム</b>"), self.ipc_col_w, self.ipc_delimiter_w, self.date_col_w, self.applicant_col_w, self.applicant_delimiter_w], layout=col_box_layout)
        hover_cols_box = VBox([widgets.HTML("<b>[ホバー] 表示用カラム</b>"), self.number_col_w], layout=col_box_layout) 
        cols_selection_box = HBox([analysis_cols_box, option_cols_box, hover_cols_box], layout=Layout(width='100%', justify_content='space-around'))

        filter_box = VBox([
            widgets.HTML("<h3>B. フィルタリング設定 (メインマップ用)</h3>"),
            widgets.HTML("<p>(フィルタを変更すると、下のマップが自動で更新されます)</p>"),
            HBox([self.bin_interval_w, self.date_bin_filter_w]),
            HBox([self.applicant_filter_w, self.cluster_filter_w]) 
        ], layout=Layout(width='98%'))
        
        cluster_box = VBox([
            widgets.HTML("<h3>C. クラスタリング実行</h3>"), 
            HBox([self.min_cluster_size_w, self.min_samples_w], layout=Layout(justify_content='space-between')), 
            self.label_top_n_w, 
            self.cluster_btn
        ], layout=Layout(width='49%'))
        
        export_box = VBox([
            widgets.HTML("<h3>D. エクスポート (メインマップ用)</h3>"), 
            self.filename_w, 
            self.export_btn, 
            self.export_output
        ], layout=Layout(width='49%'))
        
        cluster_export_box = HBox([cluster_box, export_box], layout=Layout(width='98%', justify_content='space-between'))

        # ドリルダウンUIの組立
        drill_filter_box = VBox([
            widgets.HTML("<h4>F-2. フィルタリング設定 (ドリルダウン用)</h4>"),
            widgets.HTML("<p>(ここの選択肢は、上記F-1で選んだクラスタの内容に自動で同期されます)</p>"),
            HBox([self.drill_bin_interval_w, self.drill_date_bin_filter_w]),
            self.drill_applicant_filter_w
        ], layout=Layout(width='98%'))
        
        drill_exec_box = VBox([
            widgets.HTML("<h4>F-3. クラスタリング設定 (ドリルダウン用)</h4>"),
            HBox([self.drill_min_cluster_size_w, self.drill_min_samples_w], layout=Layout(justify_content='space-between')), 
            HBox([self.drill_label_top_n_w, self.drill_show_labels_chk], layout=Layout(justify_content='space-between')), # チェックボックス追加
            self.drilldown_btn # 6
        ], layout=Layout(width='98%'))
        
        drill_export_box_final = VBox([
            widgets.HTML("<h4>F-5. エクスポート (ドリルダウン用)</h4>"),
            HBox([self.drill_filename_w, self.drill_export_btn], layout=Layout(justify_content='space-between')), # 8
            self.drill_export_output
        ], layout=Layout(width='98%'))

        # 最終的なUI (単一のVBox)
        self.ui = VBox([
            widgets.HTML("<h1>Saturn V</h1>"), 
            widgets.HTML("<h3>A. ファイル設定</h3>"),
            upload_box,
            self.upload_output,
            widgets.HTML("<br>"),
            cols_selection_box,
            self.submit_cols_btn,
            self.cols_output,
            widgets.HTML("<hr>"),
            filter_box, 
            widgets.HTML("<hr>"),
            cluster_export_box,
            widgets.HTML("<hr>"),
            widgets.HTML("<h3>E. 分析結果 (メインマップ)</h3>"),
            self.cluster_output, 
            self.fig_output, # メインマップ描画領域
            
            self.label_editor_container, # G. ラベル編集
            
            widgets.HTML("<hr style='border: 2px solid #000;'>"),
            widgets.HTML("<h3>F. クラスタ ドリルダウン分析</h3>"),
            widgets.HTML("<h4>F-1. 分析対象クラスタの選択</h4>"),
            widgets.HTML("<p>上記で「3. 描画 (再計算)」を実行後、分析したいクラスタを以下で選択してください。</p>"),
            self.cluster_select_w,
            widgets.HTML("<hr>"),
            drill_filter_box, # F-2
            widgets.HTML("<hr>"),
            drill_exec_box, # F-3
            self.drilldown_output, # ドリルダウンマップ描画領域
            self.drill_label_editor_container, # F-4
            widgets.HTML("<hr>"),
            drill_export_box_final, # F-5
            
            # 特許マップ(統計分析)セクション
            widgets.HTML("<hr style='border: 2px solid #008000;'>"),
            self.stats_container, # H. 特許マップ(統計分析)
            
        ], layout=Layout(width='100%'))

    # --- 4. コールバック関数 (クラスメソッド) ---
    
    def on_upload_clicked(self, b):
        """1. CSVをアップロード"""
        self.upload_btn.disabled = True
        self.upload_btn.description = 'アップロード待機中...'
        with self.upload_output:
            clear_output(wait=True)
            print("🔄 Colabのファイルアップロード ダイアログを開きます...")
            try:
                uploaded = files.upload() 
                if not uploaded:
                    print("🚨 ファイルが選択されなかったため、キャンセルしました。")
                    self.upload_btn.disabled = False
                    self.upload_btn.description = '1. CSVをアップロード'
                    return
                file_name = next(iter(uploaded))
                content = uploaded[file_name]
                print(f"... '{file_name}' を読み込み中 ...")
                try:
                    self.df = pd.read_csv(BytesIO(content), encoding='shift_jis')
                except UnicodeDecodeError:
                    self.df = pd.read_csv(BytesIO(content), encoding='utf-8')
                clear_output(wait=True)
                print(f"✅ '{file_name}' を正常に読み込みました。")
                
                columns_with_none = [None] + list(self.df.columns)
                
                # 全てのドロップダウンを更新
                self.title_col_w.options = columns_with_none
                self.abstract_col_w.options = columns_with_none
                self.claim_col_w.options = columns_with_none
                self.ipc_col_w.options = columns_with_none
                self.applicant_col_w.options = columns_with_none
                self.number_col_w.options = columns_with_none 
                self.date_col_w.options = columns_with_none 
                
                print("\n✅ カラム選択ドロップダウンを更新しました。")
            except Exception as e:
                clear_output(wait=True)
                print(f"🚨 エラーが発生しました: {e}")
                self.df = pd.DataFrame()
            self.upload_btn.disabled = False
            self.upload_btn.description = '1. CSVをアップロード'

    def update_date_filter_options(self, event_source="submit"):
        """メインの期間フィルタの選択肢を更新する"""
        date_col = self.date_col_w.value
        
        if (date_col and date_col in self.df.columns) or ('parsed_date' in self.df.columns and not self.df['parsed_date'].isnull().all()):
            
            if 'parsed_date' not in self.df.columns or self.df['parsed_date'].isnull().all():
                if date_col and date_col in self.df.columns:
                    try:
                        self.df['parsed_date'] = pd.to_datetime(self.df[date_col], errors='coerce')
                    except Exception as e:
                        with self.cols_output:
                            print(f"🚨 期間フィルタの準備に失敗: 日付変換エラー {e}")
                        self.df['date_bin'] = None
                        self.date_bin_filter_w.options = ['(全期間)']
                        return
                else:
                    self.df['date_bin'] = None
                    self.date_bin_filter_w.options = ['(全期間)']
                    return

            if self.df['parsed_date'].isnull().all():
                self.df['date_bin'] = None
                self.date_bin_filter_w.options = ['(全期間)']
                return

            try:
                min_date = self.df['parsed_date'].min().replace(day=1, month=1)
                max_date = self.df['parsed_date'].max()
                interval = int(self.bin_interval_w.value)
                bins = pd.date_range(start=min_date, end=max_date + pd.DateOffset(years=interval), freq=f'{interval*12}MS')
                labels = [f"{bins[i].year}-{bins[i+1].year - 1}" for i in range(len(bins)-1)]
                self.df['date_bin'] = pd.cut(self.df['parsed_date'], bins=bins, labels=labels, right=False)
                
                date_bin_counts = self.df['date_bin'].value_counts()
                new_date_options = [f"(全期間) ({len(self.df)}件)"] 
                for label in labels:
                    count = date_bin_counts.get(label, 0)
                    new_date_options.append(f"{label} ({count}件)")
                
                self.date_bin_filter_w.options = new_date_options
                self.date_bin_filter_w.value = new_date_options[0] 

                if event_source == "interval_change":
                        with self.cols_output:
                            clear_output(wait=True)
                            print(f"✅ 表示期間の粒度を「{interval}年ごと」に更新しました。")

            except Exception as e:
                with self.cols_output:
                    print(f"🚨 期間フィルタの更新に失敗: {e}")
                self.df['date_bin'] = None
                self.date_bin_filter_w.options = ['(全期間)']
        else:
            self.df['date_bin'] = None
            self.date_bin_filter_w.options = ['(全期間)']

    # フィルタ変更時のコールバック
    def on_filter_changed(self, change):
        """Bセクションのフィルタが変更されたら、マップの表示を更新する"""
        if self.df.empty or 'cluster' not in self.df.columns:
            # 「3. 描画」がまだ押されていない場合は何もしない
            return
        
        # 期間粒度の変更は特別扱い (date_binの再計算)
        if change['owner'] == self.bin_interval_w:
            self.update_date_filter_options(event_source="interval_change")
            # この後、自動で 'apply_filters_and_redraw' が呼ばれる (次の処理)

        self.apply_filters_and_redraw()

    # SBERTとTF-IDFのハイブリッド処理
    def on_submit_cols_clicked(self, b):
        """2. 前処理 & フィルタ準備"""
        if self.df.empty:
            with self.cols_output:
                clear_output(wait=True)
                print("🚨 エラー: まず「1. CSVをアップロード」ボタンからファイルを設定してください。")
            return
            
        self.submit_cols_btn.disabled = True
        self.submit_cols_btn.description = '2. 処理中...'
        
        with self.cols_output:
            clear_output(wait=True)
            
            # --- 1. テキストカラムの結合 (SBERT用 と TF-IDF用) ---
            print("⚙️ 1/5: テキストデータを準備中...")
            title_col = self.title_col_w.value
            abstract_col = self.abstract_col_w.value
            claim_col = self.claim_col_w.value
            ipc_col = self.ipc_col_w.value
            ipc_delimiter = self.ipc_delimiter_w.value
            
            # (SBERT用) 生テキストを結合
            self.df['text_for_sbert'] = ''
            for col in [title_col, abstract_col, claim_col]:
                if col and col in self.df.columns:
                    self.df['text_for_sbert'] += self.df[col].fillna('') + ' '
            
            # (TF-IDF用) NLP用テキストを結合
            self.df['text_for_nlp'] = self.df['text_for_sbert'].copy()
            
            # (共通) IPCテキストを処理
            self.df['words_ipc'] = ''
            if ipc_col and ipc_col in self.df.columns:
                # 新しい extract_ipc 関数を呼び出す
                self.df['words_ipc'] = self.df[ipc_col].fillna('').apply(
                    lambda x: extract_ipc(x, ipc_delimiter)
                )

            # --- 2. TF-IDF (ラベリング用) の計算 ---
            print("⚙️ 2/5: TF-IDF (ラベリング用) を計算中...")
            self.df['words_nlp'] = self.df['text_for_nlp'].apply(advanced_tokenize)
            
            # TfidfVectorizer には 'words_nlp' (自然言語の名詞) のみを使用する
            vectorizer = TfidfVectorizer(max_features=None, min_df=5, max_df=0.80, ngram_range=(1, 1))
            self.tfidf_matrix = vectorizer.fit_transform(self.df['words_nlp']) # 'words_nlp' を使用
            
            if self.tfidf_matrix.shape[1] == 0:
                print("🚨 エラー: 分析対象となる単語が見つかりませんでした。")
                self.submit_cols_btn.disabled = False
                self.submit_cols_btn.description = '2. 前処理 & フィルタ準備'
                return
                
            self.feature_names = np.array(vectorizer.get_feature_names_out())
            # characteristic_words も 'words_nlp' (TF-IDF) ベースで計算
            self.df['characteristic_words'] = [get_top_tfidf_words(self.tfidf_matrix[i], self.feature_names) for i in range(self.tfidf_matrix.shape[0])]

            # --- 3. SBERT (マッピング用) の計算 ---
            print("⚙️ 3/5: SBERT (マッピング用) のベクトル計算中... (時間がかかります)")
            print("ℹ️ GPUランタイムでない場合、ここで数分~数十分かかります。")
            
            # SBERT用のテキストリスト = 生テキスト(NLP) + 処理済みIPC
            texts_for_sbert = (self.df['text_for_sbert'] + ' ' + self.df['words_ipc']).tolist()
            
            # SBERTによるベクトル化
            self.sbert_embeddings = self.sbert_model.encode(
                texts_for_sbert, 
                show_progress_bar=True,
                batch_size=128 # GPUの場合、バッチサイズを大きくすると高速
            )
            print("... SBERT計算完了")

            # --- 4. UMAP (SBERTベクトルを使用) ---
            print("⚙️ 4/5: UMAPによる次元削減を実行中...")
            reducer = umap.UMAP(n_neighbors=15, min_dist=0.1, n_components=2, random_state=42)
            
            # UMAPの入力が TF-IDF から SBERT に変更
            self.embedding = reducer.fit_transform(self.sbert_embeddings) 
            
            self.df['umap_x'] = self.embedding[:, 0]
            self.df['umap_y'] = self.embedding[:, 1]
            
            # ドリルダウン用カラムを初期化
            self.df['drill_cluster'] = np.nan
            self.df['drill_cluster_label'] = np.nan
            self.df['drill_hover_text'] = np.nan
            self.df['drill_x'] = np.nan
            self.df['drill_y'] = np.nan
            
            # 不要になった中間テキストカラムを削除 (メモリ節約)
            self.df = self.df.drop(columns=['text_for_sbert', 'text_for_nlp', 'words_nlp', 'words_ipc'], errors='ignore')

            # --- 5. フィルタ準備 ---
            print("⚙️ 5/5: フィルタを準備中...")
            self.update_date_filter_options(event_source="submit")
                    
            applicant_col = self.applicant_col_w.value
            delimiter = self.applicant_delimiter_w.value
            if applicant_col and applicant_col in self.df.columns and delimiter:
                try:
                    applicants = self.df[applicant_col].fillna('').str.split(delimiter).explode().str.strip()
                    applicant_counts = applicants.value_counts()
                    unique_applicants = sorted([app for app in applicants.unique() if app])
                    
                    new_options = [f"(全出願人) ({len(self.df)}件)"] 
                    for app_name in unique_applicants:
                        count = applicant_counts.get(app_name, 0)
                        new_options.append(f"{app_name} ({count}件)") 
                        
                    self.applicant_filter_w.options = new_options
                    self.applicant_filter_w.value = [new_options[0]] 
                    print("...出願人フィルタ 準備完了")
                except Exception as e:
                    print(f"🚨 出願人フィルタの準備に失敗: {e}")
                    self.applicant_filter_w.options = ['(全出願人)']
                    self.applicant_filter_w.value = ['(全出願人)']
            else:
                self.applicant_filter_w.options = ['(全出願人)']
                self.applicant_filter_w.value = ['(全出願人)']
            
            clear_output(wait=True)
            print("✅ 全ての前処理とフィルタ準備が完了しました。 (SBERTベクトル計算を含む)")
            print("▶ 次に、「3. 描画 (再計算)」ボタンを押してください。")
            
        self.submit_cols_btn.disabled = False
        self.submit_cols_btn.description = '2. 前処理 & フィルタ準備'

    # ホバーテキスト更新関数
    def update_hover_text(self):
        """self.df の hover_text カラムを最新の cluster_label で更新する"""
        number_col = self.number_col_w.value
        applicant_col_hover = self.applicant_col_w.value 
        title_col = self.title_col_w.value
        hover_texts = []
        for index, row in self.df.iterrows():
            text = ""
            if title_col and title_col in self.df.columns and pd.notna(row[title_col]): text += f"<b>名称:</b> {str(row[title_col])[:50]}...<br>"
            if number_col and number_col in self.df.columns and pd.notna(row[number_col]): text += f"<b>番号:</b> {row[number_col]}<br>"
            if applicant_col_hover and applicant_col_hover in self.df.columns and pd.notna(row[applicant_col_hover]): text += f"<b>出願人:</b> {str(row[applicant_col_hover])[:50]}...<br>"
            if 'characteristic_words' in row: text += f"<b>特徴語:</b> {row['characteristic_words']}<br>"
            if 'cluster_label' in row: text += f"<b>クラスタ:</b> {row['cluster_label']}" # self.df['cluster_label'] を参照
            hover_texts.append(text)
        self.df['hover_text'] = hover_texts

    def on_cluster_btn_clicked(self, b):
        """3. メインマップの描画 (データ処理)"""
        
        self.cluster_btn.disabled = True
        self.cluster_btn.description = '3. 計算中...'
        
        # 1. メッセージ用Outputをクリアし、エラーチェック
        with self.cluster_output:
            clear_output(wait=True)
            print("⚙️ 1/4: クラスタリング設定を読み込み中...")
            try:
                min_cluster_size_val = int(self.min_cluster_size_w.value)
                min_samples_val = int(self.min_samples_w.value)
                self.label_top_n = int(self.label_top_n_w.value) # クラス変数に保存
            except ValueError:
                print("🚨 エラー: パラメータは半角数値で入力してください。")
                self.cluster_btn.disabled = False
                self.cluster_btn.description = '3. 描画 (再計算)'
                return
            
            if self.embedding is None or self.embedding.shape[0] == 0:
                print("🚨 エラー: まず「2. 前処理 & フィルタ準備」ボタンを押してください。")
                self.cluster_btn.disabled = False
                self.cluster_btn.description = '3. 描画 (再計算)'
                return

        # 2. HDBSCAN クラスタリング(毎回実行)
        # self.embedding は SBERTベースのUMAP座標
        with self.cluster_output: print(f"⚙️ 2/4: HDBSCANクラスタリングを実行中 (min_size:{min_cluster_size_val}, min_samples:{min_samples_val})...")
        
        clusterer = hdbscan.HDBSCAN(
            min_cluster_size=min_cluster_size_val,
            min_samples=min_samples_val,
            metric='euclidean',
            cluster_selection_method='eom', 
            gen_min_span_tree=True 
        )
        clustering = clusterer.fit(self.embedding)
        self.df['cluster'] = clustering.labels_
                
        # 3. ラベルの**自動生成** (TF-IDFベース)
        with self.cluster_output: print("⚙️ 3/4: ラベルを自動生成中...")
        self.cluster_labels_map = {} # マップをリセット
        unique_clusters = sorted(self.df['cluster'].unique())
        for cluster_id in unique_clusters:
            if cluster_id == -1:
                label = "ノイズ / 小クラスタ"
                self.cluster_labels_map[cluster_id] = label
                continue
            
            indices = self.df[self.df['cluster'] == cluster_id].index 
            if len(indices) == 0:
                label = "(該当なし)"
            else:
                # ラベリングは TF-IDF で行う (ハイブリッド)
                cluster_vectors = self.tfidf_matrix[indices]
                mean_vector = np.array(cluster_vectors.mean(axis=0)).flatten()
                top_indices = np.argsort(mean_vector)[::-1][:self.label_top_n]
                label = ", ".join([self.feature_names[i] for i in top_indices])

            self.cluster_labels_map[cluster_id] = f"[{cluster_id}] {label}"
            
        self.df['cluster_label'] = self.df['cluster'].map(self.cluster_labels_map)
        
        # ホバーテキストの生成をここで呼ぶ
        self.update_hover_text()

        # --- 4. フィルタとUIの更新 ---
        with self.cluster_output: print("⚙️ 4/4: フィルタとUIを更新中...")
        
        # 2つのクラスタフィルタを更新
        try:
            cluster_counts = self.df['cluster_label'].value_counts()
            new_cluster_options = []
            # クラスタIDでソート
            for cluster_id in sorted(self.df['cluster'].unique()):
                label = self.cluster_labels_map.get(cluster_id)
                if label:
                    count = cluster_counts.get(label, 0)
                    new_cluster_options.append( (f"{label} ({count}件)", cluster_id) ) 
            
            # ドリルダウン用セレクトボックス(F-1)の更新
            drill_options = [opt for opt in new_cluster_options if opt[1] != -1] # ノイズを除く
            drill_options.insert(0, (f"(全データ) ({len(self.df)}件)", "ALL"))
            self.cluster_select_w.options = drill_options
            self.cluster_select_w.value = "ALL"
            
            # メインマップ用フィルタ (B) の更新
            filter_options_b = [(f"(全クラスタ) ({len(self.df)}件)", "ALL")] + new_cluster_options
            self.cluster_filter_w.options = filter_options_b
            self.cluster_filter_w.value = ["ALL"]
            
            # 特許マップ用フィルタ (H) の更新
            filter_options_h = [(f"(全クラスタ) ({len(self.df)}件)", "ALL")] + new_cluster_options
            self.stats_cluster_filter_w.options = filter_options_h
            self.stats_cluster_filter_w.value = ["ALL"]
            
            # ドリルダウンフィルタも(全データ)で初期化
            self.update_drill_filters(self.df) 
            
        except Exception as e:
            with self.cluster_output:
                print(f"🚨 ドリルダウン用ウィジェットの更新に失敗: {e}")
        # --- ここまで ---
        
        # 5. ラベル編集UIの構築
        self.update_label_editor_ui()
        
        # 6. グラフ描画の呼び出し (フィルタ適用)
        self.apply_filters_and_redraw()
            
        # 7. 完了メッセージ
        with self.cluster_output:
            clear_output(wait=True)
            print(f"✅ クラスタリングと描画が完了しました。")
            print("▶ 「B. フィルタリング設定」を変更すると、マップ表示が自動更新されます。")
            print("▶ 「G. ラベル編集」セクションでラベル名を変更し、「4. ラベルを更新」ボタンを押せます。")
        
        self.cluster_btn.disabled = False
        self.cluster_btn.description = '3. 描画 (再計算)'

    # フィルタ適用とメインマップ再描画
    def apply_filters_and_redraw(self):
        """Bセクションのフィルタを適用し、メインマップを再描画する(軽い処理)"""
        
        # --- 1. 期間フィルタ ---
        selected_date_bin_raw = self.date_bin_filter_w.value
        if selected_date_bin_raw.startswith('(全期間)'):
            date_mask = pd.Series(True, index=self.df.index)
            selected_date_bin_for_title = '(全期間)'
        else:
            date_bin_label_for_filter = selected_date_bin_raw.split(' (')[0].strip()
            date_mask = (self.df['date_bin'].astype(str) == date_bin_label_for_filter)
            selected_date_bin_for_title = selected_date_bin_raw
        
        # --- 2. 出願人フィルタ ---
        applicant_col = self.applicant_col_w.value
        selected_applicants_raw = list(self.applicant_filter_w.value)
        
        self.is_applicant_color_mode = True # 色分けモード
        is_all_applicants_selected = any(s.startswith('(全出願人)') for s in selected_applicants_raw)

        if is_all_applicants_selected or not selected_applicants_raw:
            self.is_applicant_color_mode = False
            applicant_mask = pd.Series(True, index=self.df.index)
            selected_applicant_for_title = '(全出願人)'
        else:
            mask_list = []
            applicant_name_list = []
            for app_name_raw in selected_applicants_raw:
                app_name = app_name_raw.split(' (')[0].strip()
                applicant_name_list.append(app_name)
                if applicant_col and applicant_col in self.df.columns:
                    mask_list.append(self.df[applicant_col].fillna('').str.contains(re.escape(app_name)))
            
            if mask_list:
                applicant_mask = pd.concat(mask_list, axis=1).any(axis=1) # OR条件
            else:
                applicant_mask = pd.Series(False, index=self.df.index)
            selected_applicant_for_title = ", ".join(applicant_name_list)
            
        # --- 3. メインマップ用クラスタフィルタ ---
        selected_clusters_raw = list(self.cluster_filter_w.value)
        is_all_clusters_selected = any(s == "ALL" for s in selected_clusters_raw)
        
        if is_all_clusters_selected or not selected_clusters_raw:
            cluster_mask = pd.Series(True, index=self.df.index)
            selected_cluster_for_title = '(全クラスタ)'
        else:
            # cluster_id (int) のリストに変換
            selected_cluster_ids = [cid for cid in selected_clusters_raw if cid != "ALL"]
            cluster_mask = self.df['cluster'].isin(selected_cluster_ids)
            
            # タイトル用のラベル名を取得
            cluster_label_list = [self.cluster_labels_map.get(cid, "") for cid in selected_cluster_ids]
            selected_cluster_for_title = ", ".join(cluster_label_list)

        # --- 4. 最終的な絞り込み ---
        final_mask = date_mask & applicant_mask & cluster_mask
        self.current_visible_df = self.df[final_mask]
        self.current_faded_df = self.df[~final_mask]
        
        # タイトルを更新
        self.current_title = f'Saturn V (期間: {selected_date_bin_for_title}, 出願人: {selected_applicant_for_title}, クラスタ: {selected_cluster_for_title})'
        
        # 5. メインマップを再描画
        self.redraw_main_map()


    # ラベル編集UI生成の共通ヘルパーメソッド (レイアウト修正)
    def _create_label_editor_ui(self, target_vbox, labels_map, widgets_dict, placeholder_text):
        """ラベル編集UIを動的に構築する共通ロジック"""
        
        widgets_dict.clear() # ウィジェット辞書をリセット
        
        # ソートされたクラスタID(-1を除く)でループ
        sorted_ids = sorted([cid for cid in labels_map.keys() if cid != -1])
        
        ui_elements = []
        
        # HBoxのレイアウトを定義
        hbox_layout = Layout(width='100%', justify_content='space-between', margin='5px 0 5px 0')
        label_layout = Layout(width='40%') # ラベル幅
        text_layout = Layout(width='58%')  # テキストボックス幅 (合計98%で隙間を空ける)
        
        for cluster_id in sorted_ids:
            auto_label = labels_map.get(cluster_id, "")
            if auto_label == "(該当なし)": # 該当なしは編集不可
                continue
            
            # レイアウトを適用
            label_widget = widgets.Label(value=auto_label, layout=label_layout)
            text_widget = widgets.Text(
                value=auto_label, # 初期値は自動ラベル
                placeholder=placeholder_text,
                layout=text_layout
            )
            
            widgets_dict[cluster_id] = text_widget
            
            # HBoxにもレイアウトを適用
            ui_elements.append(HBox([label_widget, text_widget], layout=hbox_layout))
        
        # 最後にノイズ(-1)を追加 (編集不可)
        if -1 in labels_map:
            # レイアウトを適用
            label_widget = widgets.Label(value=labels_map[-1], layout=label_layout)
            text_widget = widgets.Text(value=labels_map[-1], disabled=True, layout=text_layout)
            ui_elements.append(HBox([label_widget, text_widget], layout=hbox_layout))

        # コンテナ VBox の children を直接差し替える
        target_vbox.children = ui_elements

    def update_label_editor_ui(self):
        """ G. ラベル編集UIを動的に構築する"""
        # 共通ヘルパーメソッドを呼び出す
        self._create_label_editor_ui(
            self.label_editor_widgets_box,
            self.cluster_labels_map,
            self.label_widgets,
            'ここに新しいラベル名を入力'
        )

    # チェックボックス変更時のイベント
    def on_show_labels_changed(self, change):
        """ラベル表示チェックボックスが変更されたら、マップを再描画する"""
        # データがなければ何もしない
        if self.df.empty or 'cluster' not in self.df.columns:
            return
            
        with self.cluster_output:
            clear_output(wait=True)
            print(f"⚙️ ラベル表示を「{change['new']}」に変更して再描画します...")

        self.apply_filters_and_redraw() # フィルタ再適用
        
        with self.cluster_output:
            clear_output(wait=True)
            print("✅ ラベル表示を更新しました。")


    def on_update_labels_clicked(self, b):
        """ 4. ラベル更新ボタンの処理"""
        self.update_labels_btn.disabled = True
        self.update_labels_btn.description = '4. 更新中...'

        with self.cluster_output:
            clear_output(wait=True)
            print("⚙️ ラベルを更新中...")

        # 1. 新しいラベルマップを構築
        new_labels_map = {}
        for cluster_id, text_widget in self.label_widgets.items():
            new_labels_map[cluster_id] = text_widget.value
        
        # -1 (ノイズ) のラベルもマップに追加
        if -1 in self.cluster_labels_map:
            new_labels_map[-1] = self.cluster_labels_map[-1]
        
        # 2. self.df['cluster_label'] を上書き
        self.df['cluster_label'] = self.df['cluster'].map(new_labels_map)
        
        # メインの cluster_labels_map も更新
        self.cluster_labels_map = new_labels_map

        # 3. ホバーテキストも更新
        self.update_hover_text()
        
        # 4. 描画中のデータフレーム (visible, faded) も更新
        self.current_visible_df = self.df.loc[self.current_visible_df.index]
        self.current_faded_df = self.df.loc[self.current_faded_df.index]
        
        # 5. 再描画
        self.apply_filters_and_redraw() # フィルタを再適用して描画
        
        with self.cluster_output:
            clear_output(wait=True)
            print("✅ ラベルを更新して再描画しました。")
        
        self.update_labels_btn.disabled = False
        self.update_labels_btn.description = '4. ラベルを更新して再描画'

    def redraw_main_map(self):
        """ メインマップの描画ロジック """
        
        df_visible = self.current_visible_df
        df_faded = self.current_faded_df
        
        fig = go.Figure()
        
        # Faded dots (背景)
        fig.add_trace(go.Scatter(
            x=df_faded['umap_x'], y=df_faded['umap_y'], mode='markers',
            marker=dict(color='lightgray', size=3, opacity=0.1),
            hoverinfo='none', name='他'
        ))

        # 色分けモード切替
        if not self.is_applicant_color_mode:
            # モード1: クラスタ色分け
            fig.add_trace(go.Scatter(
                x=df_visible['umap_x'], y=df_visible['umap_y'], mode='markers',
                marker=dict(
                    color=df_visible['cluster'], 
                    colorscale='turbo', 
                    showscale=False, 
                    size=4, 
                    opacity=0.5 
                ), 
                hoverinfo='text', hovertext=df_visible['hover_text'], name='表示対象'
            ))
        else:
            # モード2: 出願人色分け
            colors = px.colors.qualitative.Plotly 
            selected_applicants_raw = list(self.applicant_filter_w.value) 
            applicant_col = self.applicant_col_w.value
            
            for i, app_name_raw in enumerate(selected_applicants_raw):
                app_name = app_name_raw.split(' (')[0].strip()
                df_applicant = df_visible[df_visible[applicant_col].fillna('').str.contains(re.escape(app_name))]
                if not df_applicant.empty:
                    fig.add_trace(go.Scatter(
                        x=df_applicant['umap_x'], y=df_applicant['umap_y'],
                        mode='markers',
                        marker=dict(color=colors[i % len(colors)], size=4, opacity=0.5), 
                        hoverinfo='text', hovertext=df_applicant['hover_text'],
                        name=app_name 
                    ))
        
        # ラベル
        annotations = []
        # チェックボックスがONの時だけラベルを生成
        if self.show_labels_chk.value:
            # visibleなデータに含まれるクラスタIDだけを対象にする
            visible_cluster_ids = df_visible['cluster'].unique()
            
            for cluster_id, group in self.df[self.df['cluster'].isin(visible_cluster_ids)].groupby('cluster'):
                if cluster_id == -1: continue # ノイズは表示しない
                    
                mean_pos = group[['umap_x', 'umap_y']].mean()
                label_text = group['cluster_label'].iloc[0]
                if label_text != "(該当なし)":
                    annotations.append(
                        go.layout.Annotation(
                            x=mean_pos['umap_x'], y=mean_pos['umap_y'], 
                            text=label_text, 
                            showarrow=False, 
                            font=dict(size=11, color='black'),
                        )
                    )
        
        # 軸非表示
        fig.update_layout(
            title=self.current_title, # 保存したタイトル
            showlegend=True, width=1000, height=800, template='plotly_white',
            legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
            annotations=annotations, # チェックボックスに応じて空かどうかが決まる
            xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=None),
            yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=None)
        )
        
        # fig_output に描画
        with self.fig_output:
            clear_output(wait=True)
            display(fig)

    def on_export_btn_clicked(self, b):
        """5. メインマップ結果のエクスポート"""
        with self.export_output:
            clear_output(wait=True)
            if self.df.empty:
                print("🚨 エラー: エクスポートするデータがありません。")
                return
            print("⚙️ CSVファイルをエクスポート中...")
            output_filename = self.filename_w.value
            if not output_filename.endswith('.csv'):
                output_filename += '.csv'
            try:
                # 削除するカラムリストを更新
                cols_to_drop = ['hover_text', 'parsed_date', 'date_bin', 
                                'drill_cluster', 'drill_cluster_label', 'drill_hover_text', 'drill_x', 'drill_y'] 
                export_df = self.df.drop(columns=cols_to_drop, errors='ignore')
                
                export_df.to_csv(output_filename, index=False, encoding='utf-8-sig')
                files.download(output_filename)
                print(f"✅ '{output_filename}' をダウンロードしました。")
            except Exception as e:
                print(f"🚨 エクスポートに失敗しました。 {e}")

    def update_drill_filters(self, df_subset):
        """ドリルダウンセクションのフィルタ選択肢を、指定されたdf_subsetに基づいて更新する"""
        
        # === 1. ドリルダウン用「期間フィルタ」の更新 ===
        date_col = self.date_col_w.value
        if date_col and date_col in df_subset.columns and 'parsed_date' in df_subset.columns and not df_subset['parsed_date'].isnull().all():
            try:
                min_date = df_subset['parsed_date'].min().replace(day=1, month=1)
                max_date = df_subset['parsed_date'].max()
                interval = int(self.drill_bin_interval_w.value) # ドリルダウン用
                
                bins = pd.date_range(start=min_date, end=max_date + pd.DateOffset(years=interval), freq=f'{interval*12}MS')
                labels = [f"{bins[i].year}-{bins[i+1].year - 1}" for i in range(len(bins)-1)]
                
                df_subset_date_binned = df_subset.copy()
                df_subset_date_binned['drill_date_bin'] = pd.cut(df_subset_date_binned['parsed_date'], bins=bins, labels=labels, right=False)
                
                date_bin_counts = df_subset_date_binned['drill_date_bin'].value_counts()
                new_date_options = [f"(全期間) ({len(df_subset)}件)"] 
                for label in labels:
                    count = date_bin_counts.get(label, 0)
                    if count > 0: # 件数0の期間は非表示
                        new_date_options.append(f"{label} ({count}件)")
                
                self.drill_date_bin_filter_w.options = new_date_options
                self.drill_date_bin_filter_w.value = new_date_options[0] 

            except Exception:
                self.drill_date_bin_filter_w.options = [f"(全期間) ({len(df_subset)}件)"]
                self.drill_date_bin_filter_w.value = f"(全期間) ({len(df_subset)}件)"
        else:
            self.drill_date_bin_filter_w.options = [f"(全期間) ({len(df_subset)}件)"]
            self.drill_date_bin_filter_w.value = f"(全期間) ({len(df_subset)}件)"

        # === 2. ドリルダウン用「出願人フィルタ」の更新 ===
        applicant_col = self.applicant_col_w.value
        delimiter = self.applicant_delimiter_w.value
        if applicant_col and applicant_col in df_subset.columns and delimiter:
            try:
                applicants = df_subset[applicant_col].fillna('').str.split(delimiter).explode().str.strip()
                applicant_counts = applicants.value_counts()
                unique_applicants = sorted([app for app in applicants.unique() if app])
                
                new_options = [f"(全出願人) ({len(df_subset)}件)"] 
                for app_name in unique_applicants:
                    count = applicant_counts.get(app_name, 0)
                    if count > 0: # 件数0の出願人は非表示
                        new_options.append(f"{app_name} ({count}件)") 
                        
                self.drill_applicant_filter_w.options = new_options
                self.drill_applicant_filter_w.value = [new_options[0]]
            except Exception:
                self.drill_applicant_filter_w.options = [f"(全期間) ({len(df_subset)}件)"]
                self.drill_applicant_filter_w.value = [f"(全期間) ({len(df_subset)}件)"]
        else:
            self.drill_applicant_filter_w.options = [f"(全期間) ({len(df_subset)}件)"]
            self.drill_applicant_filter_w.value = [f"(全期間) ({len(df_subset)}件)"]

    def on_cluster_select_change(self, change):
        """分析対象クラスタの変更時に、ドリルダウンフィルタを同期する"""
        if 'new' not in change:
            return
        
        selected_cluster_id = change['new']
        
        if selected_cluster_id == "ALL":
            self.update_drill_filters(self.df)
        elif selected_cluster_id in self.df['cluster'].unique():
            df_subset = self.df[self.df['cluster'] == selected_cluster_id].copy()
            self.update_drill_filters(df_subset)
        else:
            # 選択がクリアされた場合など
            self.update_drill_filters(self.df)

    def on_drill_interval_change(self, change):
        """ドリルダウンの期間粒度変更時に、ドリルダウン期間フィルタを更新する"""
        if self.df.empty:
            return
            
        selected_cluster_id = self.cluster_select_w.value
        if selected_cluster_id == "ALL":
            self.update_drill_filters(self.df)
        elif selected_cluster_id in self.df['cluster'].unique():
            df_subset = self.df[self.df['cluster'] == selected_cluster_id].copy()
            self.update_drill_filters(df_subset)
        else:
            self.update_drill_filters(self.df)


    # ドリルダウン用ホバーテキスト更新
    def update_drill_hover_text(self, df_subset):
        """df_subset の drill_hover_text カラムを更新する"""
        # メインのhover_text と サブクラスタラベル を結合
        df_subset['drill_hover_text'] = df_subset.apply(
            lambda row: f"{row['hover_text']}<br><b>サブクラスタ:</b> {row['drill_cluster_label']}", axis=1
        )
        return df_subset

    # ドリルダウン用ラベルUI更新
    def update_drill_label_editor_ui(self):
        """ F-4. ドリルダウン用ラベル編集UIを動的に構築する"""
        # 共通ヘルパーメソッドを呼び出す
        self._create_label_editor_ui(
            self.drill_label_editor_widgets_box,
            self.current_drill_labels_map,
            self.drill_label_widgets,
            'ここに新しいサブクラスタ名を入力'
        )


    # SBERTベクトルで UMAP を実行
    def on_drilldown_clicked(self, b):
        """6. ドリルダウンマップの描画 (データ処理)"""
        
        self.drilldown_btn.disabled = True
        self.drilldown_btn.description = '6. 再マップ中...'
        
        with self.drilldown_output:
            clear_output(wait=True)
            
            selected_cluster_id = self.cluster_select_w.value
            
            if selected_cluster_id == "ALL":
                print("ドリルダウン: (全データ) はメインマップで分析してください。")
                self.current_drill_df = pd.DataFrame() # 状態をリセット
                self.drill_df = pd.DataFrame() # エクスポート用にリセット
                self.drill_label_editor_widgets_box.children = [widgets.Label("メインマップでクラスタを選択してください。")]
                self.drilldown_btn.disabled = False
                self.drilldown_btn.description = '6. 選択クラスタで再マップ (ドリルダウン)'
                return
            
            try:
                # 1. 選択クラスタのベースデータを取得
                df_subset = self.df[self.df['cluster'] == selected_cluster_id].copy()
                base_label = df_subset['cluster_label'].iloc[0] # メインのラベル
                
                # --- ドリルダウン専用フィルタを適用 ---
                
                # 期間フィルタを適用
                selected_date_bin_raw = self.drill_date_bin_filter_w.value
                if not selected_date_bin_raw.startswith('(全期間)'):
                    date_bin_label_for_filter = selected_date_bin_raw.split(' (')[0].strip()
                    
                    interval = int(self.drill_bin_interval_w.value)
                    min_date = df_subset['parsed_date'].min().replace(day=1, month=1)
                    max_date = df_subset['parsed_date'].max()
                    bins = pd.date_range(start=min_date, end=max_date + pd.DateOffset(years=interval), freq=f'{interval*12}MS')
                    labels = [f"{bins[i].year}-{bins[i+1].year - 1}" for i in range(len(bins)-1)]
                    df_subset['drill_date_bin'] = pd.cut(df_subset['parsed_date'], bins=bins, labels=labels, right=False)
                    
                    date_mask = (df_subset['drill_date_bin'].astype(str) == date_bin_label_for_filter)
                    df_subset = df_subset[date_mask]

                # 出願人フィルタを適用
                applicant_col = self.applicant_col_w.value
                selected_applicants_raw = list(self.drill_applicant_filter_w.value)
                is_all_applicants_selected = any(s.startswith('(全出願人)') for s in selected_applicants_raw)

                if not is_all_applicants_selected and selected_applicants_raw:
                    mask_list = []
                    for app_name_raw in selected_applicants_raw:
                        app_name = app_name_raw.split(' (')[0].strip()
                        if applicant_col and applicant_col in self.df.columns:
                            mask_list.append(df_subset[applicant_col].fillna('').str.contains(re.escape(app_name)))
                    
                    if mask_list:
                        applicant_mask = pd.concat(mask_list, axis=1).any(axis=1)
                        df_subset = df_subset[applicant_mask]
                
                # --- フィルタリングここまで ---

                if len(df_subset) < 20: # データが少なすぎる場合
                    print(f"クラスタ {selected_cluster_id}: フィルタ適用後のデータが少なすぎるため再マップできません (20件未満)。")
                    self.current_drill_df = pd.DataFrame() # 状態をリセット
                    self.drill_df = pd.DataFrame() # エクスポート用にリセット
                    self.drill_label_editor_widgets_box.children = [widgets.Label("データ件数が少なく、サブクラスタを作成できません。")]
                    self.drilldown_btn.disabled = False
                    self.drilldown_btn.description = '6. 選択クラスタで再マップ (ドリルダウン)'
                    return
                
                print(f"⚙️ クラスタ「{base_label}」のドリルダウンを実行中 (対象件数: {len(df_subset)}件)...")
                
                # 2. 元のベクトルからスライス (TF-IDF と SBERT の両方)
                subset_indices = df_subset.index
                subset_tfidf = self.tfidf_matrix[subset_indices]
                subset_sbert_embeddings = self.sbert_embeddings[subset_indices] # SBERTベクトルをスライス

                # 3. UMAPとHDBSCANを「再実行」
                n_neighbors_drill = min(10, len(df_subset) - 1) 
                if n_neighbors_drill < 2: n_neighbors_drill = 2 # 最小値は2
                
                reducer_drill = umap.UMAP(n_neighbors=n_neighbors_drill, min_dist=0.1, n_components=2, random_state=42)
                
                # UMAPの入力を SBERT に変更
                embedding_drill = reducer_drill.fit_transform(subset_sbert_embeddings) 
                
                df_subset['drill_x'] = embedding_drill[:, 0]
                df_subset['drill_y'] = embedding_drill[:, 1]
                
                min_cluster_size_drill_val = int(self.drill_min_cluster_size_w.value)
                min_samples_drill_val = int(self.drill_min_samples_w.value)
                
                clusterer_drill = hdbscan.HDBSCAN(
                    min_cluster_size=min_cluster_size_drill_val,
                    min_samples=min_samples_drill_val,
                    metric='euclidean',
                    cluster_selection_method='eom'
                )
                clustering_drill = clusterer_drill.fit(embedding_drill)
                df_subset['drill_cluster'] = clustering_drill.labels_

                # 4. ドリルダウン後のラベルとホバーテキストを生成
                self.current_drill_labels_map = {} # ラベルマップをリセット
                label_top_n = int(self.drill_label_top_n_w.value)
                for cluster_id in sorted(df_subset['drill_cluster'].unique()):
                    if cluster_id == -1:
                        self.current_drill_labels_map[cluster_id] = "ノイズ"
                        continue
                    indices_drill = df_subset[df_subset['drill_cluster'] == cluster_id].index
                    
                    # ラベリングは TF-IDF で行う (ハイブリッド)
                    # df_subsetのインデックス(indices_drill)を、subset_tfidfの行番号(0-indexed)に変換
                    subset_tfidf_indices = [subset_indices.get_loc(idx) for idx in indices_drill if idx in subset_indices]
                    
                    mean_vector = np.array(subset_tfidf[subset_tfidf_indices].mean(axis=0)).flatten()
                    top_indices = np.argsort(mean_vector)[::-1][:label_top_n]
                    label = ", ".join([self.feature_names[i] for i in top_indices])
                    self.current_drill_labels_map[cluster_id] = f"[{cluster_id}] {label}"
                
                df_subset['drill_cluster_label'] = df_subset['drill_cluster'].map(self.current_drill_labels_map)
                
                # ホバーテキスト更新
                df_subset = self.update_drill_hover_text(df_subset)
                
                # 描画用・エクスポート用に結果をクラス変数に保存
                self.current_drill_df = df_subset.copy()
                self.drill_df = self.current_drill_df.copy()

                # 5. ラベル編集UIの構築
                self.update_drill_label_editor_ui()
                
                # 6. 描画
                self.redraw_drill_map()
            
            except Exception as e:
                print(f"🚨 ドリルダウン中にエラー: {e}")
                self.current_drill_df = pd.DataFrame() # 状態をリセット
                self.drill_df = pd.DataFrame() # エクスポート用にリセット
                import traceback
                traceback.print_exc()
                
        self.drilldown_btn.disabled = False
        self.drilldown_btn.description = '6. 選択クラスタで再マップ (ドリルダウン)'
        
    def on_update_drill_labels_clicked(self, b):
        """ 7. ドリルダウン用ラベル更新ボタンの処理"""
        if self.current_drill_df.empty:
            with self.drilldown_output:
                clear_output(wait=True)
                print("🚨 エラー: まず「6. ドリルダウン」を実行してください。")
            return
            
        self.update_drill_labels_btn.disabled = True
        self.update_drill_labels_btn.description = '7. 更新中...'

        with self.drilldown_output:
            clear_output(wait=True)
            print("⚙️ サブクラスタ・ラベルを更新中...")

        # 1. 新しいラベルマップを構築
        new_labels_map = {}
        for cluster_id, text_widget in self.drill_label_widgets.items():
            new_labels_map[cluster_id] = text_widget.value
        if -1 in self.current_drill_labels_map:
            new_labels_map[-1] = self.current_drill_labels_map[-1]
        
        # 2. self.current_drill_df の 'drill_cluster_label' を上書き
        self.current_drill_df['drill_cluster_label'] = self.current_drill_df['drill_cluster'].map(new_labels_map)

        # 3. ホバーテキストも更新
        self.current_drill_df = self.update_drill_hover_text(self.current_drill_df)
        
        # 4. エクスポート用DFも更新
        self.drill_df = self.current_drill_df.copy()
        
        # 5. 再描画
        self.redraw_drill_map()
        
        with self.drilldown_output:
            clear_output(wait=True)
            print("✅ サブクラスタ・ラベルを更新して再描画しました。")
        
        self.update_drill_labels_btn.disabled = False
        self.update_drill_labels_btn.description = '7. サブクラスタ・ラベルを更新して再描画'

    def on_drill_show_labels_changed(self, change):
        """ドリルダウン用ラベル表示チェックボックスが変更されたら、マップを再描画する"""
        # データがなければ何もしない
        if self.current_drill_df.empty:
            return
            
        with self.drilldown_output:
            clear_output(wait=True)
            print(f"⚙️ ラベル表示を「{change['new']}」に変更して再描画します...")

        self.redraw_drill_map() # マップを再描画
        
        with self.drilldown_output:
            clear_output(wait=True)
            print("✅ ラベル表示を更新しました。")

    def redraw_drill_map(self):
        """ドリルダウンマップの描画ロジック"""
        
        df_subset = self.current_drill_df
        if df_subset.empty:
            with self.drilldown_output:
                clear_output(wait=True)
                # print("描画するデータがありません。") # 6. 実行直後は描画されるので不要
            return
        
        # メインのラベル(タイトル用)
        base_label = df_subset['cluster_label'].iloc[0]

        fig_drill = go.Figure()
        
        fig_drill.add_trace(go.Scatter(
            x=df_subset['drill_x'], y=df_subset['drill_y'], mode='markers',
            marker=dict(
                color=df_subset['drill_cluster'], 
                colorscale='turbo', 
                showscale=False, 
                size=4, 
                opacity=0.5 
            ),
            hoverinfo='text', hovertext=df_subset['drill_hover_text'], name='表示対象'
        ))
        
        # ラベル描画
        annotations_drill = []
        if self.drill_show_labels_chk.value:
            for cluster_id, group in df_subset[df_subset['drill_cluster'] != -1].groupby('drill_cluster'):
                mean_pos = group[['drill_x', 'drill_y']].mean()
                label_text = group['drill_cluster_label'].iloc[0] # サブクラスタラベル
                
                annotations_drill.append(go.layout.Annotation(
                    x=mean_pos['drill_x'], y=mean_pos['drill_y'], 
                    text=label_text,
                    showarrow=False, 
                    font=dict(size=11, color='black'),
                ))
        
        # 軸非表示
        fig_drill.update_layout(
            title=f'Saturn V ドリルダウン: {base_label}',
            height=800, 
            width=1000,
            template='plotly_white',
            annotations=annotations_drill,
            xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=None),
            yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=None)
        )
        
        with self.drilldown_output:
            clear_output(wait=True) # メッセージを消して
            display(fig_drill) # マップを表示


    def on_drill_export_clicked(self, b):
        """8. ドリルダウン結果のエクスポート"""
        with self.drill_export_output:
            clear_output(wait=True)
            if self.drill_df.empty: # エクスポート専用DFを見る
                print("🚨 エラー: エクスポートするドリルダウンデータがありません。")
                print("   (先に「6. ドリルダウン」を実行してください)")
                return
            
            print("⚙️ ドリルダウン結果をCSVファイルにエクスポート中...")
            output_filename = self.drill_filename_w.value
            if not output_filename.endswith('.csv'):
                output_filename += '.csv'
            
            try:
                # 削除するカラムリストを更新
                cols_to_drop = [
                    'hover_text', 'parsed_date', 'date_bin', 
                    'drill_hover_text', 'drill_date_bin' 
                ]
                export_df = self.drill_df.drop(columns=cols_to_drop, errors='ignore')
                
                export_df.to_csv(output_filename, index=False, encoding='utf-8-sig')
                files.download(output_filename)
                print(f"✅ '{output_filename}' をダウンロードしました。")
            except Exception as e:
                print(f"🚨 エクスポートに失敗しました。 {e}")

    # --- 特許マップ(Hセクション)用コールバック ---

    def _get_filtered_stats_df(self):
        """Hセクションの特許マップで使われる、フィルタ済みのDFを(描画なしで)取得する"""
        
        if self.df.empty or 'cluster' not in self.df.columns:
            return pd.DataFrame()
            
        df_stats_base = self.df.copy() # 全データから開始
        
        # --- Bセクションのフィルタを適用 (期間) ---
        selected_date_bin_raw = self.date_bin_filter_w.value
        if not selected_date_bin_raw.startswith('(全期間)'):
            date_bin_label_for_filter = selected_date_bin_raw.split(' (')[0].strip()
            date_mask = (df_stats_base['date_bin'].astype(str) == date_bin_label_for_filter)
            df_stats_base = df_stats_base[date_mask]
        
        # --- Bセクションのフィルタを適用 (出願人) ---
        applicant_col = self.applicant_col_w.value
        selected_applicants_raw = list(self.applicant_filter_w.value)
        is_all_applicants_selected = any(s.startswith('(全出願人)') for s in selected_applicants_raw)

        if not is_all_applicants_selected and selected_applicants_raw:
            mask_list = []
            for app_name_raw in selected_applicants_raw:
                app_name = app_name_raw.split(' (')[0].strip()
                if applicant_col and applicant_col in self.df.columns:
                    mask_list.append(df_stats_base[applicant_col].fillna('').str.contains(re.escape(app_name)))
            
            if mask_list:
                applicant_mask = pd.concat(mask_list, axis=1).any(axis=1) # OR条件
                df_stats_base = df_stats_base[applicant_mask]
            else:
                df_stats_base = df_stats_base[pd.Series(False, index=df_stats_base.index)] # 空にする
        
        # --- Hセクションのフィルタを適用 (クラスタ) ---
        selected_clusters_raw = list(self.stats_cluster_filter_w.value)
        is_all_clusters_selected = any(s == "ALL" for s in selected_clusters_raw)
        
        if not is_all_clusters_selected and selected_clusters_raw:
            selected_cluster_ids = [cid for cid in selected_clusters_raw if cid != "ALL"]
            cluster_mask = df_stats_base['cluster'].isin(selected_cluster_ids)
            df_stats_base = df_stats_base[cluster_mask]
        
        # --- 年カラムのチェック ---
        if 'parsed_date' not in df_stats_base.columns or df_stats_base['parsed_date'].isnull().all():
            with self.stats_output_1:
                clear_output(wait=True)
                print("🚨 エラー: 「出願日カラム」の指定が必須です(A. ファイル設定)。")
            return pd.DataFrame()
        
        df_stats_base['出願年'] = df_stats_base['parsed_date'].dt.year
        return df_stats_base

    def on_update_stats_period_clicked(self, b):
        """(新設)Hセクションの期間を、フィルタ結果のmin/maxに自動設定する"""
        
        with self.stats_output_1:
            clear_output(wait=True)
            print("⚙️ フィルタ結果から期間を自動設定中...")
        with self.stats_output_2: clear_output(wait=True)
        with self.stats_output_3: clear_output(wait=True)

        df_stats_base = self._get_filtered_stats_df()
        
        if df_stats_base.empty:
            with self.stats_output_1:
                clear_output(wait=True)
                print("🚨 期間の自動設定に失敗しました。データが見つかりません。")
            return

        if not df_stats_base.empty:
            min_year = df_stats_base['出願年'].min()
            max_year = df_stats_base['出願年'].max()
            
            if pd.notna(min_year) and pd.notna(max_year):
                self.stats_start_year_w.value = int(min_year)
                self.stats_end_year_w.value = int(max_year)
                with self.stats_output_1:
                    clear_output(wait=True)
                    print(f"✅ 期間を {int(min_year)}年 ~ {int(max_year)}年 に自動設定しました。")
            else:
                with self.stats_output_1:
                    clear_output(wait=True)
                    print("🚨 期間の自動設定に失敗しました。有効な出願年データがありません。")

    def on_stats_run_clicked(self, b):
        """9. 特許マップを描画ボタンが押されたときの処理"""
        
        # 0. エラーチェック
        if self.df.empty or 'cluster' not in self.df.columns:
            with self.stats_output_1:
                clear_output(wait=True)
                print("🚨 エラー: 描画対象のデータがありません。")
                print("「3. 描画 (再計算)」を先に実行してください。")
            with self.stats_output_2: clear_output(wait=True)
            with self.stats_output_3: clear_output(wait=True)
            return

        # 手動入力された値(年、件数)を読み取る
        try:
            start_year = int(self.stats_start_year_w.value)
            end_year = int(self.stats_end_year_w.value)
            num_assignees = int(self.stats_num_assignees_w.value)
        except Exception:
            with self.stats_output_1:
                clear_output(wait=True)
                print("🚨 エラー: 特許マップ設定(年、表示件数)が正しくありません。")
            with self.stats_output_2: clear_output(wait=True)
            with self.stats_output_3: clear_output(wait=True)
            return
        
        self.stats_run_btn.disabled = True
        self.stats_run_btn.description = '9. 特許マップを描画中...'
        
        # 出力エリアを先にクリア
        with self.stats_output_1: clear_output(wait=True)
        with self.stats_output_2: clear_output(wait=True)
        with self.stats_output_3: clear_output(wait=True)

        # 1. データの準備 (フィルタ適用)
        df_stats_base = self._get_filtered_stats_df()
        
        if df_stats_base.empty:
            # _get_filtered_stats_df 内でエラーメッセージは表示済み
            self.stats_run_btn.disabled = False
            self.stats_run_btn.description = '9. 特許マップを描画'
            return

        # 読み取った年でフィルタリング
        df_stats = df_stats_base[(df_stats_base['出願年'] >= start_year) & (df_stats_base['出願年'] <= end_year)]
        
        if df_stats.empty:
            with self.stats_output_1:
                print(f"🚨 該当するデータがありません。")
                print("フィルタ条件(特に期間)を見直してください。")
            self.stats_run_btn.disabled = False
            self.stats_run_btn.description = '9. 特許マップを描画'
            return

        # フィルタ条件のサマリーに出力
        with self.stats_output_1:
            print("--- 以下の条件で集計 ---")
            
            # Bセクションのフィルタ (サマリー用に再取得)
            selected_applicants_raw = list(self.applicant_filter_w.value)
            is_all_applicants_selected = any(s.startswith('(全出願人)') for s in selected_applicants_raw)
            applicant_name_list = [s.split(' (')[0].strip() for s in selected_applicants_raw if not s.startswith('(全出願人)')]
            
            b_filter_date = self.date_bin_filter_w.value
            b_filter_app = "(全出願人)" if is_all_applicants_selected else ", ".join(applicant_name_list)
            print(f"B. 期間/出願人フィルタ: (期間: {b_filter_date}, 出願人: {b_filter_app})")
            
            # Hセクションのフィルタ (サマリー用に再取得)
            selected_clusters_raw = list(self.stats_cluster_filter_w.value)
            is_all_clusters_selected = any(s == "ALL" for s in selected_clusters_raw)
            
            h_filter_cluster = "(全クラスタ)"
            if not is_all_clusters_selected:
                selected_cluster_ids = [cid for cid in selected_clusters_raw if cid != "ALL"]
                h_filter_cluster_labels = [self.cluster_labels_map.get(cid, "") for cid in selected_cluster_ids]
                h_filter_cluster = ", ".join(h_filter_cluster_labels)
                
            print(f"H. 特許マップフィルタ: (クラスタ: {h_filter_cluster}, 年: {start_year}年 ~ {end_year}年)")
            print(f"▶ 集計対象件数: {len(df_stats)} 件")
            print("--------------------------")


        # 2. 必要なカラム名を取得
        date_col = self.date_col_w.value
        assignee_col = self.applicant_col_w.value
        
        # 3. 各マップの描画
        with self.stats_output_1:
            print("\n⚙️ MAP 1: 出願件数時系列推移 ...")
            self.create_application_trend_chart_internal(df_stats, date_col)
            
        with self.stats_output_2:
            clear_output(wait=True) # クリア
            print("⚙️ MAP 2: 出願人ランキング ...")
            self.create_assignee_ranking_map_internal(df_stats, assignee_col, num_assignees)
            
        with self.stats_output_3:
            clear_output(wait=True) # クリア
            print("⚙️ MAP 3: 出願年別出願人動向 ...")
            self.create_assignee_year_bubble_chart_internal(df_stats, assignee_col, num_assignees)

        # メインの出力(cluster_output)にも完了メッセージを出す
        with self.cluster_output:
            clear_output(wait=True)
            print("✅ 特許マップの描画が完了しました。") 
            
        self.stats_run_btn.disabled = False
        self.stats_run_btn.description = '9. 特許マップを描画'

    # 特許マップ描画関数群 (クラスメソッド化)
    
    def create_application_trend_chart_internal(self, df_stats, date_col):
        """MAP 1: 出願件数時系列推移 (matplotlib)"""
        if date_col not in df_stats.columns:
            print(f"🚨 エラー: 日付カラム '{date_col}' が見つかりません。")
            return
            
        yearly_counts = df_stats['出願年'].value_counts().sort_index()
        if yearly_counts.empty:
            print("有効な出願年データがありません。")
            return

        start_year, end_year = yearly_counts.index.min(), yearly_counts.index.max()
        plot_data = yearly_counts.loc[start_year:end_year]
        if plot_data.empty:
            print("指定期間にデータがありません。")
            return
            
        plt.style.use('seaborn-v0_8-talk')
        fig, ax = plt.subplots(figsize=(16, 8))
        bars = ax.bar(plot_data.index, plot_data.values, color='steelblue')
        ax.set_title(f'出願件数時系列推移 ({start_year}年~{end_year}年)', fontsize=20, pad=20)
        ax.set_xlabel('出願年', fontsize=14); ax.set_ylabel('出願件数', fontsize=14)
        ax.grid(axis='y', linestyle='--', alpha=0.7)
        ax.xaxis.set_major_locator(MaxNLocator(integer=True)); plt.xticks(rotation=45)
        ax.set_ylim(bottom=0)
        for bar in bars:
            height = bar.get_height()
            ax.annotate(f'{int(height)}', xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 3), textcoords="offset points", ha='center', va='bottom')
        plt.tight_layout()
        plt.show()

    def create_assignee_ranking_map_internal(self, df_stats, assignee_col, num_to_display):
        """MAP 2: 出願人ランキング (matplotlib)"""
        if assignee_col not in df_stats.columns:
            print(f"🚨 エラー: 出願人カラム '{assignee_col}' が見つかりません。")
            return
            
        assignee_counts = df_stats[assignee_col].astype(str).str.split(self.applicant_delimiter_w.value).explode().str.strip().value_counts()
        data_to_plot = assignee_counts.head(num_to_display).sort_values(ascending=True)

        if data_to_plot.empty:
            print("集計結果がありません。")
            return
            
        plt.style.use('seaborn-v0_8-talk')
        fig, ax = plt.subplots(figsize=(12, max(5, 0.4 * len(data_to_plot)))) # 最小高さ設定
        bars = ax.barh(data_to_plot.index, data_to_plot.values, color='steelblue')
        ax.set_title(f'出願人ランキング (上位{num_to_display}件)', fontsize=20, pad=20)
        ax.set_xlabel('特許件数', fontsize=14); ax.set_ylabel('出願人名', fontsize=14)
        for bar in bars:
            width = bar.get_width()
            ax.text(width + width * 0.01, bar.get_y() + bar.get_height()/2, f'{int(width)}', ha='left', va='center', fontsize=12)
        ax.set_xlim(right=ax.get_xlim()[1] * 1.15)
        ax.grid(axis='x', linestyle='--', alpha=0.6)
        plt.tight_layout()
        plt.show()

    def create_assignee_year_bubble_chart_internal(self, df_stats, assignee_col, num_to_display):
        """MAP 3: 出願年別出願人動向 (matplotlib)"""
        if assignee_col not in df_stats.columns:
            print(f"🚨 エラー: 出願人カラム '{assignee_col}' が見つかりません。")
            return

        assignees_exploded = df_stats.copy()
        assignees_exploded[assignee_col] = assignees_exploded[assignee_col].astype(str).str.split(self.applicant_delimiter_w.value)
        assignees_exploded = assignees_exploded.explode(assignee_col)
        assignees_exploded['assignee_parsed'] = assignees_exploded[assignee_col].str.strip()
        top_assignees = assignees_exploded['assignee_parsed'].value_counts().head(num_to_display).index.tolist()
        
        plot_data = assignees_exploded[assignees_exploded['assignee_parsed'].isin(top_assignees)]
        plot_data = plot_data.groupby(['出願年', 'assignee_parsed']).size().reset_index(name='件数')
        if plot_data.empty:
            print("集計結果が空のため、このマップはスキップします。")
            return
            
        assignee_rank_map = {name: i for i, name in enumerate(top_assignees[::-1])}
        plot_data['y_rank'] = plot_data['assignee_parsed'].map(assignee_rank_map)
        cmap = plt.get_cmap('Set2', len(top_assignees))

        fig, ax = plt.subplots(figsize=(16, max(8, 0.6 * num_to_display)))
        ax.scatter(x=plot_data['出願年'], y=plot_data['y_rank'], s=plot_data['件数'] * 40, c=plot_data['y_rank'], cmap=cmap, alpha=0.8)
        for _, row in plot_data.iterrows():
            ax.text(row['出願年'], row['y_rank'], row['件数'], ha='center', va='center', fontsize=9, color='black')
        ax.set_yticks(range(len(top_assignees))); ax.set_yticklabels(top_assignees[::-1])
        ax.set_title(f'出願年別出願人動向', fontsize=20, pad=20) 
        ax.set_xlabel('出願年', fontsize=14); ax.set_ylabel('出願人', fontsize=14)
        ax.grid(True, linestyle='--', alpha=0.7); ax.xaxis.set_major_locator(MaxNLocator(integer=True))
        plt.tight_layout()
        plt.show()

# --- アプリケーションの実行 ---
# 1. クラスのインスタンスを作成
app = SaturnVApp() 
# 2. UIを表示
app.ui

7. 実施例

初期画面1
初期画面2
初期画面3
全体俯瞰図
他社比較
クラスタごとの出願件数時系列推移マップ
クラスタごとの出願人ランキングマップ
クラスタごとの出願年別出願人動向マップ

8. 多段階分析の具体的な流れ

Saturn Vの価値は、これらの機能が連動することにある。以下に、分析シナリオを示す。

  1. 【課題】 「ドローン技術」全体の動向を分析したい。

  2. 【Stage 1:俯瞰】 ドローン関連特許(例:2000件)をSaturn Vに投入(上記チュートリアル 1〜6 を実行)。Eセクションのメインマップで全体像を把握する。

  3. 【Stage 1:発見】マップ上で特に密度の高い、巨大なクラスタ[5]を発見。ラベルは「[5] 制御, センサ, 姿勢」となっている。

  4. 【Stage 2:詳細化】このクラスタ[5]は大きすぎるため、Fセクションで[5]を選択し「6. ドリルダウン」を実行。クラスタ[5]が「ジャイロ, 慣性航法」と「画像, 認識, 回避」という二つのサブクラスタに分かれたことを確認する。分析者は特に「画像認識による回避技術」に強い関心を持つ。

  5. 【Stage 3:定量化】分析者は、まずこの技術分野[5]全体のプレイヤーと動向を知るため、Hセクションに移動する。

    • (a) Hセクションの「集計対象クラスタ」で「[5] 制御, センサ, 姿勢」を選択する。

    • (b) 「(フィルタ結果から期間を自動設定)」ボタンで期間を自動設定(例:2015〜2024年)。

    • (c) 「9. 特許マップを描画」を実行する。

  6. 【Stage 3:分析】

    • H-1(時系列)から、クラスタ[5]全体は2022年以降に出願が急増している「成長期」の技術であると判明。

    • H-2(出願人ランク)から、上位はA社(ドローン老舗)、B社(IT大手)、C社(新興企業)が僅差で続いていると判明。

  7. 【Stage 1+3:クロス分析】ドリルダウン(Stage 2)で得た知見と、統計(Stage 3)で得た「B社とC社が主要プレイヤーである」という知見を組み合わせる。 分析者は「B社とC社は、ジャイロと画像認識のどちらに注力しているのか?」という新たな仮説を立てる。

  8. 【仮説検証】 Bセクションの「出願人(フィルタ)」で「B社」と「C社」のみを選択する。Eセクションのメインマップが自動更新され、B社とC社の特許のみがハイライトされる。

  9. 【最終インサイト】 ハイライトされた箇所が、メインマップ上でクラスタ[5]の領域、かつ、ドリルダウンマップで「画像, 認識, 回避」に相当する位置に集中していることを視覚的に確認する。

    これにより、「競合であるB社・C社は、ドローンの制御技術全般(ジャイロ等)に参入しているのではなく、自社の強みである画像認識技術を武器に、回避技術の分野へピンポイントで参入してきている」という、具体的かつ実務的なインサイトを導き出すことができる。

9. おわりに

本稿では、セマンティック分析と統計分析の融合を目指した、新たな総合特許情報分析システム「Saturn V」の設計思想、中核理論、そして具体的な機能について詳述した。

その核心は、SBERTとTF-IDFの長所を戦略的に組み合わせた「ハイブリッド・パイプライン」、そしてHDBSCANのパラメータ(最小クラスタサイズ、最小サンプル数)の調整による「分析解像度の制御」にある。これらは、単なる技術的な選択ではなく、「分析者の思考プロセスに寄り添う」という本システムの設計思想の表れである。

Saturn Vの名は、その多段階分析のコンセプトに由来する。特許情報分析とは、多くの場合、全体像の俯瞰(Stage 1)から始まり、関心領域の詳細化(Stage 2)、そして客観的なデータによる定量的な裏付け(Stage 3)へと進む。Saturn Vは、この分析フェーズの移行をシームレスに支援し、分析者が仮説と検証を高速に繰り返すためのナビゲーション・システムとなることを目指して開発された。

もちろん、Saturn Vは万能の解決策ではない。本稿で紹介したチュートリアルのように、現状の設計では、ドリルダウン分析(Stage 2)で得た詳細なサブクラスタの知見を、特許マップ(Stage 3)の集計対象に直接反映させることはできず、分析者がメインマップ(Stage 1)に戻ってフィルタ操作を行う必要がある。このようなUI/UX上の制約は存在しており、今後の改善の余地は大きい。

しかし、ツールはあくまで道具であり、分析の主役は常に人間である。最も重要なのは、ツールを扱う分析者の「問い」そのものである。どのような解像度で技術群を切り分けるのか。どのクラスタの動向に着目し、どのような統計的裏付け(Hセクション)や視覚的証拠(Bセクションのフィルタ)を探しに行くのか。Saturn Vが提供するセマンティックな座標と統計的な数値は、すべて分析者のその「問い」に応えるための素材に過ぎない。

Saturn Vが、複雑な特許情報という広大な宇宙の中から、価値あるインサイトを引き出すための一助となり、そして何よりも、読者の皆様の新たな「問い」を生み出すきっかけとなれば、開発者としてこれに勝る喜びはない。

バグ修正について(2025/11/13)

Saturn Vをご利用してくださっている方より、「特徴語」の表示に関する重大なバグのご指摘をいただきました。上記掲載のコードはこの問題を修正しております。

【バグ修正の要約】

  • 問題の事象: E.分析結果(メインマップ)において、個々の特許にカーソルを合わせた際に表示される「特徴語」について、その文書内に含まれる有効な単語がtop_n(デフォルト: 5)個に満たない場合がありました。この際、従来のロジックでは、スコアが0.0の「その文書とは無関係な単語」が辞書から選ばれ、特徴語の2つ目以降として誤って表示されてしまうケースがありました。

  • 修正内容: 特徴語を抽出するget_top_tfidf_words関数を修正しました。TF-IDFスコア順に単語を並べた後、スコアが0.0より大きい単語だけを対象とし、その中から上位top_n個を表示するようにロジックを変更しました。

  • 影響範囲: この修正はマップの座標計算(SBERT)やクラスタラベル(HDBSCAN + 平均TF-IDF)には一切影響を与えません。個々の文書のホバー表示(特徴語)の正確性のみが向上します。

詳細な分析とご報告をいただいたユーザー様に、この場を借りて深く感謝申し上げます。

いいなと思ったら応援しよう!

しばやま よろしければ応援お願いします。いただいたチップはクリエイターとしての研究開発の活動費に使わせていただきます!