見出し画像

特許戦略分析ツール | MEGA:CAGRとポートフォリオで技術動態と構成を統合分析


1. はじめに

特許情報の分析は、技術開発のトレンド把握、競合他社の動向監視、そして自社の研究開発戦略を策定する上で不可欠な業務である。しかし、その分析は往々にして「特許出願件数などのデータから技術全体の動態を俯瞰すること(マクロ)」と「個別技術の内容を深く掘り下げること(ミクロ)」という、異なるスケールの視点を同時に要求される。

特許件数の推移を分析する「動態分析」は強力だが、個別の技術クラスタの詳細な内容までは踏み込めない。一方で、個別の特許公報を読み込む分析は、技術全体の大きな流れを見失う危険性がある。

本稿では、この「マクロ(技術動態)」と「ミクロ(技術ポートフォリオ)」の2つの視点をシームレスに繋ぐことを目的に開発された、Google Colab上で動作する無料のPython分析ツール「MEGA」を公開し、その具体的な使用方法と、背景にあるアルゴリズムについて解説する。

MEGAは、Matrix for Emerging & Growth Assessment(新興領域と成長性の評価マトリクス)の略称である。この名称は、特許データから「新興領域」と「成長性」を評価するマトリクス分析を行うことを意味する。具体的には、何千件という巨大な特許データ全体から、技術開発全体の「勢い(内在的な成長ポテンシャル)」と「活動量(直近の活動実態)」の2軸で各技術領域や出願人をマッピングし、新興の兆しを見せる領域や高い成長性を持つ技術を特定する。さらに、特定された領域やプレイヤーの内部技術ポートフォリオを詳細に分析することで、多角的な評価を可能にするツールである。

なお、本ツール(MEGA)は以前の記事で公開したツールのバージョンアップである。

2. 必要なデータ

本ツールは、CSVまたはExcelファイル(.csv, .xlsx, .xls)のアップロードに対応している。 分析の全機能を実行するため、以下のカラムを含む特許リストデータを準備する必要がある。

  • 発明の名称

  • 要約

  • 請求項

    • (上記3つはSBERT/UMAP/HDBSCANのベクトル化に使用)

  • 出願番号

    • (ホバー表示および特許の重複排除に使用)

  • 出願日

    • (動態分析のCAGR計算、技術マップの時系列フィルタに使用)

  • 出願人

    • (動態分析の分析軸、ドリルダウン分析の対象として使用)

  • IPC

    • (動態分析の分析軸、ドリルダウン分析の対象として使用)

  • Fターム

    • (動態分析の分析軸、ドリルダウン分析の対象として使用)

3. 使いかた

3-1. コードの実行とセットアップ

  1. Google Colabを開き、新しいノートブックを作成する。

  2. 本記事のコードをコピーし、Colabのセルに貼り付ける。

  3. セルの実行ボタン(▶)を押す。

  4. ライブラリのインストールとインポートが実行され、数分待つと「ライブラリのインポート完了」と表示される。

  5. その下に、MEGAの操作インターフェース(UI)が表示される。

3-2. A. 分析設定

分析の最初に行う、最も重要な前処理ステップである。

  1. ファイルアップロード:「1. 分析ファイルをアップロード」ボタンを押し、準備した特許データ(CSVまたはExcel)を選択する。

  2. カラム紐付け:アップロードしたファイルの列名が自動で読み込まれる。ドロップダウンを使い、「発明の名称」「出願日」など、各項目がどの列に対応するかを指定する。IPCや出願人が「;」などで区切られている場合は、区切り文字を指定する。

  3. 前処理実行:「2. 全データのSBERTベクトル計算と前処理を実行」ボタンを押す。

    • SBERTモデルのロード、全特許のベクトル計算、TF-IDFの計算、IPC/Fターム/出願人の正規化が実行される。

    • 完了すると「全ての前処理が完了しました」と表示される。

3-3. B. 動態分析(マクロ分析)

特許出願データから、技術全体の動態をマクロに分析する。

  1. 分析パラメータ

    • 分析軸:「出願人」「IPC (メイングループ)」「Fターム」から、マップのプロット対象(例: 出願人ごと)を選択する。

    • Y軸 (現在) の集計年数:Y軸「現在の活動量」を集計する期間(例: 5年 = 直近5年間の合計件数)を指定する。

    • X軸 (過去の勢い) 計算の最終年:X軸「勢い」を計算する際の活動観測の最終年(例: 2023年 = 2023年までに観測された活動のCAGR)を指定する。

    • 軌跡 (過去時点):ハイライトモード時に、何年過去の地点から軌跡を描画するか(例: 5 = 5年前)を指定する。

    • 最小フィルタ件数:分析ノイズを除去するため、「全期間の合計件数」がこの値未満のデータ(例: 10件未満の出願人)を分析対象から除外する。

  2. 動態分析マップ実行:「3. 動態分析マップを描画」ボタンを押す。

    • 指定された分析軸でデータが集計され、X軸(CAGR)とY軸(活動量)が計算される。

    • 「X軸=全体の平均値」「Y軸=全体の平均値」を十字線として、4象限(リーダー、新興、成熟、衰退)が自動分類され、マップが描画される。また、Y軸(現在の活動量)が0のデータは、X軸の値に関わらず「衰退・ニッチ」に分類される。

    • 同時に、ハイライトリストとドリルダウンリストが更新される。

  3. ハイライトと軌跡:動態分析マップ実行後、このリストには分析対象(例: 出願人)が合計件数付きで表示される。

    • リストから1つまたは複数(CtrlまたはShiftキーで複数選択)を選択し、再度「3. 動態分析マップを描画」ボタンを押す。

    • 選択された対象のみがマップに表示され、軌跡(過去時点)で指定した過去時点からの「軌跡」が描画される。

    • 「ハイライトをクリア」ボタンで元の全体マップに戻る。

  4. ラベル編集:自動分類された4象限のラベル(例: リーダー (Leaders))は、ここで自由な日本語(例: 巨大成長技術領域)に編集できる。「4. ラベルを更新」ボタンで、マップ上の凡例が更新される。

【軌跡のロジック】 「軌跡の始点(過去の点)」は、B-1で指定した「軌跡(過去時点)」(例: 5年)に基づいて計算される。これは、「5年前の時点」でのX軸(勢い)とY軸(活動量)を再計算したものである。 例えば、「現在」のY軸が2024年までの合計件数である場合、5年前のY軸は2019年までの合計件数として計算される。これにより、プレイヤーがどの象限から移動してきたかを視覚的に把握できる。

3-4. C. ドリルダウン分析(ミクロ分析)

Bセクションで見つけた特定のグループ(例: 特定の出願人やIPC)の内部を詳細に分析する。

  1. 分析対象の選択:ドロップダウンリストから、B-3のマップに表示されている分析対象を1つ選択する。

  2. マップパラメータ:SBERT/UMAPマップの「見た目」と「グループ分け」の感度を調整する。詳細は「アルゴリズムの解説」で後述するが、まずはデフォルト値(15, 0.1, 5, 5)での実行。

  3. 技術ポートフォリオ分析:「5. 選択対象の技術マップを描画」ボタンを押す。

    • C-1で選択された対象に属する特許のSBERTベクトルのみが抽出される。

    • UMAPが2次元に配置し、HDBSCANが自動でサブクラスタにグループ分けする。

    • TF-IDFにより、各クラスタを代表するキーワードが自動で付与される。

  4. サブクラスタ・ラベル編集:C-3で自動付与されたクラスタ名を、より解釈に適した名前(例: CO2回収技術)に編集できる。

  5. 期間フィルタ:Saturn Vと同様の「期間ビン」方式のフィルタを搭載している。

    • 期間の粒度: 期間を何年ごとに区切るか(例: 3年)を選択する。

    • 表示期間: 粒度に応じて生成された期間(例: 2018-2020年 (30件))を選択する。

  6. マップ更新:C-4(ラベル)またはC-5(期間)を変更した後、「6. ラベル・期間フィルタを更新」ボタンを押す。

    • マップが即座に再描画される。

    • 期間フィルタで選択されなかったデータは、元のクラスタ色を保持したまま「淡い灰色」で背景に表示される。

    • 凡例には、期間外になったクラスタも「(期間外)」として表示され、見失うことがない。

3-5. D. エクスポート

分析結果をCSVファイルとしてダウンロードする。

  • 動態分析マップ結果をエクスポート: Bセクションの集計結果(X/Y座標、CAGR、バブルサイズ、カスタムラベルなど)をエクスポートする。

  • ドリルダウン結果をエクスポート:Cセクションの特許単位のマップデータ(x/y座標、カスタムラベル、発明の名称、要約、請求項など)をエクスポートする。

4. MEGAを用いた分析の具体例

MEGAは、マクロとミクロを往復することで、より深い洞察を得るために設計されている。

分析シナリオ例:「特定技術領域における競合の技術戦略分析」

  1. マクロ分析(Bセクション)の実行

    • A-1: 特定の技術領域に関連する特許データをアップロードする。

    • B-1: 分析軸を「出願人」に設定し、B-3(描画)ボタンを押す。

    • [結果] 動態分析マップが描画される。複数の出願人が、それぞれ「リーダー」象限や「新興・高ポテンシャル」象限にプロットされた。

  2. ミクロ分析(Cセクション)による深掘り

    • C-1: 分析対象として、マクロ分析で注目された特定の出願人を1社選択する。

    • C-2: パラメータはデフォルトのまま、C-3(描画)ボタンを押す。

    • [結果] 選択した出願人の技術ポートフォリオマップが描画され、自動で複数のクラスタに分類された。

    • C-4: 自動ラベルを確認すると、複数の技術クラスタが存在することが分かった。

    • C-5: 期間フィルタを使い、「表示期間」を直近の期間に指定して、C-6(更新)ボタンを押す。

    • [結果] マップが更新される。一部の技術クラスタのプロットは淡い灰色(期間外)になり、一方で別の技術クラスタのプロットは鮮やかな色のまま(期間内)残った。

  3. 分析者による仮説

    • この結果から、選択した出願人が、過去に注力していた技術分野から、近年は別の新興技術分野へと、研究開発の主戦場をシフトさせているという仮説を立てることができる。

5. アルゴリズムの解説

MEGAの分析は、複数のアルゴリズムを組み合わせることで実現されている。

5-1. B. 動態分析(CAGRと絶対軸)

AIによる分類ではなく、客観的な2つの指標(X軸・Y軸)の平均値に基づいた「相対軸」による分類を採用している点が特徴である。

  • X軸(勢い): 特許件数のCAGR B-1で指定した「計算の最終年」までに観測された活動のうち、「最初に出願が観測された年」から「最後に出願が観測された年」までのCAGR(年平均成長率)を計算する。これにより、「たまたま直近が0件だった」というノイズに影響されず、そのプレイヤーが持つ内在的な成長ポテンシャルを評価できる。

  • Y軸(活動量): 直近N年の合計件数 B-1で指定された年数(例: 5年)の合計出願件数を計算する。

  • 十字線(4象限の定義): MEGAは、分析対象データ全体の「重心」を十字線として採用する「相対軸」の手法を採用している。これにより、例えば市場全体が高成長している分野(平均CAGRが高い)では、平均を下回るプレイヤーは「成熟・衰退」側に分類され、逆に縮小市場(平均CAGRが低い)では、平均よりも善戦しているプレイヤーを「リーダー・新興」側に分類するなど、その技術分野の動向を反映した動的な分析が可能になる。

    • X軸の十字線: 最小フィルタ件数を通過したデータ群の「X軸(勢い)の平均値」に設定される。

    • Y軸の十字線: 最小フィルタ件数を通過したデータ群の「Y軸(活動量)の平均値」に設定される。

    • 分類の優先ルール: 上記の4象限分類に先立ち、Y軸(現在の活動量)が0以下のデータは、X軸の値に関わらずすべて「衰退・ニッチ」グループに強制的に分類される。

5-2. C. ドリルダウン(SBERT, UMAP, HDBSCAN)

ドリルダウン分析は、3つのアルゴリズムの連携によって実現されている。

  1. SBERT (SentenceTransformer) :特許の「意味」をベクトル化するAIモデルである。A-3の前処理において、全特許の「名称+要約+請求項」をSBERTに入力し、高次元のベクトルに変換して保持している。

  2. UMAP (Uniform Manifold Approximation and Projection): SBERTが生成した高次元のベクトルを、2次元のXY座標に圧縮するアルゴリズムである。「意味が近い(ベクトルが似ている)特許は、マップ上でも近くに配置する」という特性を持つ。

  3. HDBSCAN (Hierarchical Density-Based Spatial Clustering) KMeansとは異なり、「密度」に基づいてクラスタを自動検出するアルゴリズムである。UMAPが配置した2次元マップに対し、C-2で指定されたパラメータをヒントに、点が密集している領域を自動的にグループ分けする。 HDBSCANの最大の利点は、「どのグループにも属さない点」をノイズとして正確に検出できる点にある。

5-2. C-2 マップパラメータの設定方針(詳細)

これらのパラメータは、UMAP(マップの「見た目」)とHDBSCAN(グループ分け)を調整するためのものである。

  • UMAP 近傍点 (n_neighbors):

    • 概要: UMAPがマップを作成する際に、「1つの点をどれだけ広い範囲の点と関連付けるか」を決める。

    • 値を小さくする(例: 2〜5):

      • 結果: マップは局所的な構造(ミクロな塊)を優先して描画される。各クラスタが細かく分離されがちである。

      • 方針: 「この特許とこの特許がピンポイントで似ている」という局所的な関係を見たい場合に使用する。

    • 値を大きくする(例: 15〜50):

      • 結果: マップは全体的な構造(マクロな塊)を優先して描画される。クラスタ同士が滑らかに繋がり、大局的な配置が分かりやすくなる。

      • 方針: 「技術分野全体の大まかな配置」を掴みたい場合に使用する。(デフォルトの15を推奨)

  • UMAP 最小距離 (min_dist):

    • 概要: マップ上で、似ている点同士をどれだけ「ぎゅっと」近づけるかを決める。

    • 値を小さくする(例: 0.0〜0.1):

      • 結果: クラスタ内の点が密集し、クラスタ間の隙間がはっきりする。メリハリのあるマップになる。

      • 方針: グループ分けを明確にしたい場合に適している。(デフォルトの0.1を推奨)

    • 値を大きくする(例: 0.5〜1.0):

      • 結果: 点が均等に広がり、クラスタ間の隙間が曖昧になる。全体の分布をぼんやりと眺めたい場合に適している。

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

    • 概要: 最も重要なパラメータである。「最低何件の特許が集まったら、それを一つのグループ(クラスタ)とみなすか」を指定する。

    • 値を小さくする(例: 3〜5):

      • 結果: 小さな塊も積極的にクラスタとして認識する。クラスタの数が多くなり、ノイズ(灰色の点)が減る。

      • 方針: 細かい技術トピックまで拾いたい場合に使用する。

    • 値を大きくする(例: 10〜20):

      • 結果: 大きな塊だけをクラスタとして認識する。小さな塊は「ノイズ」として分類され、クラスタの数が減る。

      • 方針: 主要な技術トピック(太い幹)だけを明確にしたい場合に使用する。

  • HDBSCAN 最小サンプル数 (min_samples):

    • 概要: クラスタの「密度」を定義する。この値が大きいほど、「より密集した」塊でないとクラスタとして認識されなくなる。

    • 方針: 基本的には「最小クラスタサイズ」と同じ値(デフォルト: 5)のままにしておくことを推奨する。

6. コード

# ==================================================================
# --- 1. ライブラリのインストールとインポート ---
# ==================================================================
!pip install pandas numpy scikit-learn openpyxl ipywidgets plotly sentence-transformers keybert janome umap-learn hdbscan -q

from google.colab import output
output.enable_custom_widget_manager()

import pandas as pd
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
import plotly.io as pio
from google.colab import files
import ipywidgets as widgets
from ipywidgets import VBox, HBox, Layout, Output
from IPython.display import display, clear_output
import io
import datetime
import warnings
import unicodedata
import re

from sentence_transformers import SentenceTransformer
from keybert import KeyBERT
from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from umap import UMAP 
import hdbscan 

warnings.filterwarnings('ignore')
pio.renderers.default = "colab"

# ==================================================================
# --- 2. ヘルパー関数 ---
# ==================================================================

t = Tokenizer()

stop_words = {
    "する","ある","なる","ため","こと","よう","もの","これ","それ","あれ","ここ","そこ","どれ","どの","この","その","当該","該","および","及び","または","また","例えば","例えばは","において","により","に対して","に関して","について","として","としては","場合","一方","他方","さらに","そして","ただし","なお","等","など","等々","いわゆる","所謂","同様","同時","前記","本","同","各","各種","所定","所望","一例","他","一部","一つ","複数","少なくとも","少なくとも一つ","上記","下記","前述","後述","既述","関する","基づく","用いる","使用","利用","有する","含む","備える","設ける","すなわち","従って","しかしながら","次に","特に","具体的に","詳細に","いずれ","うち","それぞれ","とき","かかる","かような","かかる場合","本件","本願","本出願","本明細書","これら","それら","各々","随時","適宜","任意","必ずしも","通常","一般に","典型的","代表的",
    "本発明","発明","実施例","実施形態","変形例","請求","請求項","図","図面","符号","符号の説明","図面の簡単な説明","発明の詳細な説明","技術分野","背景技術","従来技術","発明が解決しようとする課題","課題","解決手段","効果","要約","発明の効果","目的","手段","構成","構造","工程","処理","方法","手法","方式","システム","プログラム","記憶媒体","特徴","特徴とする","特徴部","ステップ","フロー","シーケンス","定義","関係","対応","整合", "実施の形態","実施の態様","態様","変形","修正例","図示","図示例","図示しない","参照","参照符号","段落","詳細説明","要旨","一実施形態","他の実施形態","一実施例","別の側面","付記","適用例","用語の定義","開示","本開示","開示内容",
    "上部","下部","内部","外部","内側","外側","表面","裏面","側面","上面","下面","端面","先端","基端","後端","一端","他端","中心","中央","周縁","周辺","近傍","方向","位置","空間","領域","範囲","間隔","距離","形状","形態","状態","種類","層","膜","部","部材","部位","部品","機構","装置","容器","組成","材料","用途","適用","適用例","片側","両側","左側","右側","前方","後方","上流","下流","隣接","近接","離間","間置","介在","重畳","概ね","略","略中央","固定側","可動側","伸長","収縮","係合","嵌合","取付","連結部","支持体","支持部","ガイド部",
Player 
    "データ","情報","信号","出力","入力","制御","演算","取得","送信","受信","表示","通知","設定","変更","更新","保存","削除","追加","実行","開始","終了","継続","停止","判定","判断","決定","選択","特定","抽出","検出","検知","測定","計測","移動","回転","変位","変形","固定","配置","生成","付与","供給","適用","照合","比較","算出","解析","同定","初期化","読出","書込","登録","記録","配信","連携","切替","起動","復帰","監視","通知処理","取得処理","演算処理",
    "良好","容易","簡便","適切","有利","有用","有効","効果的","高い","低い","大きい","小さい","新規","改良","改善","抑制","向上","低減","削減","増加","減少","可能","好適","好ましい","望ましい","優れる","優れた","高性能","高効率","低コスト","コスト","簡易","安定","安定性","耐久","耐久性","信頼性","簡素","簡略","単純","最適","最適化","汎用","汎用性","実現","達成","確保","維持","防止","回避","促進","不要","必要","高精度","省電力","省資源","高信頼","低負荷","高純度","高密度","高感度","迅速","円滑","簡略化","低価格","実効的","可能化","有効化","非必須","適合","互換",
    "出願","出願人","出願番号","出願日","出願書","出願公開","公開","公開番号","公開公報","公報","公報番号","特許","特許番号","特許文献","非特許文献","引用","引用文献","先行技術","審査","審査官","拒絶","意見書","補正書","優先","優先日","分割出願","継続出願","国内移行","国際出願","国際公開","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","ファイル","データ構造","テーブル","レコード",
    "軸","シャフト","ギア","モータ","エンジン","アクチュエータ","センサ","バルブ","ポンプ","筐体","ハウジング","フレーム","シャーシ","駆動","伝達","支持","連結"
}

def extract_ipc(text, delimiter=';'):
    if not isinstance(text, str):
        return [] 
    text = unicodedata.normalize('NFKC', text).lower()
    text = re.sub(r'[\((][^)]*[\))]', ' ', text)
    ipc_codes = []
    parts = text.split(delimiter)
    for part in parts:
        part = part.strip()
        if not part:
            continue
        match = re.search(r'([a-z]\d{2}[a-z])\s*(\d{1,4}/\d{2,})', part)
        if match:
            ipc_code = match.group(1) + match.group(2)
            ipc_codes.append(ipc_code)
        else:
            match_main = re.search(r'\b([a-z]\d{2}[a-z])\b', part)
            if match_main:
                ipc_codes.append(match_main.group(1))
    return 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_words_from_dense_vector(dense_vector, feature_names, top_n=5):
    """[内部] 密ベクトルからトップN単語を抽出する"""
    indices = np.argsort(dense_vector)[::-1]
    top_words = [feature_names[i] for i in indices[:top_n]]
    return ", ".join(top_words)

# ==================================================================
# --- 3. MEGA アプリケーションクラスの定義 ---
# ==================================================================

class MEGAApp:
    def __init__(self):
        """MEGAツールのUIウィジェットを初期化し、レイアウトを構築します。"""
        
        self.df_main = pd.DataFrame()
        self.sbert_model = None
        self.sbert_embeddings = None
        self.tfidf_matrix = None
        self.feature_names = None
        self.col_map = {}
        
        self.df_momentum_result = pd.DataFrame() 
        self.mega_group_map_auto = {} 
        self.mega_group_map_custom = {} 
        self.fig_mega = None                 
        self.axis_label = ""                 
        self.past_offset = 5                 
        self.highlight_targets = []
        self.cagr_start_year_min = 2000
        self.cagr_end_year_val = 2023
        self.base_color_map = { 
            'リーダー (Leaders)': '#28a745',
            '新興・高ポテンシャル (Emerging)': '#ffc107',
            '成熟・既存勢力 (Established)': '#007bff',
            '衰退・ニッチ (Declining/Niche)': '#6c757d',
            'N/A': '#ced4da'
        }
        
        self.df_drilldown = pd.DataFrame()
        self.fig_sbert_map = None
        self.sbert_sub_cluster_map_auto = {} 
        self.sbert_sub_cluster_map_custom = {} 
        
        self.sbert_bin_interval_w = widgets.Dropdown(description="期間の粒度:", options=[1, 2, 3, 5], value=3, style={'description_width': 'initial'}, layout=widgets.Layout(width='49%'))
        self.sbert_date_bin_filter_w = widgets.Dropdown(description="表示期間 (フィルタ):", options=['(全期間)'], value='(全期間)', style={'description_width': 'initial'}, layout=widgets.Layout(width='49%'))

        self.drill_n_neighbors_w = widgets.IntText(value=15, description='UMAP 近傍点 (n_neighbors):', style={'description_width': 'initial'}, layout=widgets.Layout(width='49%'))
        self.drill_min_dist_w = widgets.FloatText(value=0.1, description='UMAP 最小距離 (min_dist):', style={'description_width': 'initial'}, layout=widgets.Layout(width='49%'))
        self.drill_min_cluster_size_w = widgets.IntText(value=5, description='HDBSCAN 最小クラスタサイズ:', style={'description_width': 'initial'}, layout=widgets.Layout(width='49%'))
        self.drill_min_samples_w = widgets.IntText(value=5, description='HDBSCAN 最小サンプル数:', style={'description_width': 'initial'}, layout=widgets.Layout(width='49%'))

        self.style = {'description_width': 'initial'}
        self.layout_col = Layout(width='32%', min_width='300px', margin='0 5px 0 5px')
        self.layout_full = Layout(width='98%')
        self.layout_btn = Layout(width='98%', height='40px')
        self.layout_btn_half = Layout(width='49%', height='40px')
        
        self.upload_btn = widgets.Button(
            description='1. 分析ファイルをアップロード', icon='upload', 
            button_style='primary', layout=self.layout_btn
        )
        self.upload_output = Output()
        self.title_col_w = widgets.Dropdown(description="発明の名称:", options=[None], style=self.style)
        self.abstract_col_w = widgets.Dropdown(description="要約:", options=[None], style=self.style)
        self.claim_col_w = widgets.Dropdown(description="請求項:", options=[None], style=self.style)
        self.app_num_col_w = widgets.Dropdown(description="出願番号:", options=[None], style=self.style)
        self.date_col_w = widgets.Dropdown(description="出願日:", options=[None], style=self.style)
        self.applicant_col_w = widgets.Dropdown(description="出願人:", options=[None], style=self.style)
        self.applicant_delimiter_w = widgets.Text(description="出願人区切り文字:", value=';', style=self.style) 
        self.ipc_col_w = widgets.Dropdown(description="IPC:", options=[None], style=self.style)
        self.ipc_delimiter_w = widgets.Text(description="IPC区切り文字:", value=';', style=self.style)
        self.fterm_col_w = widgets.Dropdown(description="Fターム:", options=[None], style=self.style)
        self.fterm_delimiter_w = widgets.Text(description="Fターム区切り文字:", value=';', style=self.style)
        self.preprocess_btn = widgets.Button(
            description='2. 全データのSBERTベクトル計算と前処理を実行', icon='gears', 
            button_style='info', layout=self.layout_btn, disabled=True
        )
        self.preprocess_output = Output()

        self.analysis_axis_w = widgets.Dropdown(
            description="分析軸:", options=[
                ('出願人', 'applicant_main'), 
                ('IPC (メイングループ)', 'ipc_main_group'), 
                ('Fターム (テーマコード)', 'fterm_main')
            ], 
            style=self.style
        )
        self.yaxis_slider_w = widgets.IntSlider(
            value=5, min=1, max=10, description='Y軸 (現在) の集計年数:', style=self.style
        )
        self.cagr_end_year_w = widgets.IntText(value=datetime.datetime.now().year - 1, description='X軸 (過去の勢い) 計算の最終年:', style=self.style)
        self.trajectory_past_w = widgets.IntText(value=5, description='軌跡 (過去時点):', style=self.style)
        
        self.min_patents_w = widgets.IntText(value=10, description='最小フィルタ件数 (AI分類・描画対象):', style=self.style)

        self.highlight_filter_w = widgets.SelectMultiple(
            description='注目対象 (複数選択可):',
            options=['(「3. 描画」ボタンを押すと更新されます)'],
            value=[],
            rows=5,
            style=self.style,
            layout=Layout(width='70%')
        )
        self.highlight_clear_btn = widgets.Button(
            description='ハイライトをクリア', 
            icon='times-circle', 
            button_style='warning',
            layout=Layout(width='28%', margin='25px 0 0 5px')
        )
        
        self.run_mega_map_btn = widgets.Button(
            description='3. 動態分析マップを描画', icon='line-chart',
            button_style='success', layout=self.layout_btn, disabled=True
        )
        self.mega_map_output = Output()
        self.mega_label_editor_box = VBox([widgets.Label("動態分析マップ描画後にここにUIが表示されます。")])
        self.update_mega_labels_btn = widgets.Button(
            description='4. ラベルを更新', icon='pencil', layout=self.layout_btn_half, disabled=True
        )
        
        self.drilldown_target_w = widgets.Dropdown(
            description="ドリルダウン対象:", options=['(動態分析マップを描画してください)'], style=self.style
        )
        
        self.sbert_params_box = VBox([
            HBox([self.drill_n_neighbors_w, self.drill_min_dist_w]),
            HBox([self.drill_min_cluster_size_w, self.drill_min_samples_w])
        ])
        
        self.run_sbert_map_btn = widgets.Button(
            description='5. 選択対象の技術マップを描画 (SBERT/UMAP)', icon='sitemap', 
            button_style='info', 
            layout=self.layout_btn, disabled=True
        )
        self.sbert_map_output = Output()
        
        self.sbert_label_editor_box = VBox([widgets.Label("技術マップ描画後にここにUIが表示されます。")])
        
        self.sbert_filter_box = HBox([self.sbert_bin_interval_w, self.sbert_date_bin_filter_w])
        
        self.update_sbert_view_btn = widgets.Button(
            description='6. ラベル・期間フィルタを更新', icon='sync', 
            button_style='success',
            layout=self.layout_btn_half, disabled=True
        )
        
        self.export_mega_btn = widgets.Button(
            description='7. 動態分析マップ結果をエクスポート', icon='download', layout=self.layout_btn_half
        )
        self.export_drilldown_btn = widgets.Button(
            description='8. ドリルダウン結果をエクスポート', icon='download', layout=self.layout_btn_half
        )
        self.export_output = Output()

        self.ui = VBox([
            widgets.HTML("<h1>MEGA: Matrix for Emerging & Growth Assessment</h1>"),
            
            widgets.HTML("<hr style='border-top: 2px solid #007bff;'><h3>A. 分析設定</h3>"),
            widgets.HTML("<h4>A-1. ファイルアップロード</h4>"),
            self.upload_btn,
            self.upload_output,
            widgets.HTML("<h4>A-2. カラム紐付け (必須)</h4>"),
            HBox([
                VBox([self.title_col_w, self.abstract_col_w, self.claim_col_w], layout=self.layout_col),
                VBox([self.app_num_col_w, self.date_col_w, self.applicant_col_w, self.applicant_delimiter_w], layout=self.layout_col),
                VBox([self.ipc_col_w, self.ipc_delimiter_w, self.fterm_col_w, self.fterm_delimiter_w], layout=self.layout_col)
            ], layout=self.layout_full),
            widgets.HTML("<h4>A-3. 前処理実行</h4>"),
            self.preprocess_btn,
            self.preprocess_output,

            widgets.HTML("<hr style='border-top: 2px solid #28a745;'><h3>B. 動態分析</h3>"),
            widgets.HTML("<h4>B-1. 分析パラメータ</h4>"),
            VBox([
                self.analysis_axis_w, self.yaxis_slider_w, self.cagr_end_year_w, 
                self.trajectory_past_w,
                self.min_patents_w
            ], layout=self.layout_full),
            widgets.HTML("<h4>B-2. ハイライトと軌跡</h4>"),
            HBox([self.highlight_filter_w, self.highlight_clear_btn], layout=self.layout_full), 
            widgets.HTML("<h4>B-3. 動態分析マップ実行</h4>"),
            self.run_mega_map_btn,
            self.mega_map_output,
            widgets.HTML("<h4>B-4. ラベル編集</h4>"),
            self.mega_label_editor_box,
            self.update_mega_labels_btn,

            widgets.HTML("<hr style='border-top: 2px solid #ffc107;'><h3>C. ドリルダウン分析</h3>"),
            widgets.HTML("<h4>C-1. 分析対象の選択</h4>"),
            self.drilldown_target_w,
            
            widgets.HTML("<h4>C-2. マップパラメータ (UMAP / HDBSCAN)</h4>"), 
            self.sbert_params_box, 
            
            widgets.HTML("<h4>C-3. 技術ポートフォリオ分析 (SBERT/UMAP)</h4>"),
            self.run_sbert_map_btn,
            self.sbert_map_output,
            
            widgets.HTML("<h4>C-4. サブクラスタ・ラベル編集</h4>"),
            self.sbert_label_editor_box,
            
            widgets.HTML("<h4>C-5. 期間フィルタ</h4>"),
            self.sbert_filter_box, 
            
            widgets.HTML("<h4>C-6. マップ更新</h4>"),
            self.update_sbert_view_btn,

            widgets.HTML("<hr style='border-top: 2px solid #6c757d;'><h3>D. エクスポート</h3>"),
            HBox([self.export_mega_btn, self.export_drilldown_btn]),
            self.export_output
        ], layout=Layout(width='1000px')) 

        self.upload_btn.on_click(self.on_upload_clicked)
        self.preprocess_btn.on_click(self.on_preprocess_clicked)
        self.run_mega_map_btn.on_click(self.on_run_mega_map_clicked)
        self.update_mega_labels_btn.on_click(self.on_update_mega_labels_clicked)
        self.highlight_clear_btn.on_click(self.on_highlight_clear_clicked) 
        
        self.run_sbert_map_btn.on_click(self.on_run_sbert_map_clicked)
        self.update_sbert_view_btn.on_click(self.on_update_sbert_view_clicked)
        self.sbert_bin_interval_w.observe(self.on_sbert_interval_changed, names='value') 
        
        self.export_mega_btn.on_click(self.on_export_mega_clicked)
        self.export_drilldown_btn.on_click(self.on_export_drilldown_clicked)

    # ==================================================================
    # --- 4. コールバック関数 (ロジック実装) ---
    # ==================================================================
    
    def on_upload_clicked(self, b):
        """A-1. ファイルアップロード ボタンの処理"""
        self.upload_btn.disabled = True
        self.upload_btn.description = '1. アップロード待機中...'
        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. 分析ファイルをアップロード'
                    return
                
                file_name = next(iter(uploaded))
                content = uploaded[file_name]
                print(f"🔄 '{file_name}' を読み込み中...")
                
                if file_name.lower().endswith('.csv'):
                    try:
                        self.df_main = pd.read_csv(io.BytesIO(content), dtype=str)
                    except UnicodeDecodeError:
                        self.df_main = pd.read_csv(io.BytesIO(content), dtype=str, encoding='shift_jis')
                elif file_name.lower().endswith(('.xlsx', '.xls')):
                    self.df_main = pd.read_excel(io.BytesIO(content), dtype=str)
                else:
                    print(f"🚨 サポートされていないファイル形式です: {file_name}")
                    self.upload_btn.disabled = False
                    self.upload_btn.description = '1. 分析ファイルをアップロード'
                    return

                print(f"✅ ファイル読み込み完了 ({len(self.df_main)}行)。カラムを紐付けてください。")
                
                columns_with_none = [None] + list(self.df_main.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.app_num_col_w.options = columns_with_none
                self.date_col_w.options = columns_with_none
                self.applicant_col_w.options = columns_with_none
                self.ipc_col_w.options = columns_with_none
                self.fterm_col_w.options = columns_with_none
                
                self.preprocess_btn.disabled = False
                
            except Exception as e:
                clear_output(wait=True)
                print(f"🚨 ファイル読み込みエラー: {e}")
            
            self.upload_btn.disabled = False
            self.upload_btn.description = '1. 分析ファイルをアップロード'

    def on_preprocess_clicked(self, b):
        """A-3. 前処理実行 ボタンの処理 (SBERT + TF-IDF + 正規化)"""
        self.preprocess_btn.disabled = True
        self.preprocess_btn.description = '2. 処理中... (SBERTモデルのロードと計算に時間がかかります)'
        
        with self.preprocess_output:
            clear_output(wait=True)
            try:
                print("⚙️ 1/7: カラム設定をチェック中...")
                self.col_map = {
                    'title': self.title_col_w.value,
                    'abstract': self.abstract_col_w.value,
                    'claim': self.claim_col_w.value,
                    'app_num': self.app_num_col_w.value,
                    'date': self.date_col_w.value,
                    'applicant': self.applicant_col_w.value,
                    'ipc': self.ipc_col_w.value,
                    'fterm': self.fterm_col_w.value
                }
                
                if any(v is None for v in self.col_map.values()):
                    missing = [k for k, v in self.col_map.items() if v is None]
                    print(f"🚨 エラー: 必須カラムが選択されていません: {missing}")
                    self.preprocess_btn.disabled = False
                    self.preprocess_btn.description = '2. 前処理を実行'
                    return
                
                print("⚙️ 2/7: SBERTモデルをロード中... (初回はダウンロードが発生します)")
                self.sbert_model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
                print("... SBERTモデル ロード完了")

                print("⚙️ 3/7: SBERT用のテキストデータを結合中...")
                self.df_main['text_for_sbert'] = (
                    self.df_main[self.col_map['title']].fillna('') + ' ' +
                    self.df_main[self.col_map['abstract']].fillna('') + ' ' +
                    self.df_main[self.col_map['claim']].fillna('')
                )

                print("⚙️ 4/7: SBERTベクトル計算を実行中... (データ量に応じて数分かかります)")
                texts_for_sbert_list = self.df_main['text_for_sbert'].tolist()
                self.sbert_embeddings = self.sbert_model.encode(
                    texts_for_sbert_list,
                    show_progress_bar=True,
                    batch_size=128
                )
                print("... SBERTベクトル計算 完了")

                print("⚙️ 5/7: TF-IDF (自動ラベリング用) を計算中...")
                self.df_main['text_for_tfidf'] = self.df_main['text_for_sbert'].apply(advanced_tokenize)
                
                vectorizer = TfidfVectorizer(max_features=None, min_df=5, max_df=0.80)
                self.tfidf_matrix = vectorizer.fit_transform(self.df_main['text_for_tfidf'])
                self.feature_names = np.array(vectorizer.get_feature_names_out())
                print("... TF-IDF計算 完了")

                print("⚙️ 6/7: 日付と分析軸(IPC/Fターム/出願人)を正規化中...")
                
                self.df_main['parsed_date'] = pd.to_datetime(self.df_main[self.col_map['date']], errors='coerce')
                self.df_main['year'] = self.df_main['parsed_date'].dt.year
                self.df_main['app_num_main'] = self.df_main[self.col_map['app_num']].astype(str).str.strip()

                ipc_delimiter = self.ipc_delimiter_w.value
                self.df_main['ipc_normalized'] = self.df_main[self.col_map['ipc']].apply(
                    lambda x: extract_ipc(x, ipc_delimiter)
                )
                
                ipc_raw_list = self.df_main[self.col_map['ipc']].fillna('').astype(str).str.split(ipc_delimiter)
                self.df_main['ipc_main_group'] = ipc_raw_list.apply(
                    lambda terms: [t.strip().split('/')[0].strip() for t in terms]
                )

                fterm_delimiter = self.fterm_delimiter_w.value
                self.df_main['fterm_raw_split'] = self.df_main[self.col_map['fterm']].fillna('').astype(str).str.split(fterm_delimiter)
                self.df_main['fterm_main'] = self.df_main['fterm_raw_split'].apply(
                    lambda terms: [t.strip()[:5] for t in terms]
                )

                applicant_delimiter = self.applicant_delimiter_w.value 
                self.df_main['applicant_raw_split'] = self.df_main[self.col_map['applicant']].fillna('').astype(str).str.split(applicant_delimiter)
                self.df_main['applicant_main'] = self.df_main['applicant_raw_split'].apply(
                    lambda names: [n.strip() for n in names]
                )
                
                print("⚙️ 7/7: 中間データをクリーンアップ中...")
                self.df_main = self.df_main.drop(columns=['text_for_sbert', 'text_for_tfidf', 'fterm_raw_split', 'applicant_raw_split'], errors='ignore')
                
                print("✅ 全ての前処理が完了しました!")
                print("▶ 「B. 動態分析」セクションに進み、「3. 動態分析マップを描画」ボタンを押してください。")
                
                self.run_mega_map_btn.disabled = False
                self.run_sbert_map_btn.disabled = False 

            except Exception as e:
                clear_output(wait=True)
                print(f"🚨 前処理中にエラーが発生しました: {e}")
                import traceback
                traceback.print_exc()
            
            self.preprocess_btn.disabled = False
            self.preprocess_btn.description = '2. 全データのSBERTベクトル計算と前処理を実行'

    # ==========================================================
    # --- B. 動態分析 ---
    # ==========================================================

    def _calculate_cagr(self, row, cagr_end_year_val):
        """
        [内部] CAGRを計算するヘルパー関数 (★ロジック変更済 v2)
        X軸(勢い)用。指定されたcagr_end_year_val「以前」で、
        活動(>0件)があった「最初」の年と「最後」の年を自動検出し、その2点間でCAGRを計算する。
        """
        valid_years = row[row > 0].index
        if not any(valid_years): 
            return np.nan 

        valid_years_in_range = valid_years[valid_years <= cagr_end_year_val]
        if not any(valid_years_in_range):
            return np.nan 

        start_year = min(valid_years_in_range)
        end_year = max(valid_years_in_range)
        
        if start_year >= end_year:
            return np.nan 

        start_value = row[start_year]
        end_value = row[end_year]
        
        num_years = end_year - start_year
        if num_years <= 0:
             return np.nan
        
        return ((end_value / start_value) ** (1 / num_years)) - 1

    def _calculate_metrics(self, pivot_df, cagr_end_year, y_axis_years, current_year, past_offset=0):
        """[内部] 指定された時点のX,Y,Bubble指標を計算する"""
        
        target_cagr_end = cagr_end_year - past_offset
        target_current_year = current_year - past_offset
        
        y_start = target_current_year - y_axis_years + 1
        y_cols = [col for col in pivot_df.columns if col >= y_start and col <= target_current_year]
        y_axis = pivot_df[y_cols].sum(axis=1) if y_cols else pd.Series(0, index=pivot_df.index)

        if past_offset == 0:
            bubble_size = pivot_df.sum(axis=1)
        else:
            bubble_cols = [col for col in pivot_df.columns if col <= target_current_year]
            bubble_size = pivot_df[bubble_cols].sum(axis=1) if bubble_cols else pd.Series(0, index=pivot_df.index)

        cagr_cols = [col for col in pivot_df.columns if col <= target_cagr_end]
        if not cagr_cols:
            x_axis = pd.Series(np.nan, index=pivot_df.index)
        else:
            x_axis = pivot_df[cagr_cols].apply(
                self._calculate_cagr, 
                axis=1, 
                cagr_end_year_val=target_cagr_end 
            )
        
        return x_axis, y_axis, bubble_size

    def _prepare_momentum_data(self, axis_col):
        """[内部] 分析軸に応じてデータを展開・重複排除する"""
        
        df = self.df_main[['app_num_main', 'year', axis_col]].copy()
        df.dropna(subset=['app_num_main', 'year', axis_col], inplace=True)
        
        df_exploded = df.explode(axis_col)
        df_exploded[axis_col] = df_exploded[axis_col].str.strip()
        df_exploded.dropna(subset=[axis_col], inplace=True)
        df_exploded = df_exploded[df_exploded[axis_col] != '']
        
        df_unique = df_exploded.drop_duplicates(subset=['app_num_main', axis_col], keep='first')
        
        pivot_df = pd.pivot_table(df_unique, index=axis_col, columns='year', aggfunc='size', fill_value=0)
        
        return pivot_df

    def on_run_mega_map_clicked(self, b):
        """B-3. 動態分析マップを描画 ボタンの処理 (計算)"""
        self.run_mega_map_btn.disabled = True
        self.run_mega_map_btn.description = '3. 描画中...'
        
        with self.mega_map_output:
            clear_output(wait=True)
            print("⚙️ 1/7: パラメータを読み込み中...")
            try:
                axis_col = self.analysis_axis_w.value 
                options_map = {v: k for k, v in self.analysis_axis_w.options} 
                self.axis_label = options_map.get(axis_col)
                
                cagr_end_year = int(self.cagr_end_year_w.value)
                y_axis_years = int(self.yaxis_slider_w.value)
                self.past_offset = int(self.trajectory_past_w.value)
                current_year = datetime.datetime.now().year
                min_patents_threshold = int(self.min_patents_w.value)

                print(f"⚙️ 2/7: 分析軸 ({self.axis_label}) のデータを集計中...")
                pivot_df = self._prepare_momentum_data(axis_col) 
                if pivot_df.empty:
                    print(f"🚨 エラー: 分析軸 ({self.axis_label}) の有効なデータがありません。")
                    self.run_mega_map_btn.disabled = False
                    self.run_mega_map_btn.description = '3. 動態分析マップを描画'
                    return
                        
                print(f"⚙️ 4/7: 「現在」と「過去」の動態指標を計算中...")
                x_present, y_present, bubble_present = self._calculate_metrics(
                    pivot_df, cagr_end_year, y_axis_years, current_year, past_offset=0
                )
                x_past, y_past, bubble_past = self._calculate_metrics(
                    pivot_df, cagr_end_year, y_axis_years, current_year, past_offset=self.past_offset
                )

                print("⚙️ (B-2): 注目対象フィルタを件数付きで更新中...")
                options_with_counts = [
                    (f"{name} ({int(count)}件)", name) 
                    for name, count in bubble_present.sort_index().items()
                ]
                
                current_selection = self.highlight_filter_w.value
                self.highlight_filter_w.options = options_with_counts
                valid_selection = [s for s in current_selection if s in bubble_present.index]
                self.highlight_filter_w.value = valid_selection
                self.highlight_targets = list(self.highlight_filter_w.value)

                start_years = pivot_df[pivot_df > 0].apply(lambda row: row.first_valid_index(), axis=1)
                self.cagr_start_year_min = start_years.min() if not start_years.empty else cagr_end_year
                self.cagr_end_year_val = cagr_end_year
                if pd.isna(self.cagr_start_year_min):
                    self.cagr_start_year_min = cagr_end_year

                df_result = pd.DataFrame({
                    'X_Present': x_present, 'Y_Present': y_present, 'Bubble_Present': bubble_present,
                    'X_Past': x_past, 'Y_Past': y_past, 'Bubble_Past': bubble_past
                })
                
                df_result = df_result.astype({
                    'X_Present': 'float', 'Y_Present': 'float', 'Bubble_Present': 'float',
                    'X_Past': 'float', 'Y_Past': 'float', 'Bubble_Past': 'float'
                })
                
                df_result.index.name = self.axis_label
                df_result.replace([np.inf, -np.inf], np.nan, inplace=True)
                df_result.dropna(subset=['X_Present', 'Y_Present'], inplace=True)
                
                df_result = df_result[df_result['Bubble_Present'] >= min_patents_threshold].copy()
                
                if df_result.empty:
                    print(f"🚨 エラー: フィルタリング(最小{min_patents_threshold}件)後の分析結果が0件です。")
                    self.run_mega_map_btn.disabled = False
                    self.run_mega_map_btn.description = '3. 動態分析マップを描画'
                    return
                        
                print("⚙️ 6/7: 4象限分類 (Y軸優先 相対評価) を実行中...")
                
                x_threshold = df_result['X_Present'].mean() if not df_result.empty else 0
                y_threshold = df_result['Y_Present'].mean() if not df_result.empty else 0

                # ★★★ Y軸優先ロジック(v3) ★★★
                def assign_relative_label(row):
                    if row['Y_Present'] <= 0:
                        return '衰退・ニッチ (Declining/Niche)'
                    
                    if (row['X_Present'] > x_threshold) and (row['Y_Present'] > y_threshold):
                        return 'リーダー (Leaders)'
                    elif (row['X_Present'] > x_threshold) and (row['Y_Present'] <= y_threshold):
                        return '新興・高ポテンシャル (Emerging)'
                    elif (row['X_Present'] <= x_threshold) and (row['Y_Present'] > y_threshold):
                        return '成熟・既存勢力 (Established)'
                    else: 
                        return '衰退・ニッチ (Declining/Niche)'

                df_result['Group_Auto'] = df_result.apply(assign_relative_label, axis=1)

                self.mega_group_map_auto = {
                    'リーダー (Leaders)': 'リーダー (Leaders)',
                    '新興・高ポテンシャル (Emerging)': '新興・高ポテンシャル (Emerging)',
                    '成熟・既存勢力 (Established)': '成熟・既存勢力 (Established)',
                    '衰退・ニッチ (Declining/Niche)': '衰退・ニッチ (Declining/Niche)'
                }
                
                print("⚙️ 7/7: グラフ描画とUI更新を実行中...")
                self.df_momentum_result = df_result.copy()
                self.mega_group_map_custom = {} 
                
                self._update_mega_label_editor() 
                self._update_drilldown_dropdown() 
                self._redraw_mega_map() 

                self.update_mega_labels_btn.disabled = False
                print("✅ 動態分析マップの描画が完了しました。") 

            except Exception as e:
                clear_output(wait=True)
                print(f"🚨 動態分析マップ分析中にエラーが発生しました: {e}") 
                import traceback
                traceback.print_exc()
            
            self.run_mega_map_btn.disabled = False
            self.run_mega_map_btn.description = '3. 動態分析マップを描画'
    
    def on_highlight_clear_clicked(self, b):
        """B-2. ハイライトをクリア ボタンの処理"""
        self.highlight_filter_w.value = []
        with self.mega_map_output:
            clear_output(wait=True)
            print("💡 ハイライトの選択をクリアしました。")
            print("▶ 「3. 動態分析マップを描画」ボタンを押して、全体マップを再描画してください。") 

    def _update_mega_label_editor(self):
        """[内部] B-4 ラベル編集UIを構築する"""
        auto_labels = sorted(list(set(self.mega_group_map_auto.values())))
        self.mega_label_editor_box.children = [
            widgets.Text(
                description=f"[{label}]", 
                value=self.mega_group_map_custom.get(label, label), 
                layout=self.layout_full
            )
            for label in auto_labels 
        ]

    def _update_drilldown_dropdown(self):
        """[内部] C-1 ドリルダウン対象ドロップダウンを更新する"""
        
        df_filtered = self.df_momentum_result.copy()
        
        df_filtered = df_filtered[df_filtered['Group_Auto'] != 'N/A']
        
        options_with_counts = [
            (f"{name} ({int(row['Bubble_Present'])}件)", name) 
            for name, row in df_filtered.sort_index().iterrows()
        ]
        
        drilldown_options = [('(分析対象を選択)', '(分析対象を選択)')] + options_with_counts
        
        self.drilldown_target_w.options = drilldown_options
        self.drilldown_target_w.value = '(分析対象を選択)'
        
    def _get_hover_template_mode1(self, is_past=False):
        """[内部] Plotly用のホバーテンプレートを生成する (ハイライトモード - go.Scatter用)"""
        if is_past:
            return f"""<b>%{{customdata[2]}}</b> (過去)<br>
<br>
X (勢い): %{{x:.1%}}<br>
Y (活動量): %{{customdata[1]:,.0f}}<br>
Bubble (総件数): %{{customdata[0]:,}}<br>
<extra></extra>"""
        else:
            return f"""<b>%{{customdata[5]}}</b> (現在)<br>
<br>
戦略グループ: %{{customdata[0]}}<br>
X (勢い): %{{x:.1%}}<br>
Y (活動量): %{{customdata[4]:,.0f}}<br>
Bubble (総件数): %{{customdata[1]:,}}<br>
<br>---<br>
過去 X ({self.past_offset}年前): %{{customdata[2]:.1%}}<br>
過去 Y ({self.past_offset}年前): %{{customdata[3]:,.0f}}<br>
<extra></extra>"""
            
    def _get_hover_template_mode2(self, is_past=False):
        """[内部] Plotly用のホバーテンプレートを生成する (全体描画モード - px.scatter用)"""
        if is_past:
            return ""
        else:
            return f"""<b>%{{hovertext}}</b> (現在)<br>
<br>
戦略グループ: %{{customdata[0]}}<br>
X (勢い): %{{x:.1%}}<br>
Y (活動量): %{{customdata[4]:,.0f}}<br>
Bubble (総件数): %{{customdata[1]:,}}<br>
<br>---<br>
過去 X ({self.past_offset}年前): %{{customdata[2]:.1%}}<br>
過去 Y ({self.past_offset}年前): %{{customdata[3]:,.0f}}<br>
<extra></extra>"""

    def _redraw_mega_map(self):
        """[内部] B-3 / B-4 から呼び出される共通の描画関数"""
        
        df_to_plot = self.df_momentum_result.copy()
        
        self.mega_group_map_custom = {
            widget.description.strip('[]'): widget.value 
            for widget in self.mega_label_editor_box.children
        }
        self.mega_group_map_custom['N/A'] = 'N/A' 
        
        df_to_plot['Group_Custom'] = df_to_plot['Group_Auto'].map(self.mega_group_map_custom).fillna('N/A')
        
        current_color_map = {
            self.mega_group_map_custom.get(auto_label, auto_label): color
            for auto_label, color in self.base_color_map.items()
        }
        
        title = f"MEGA 動態分析マップ - 分析軸: {self.axis_label}" 
        
        xaxis_title_label = f"過去の勢い (CAGR, {int(self.cagr_start_year_min)}-{int(self.cagr_end_year_val)}年内の活動期間)"
        
        x_threshold = df_to_plot['X_Present'].mean() if not df_to_plot.empty else 0
        y_threshold = df_to_plot['Y_Present'].mean() if not df_to_plot.empty else 0
        
        fig = go.Figure()

        if self.highlight_targets:
            df_highlighted = df_to_plot[df_to_plot.index.isin(self.highlight_targets)].copy()
            if df_highlighted.empty:
                with self.mega_map_output:
                    clear_output(wait=True)
                    print(f"🚨 注目対象「{self.highlight_targets}」が分析結果に見つかりません。")
                return

            title += f" (注目対象: {', '.join(self.highlight_targets)})"
            
            max_bubble_for_scaling = df_to_plot['Bubble_Present'].max()
            if max_bubble_for_scaling == 0: max_bubble_for_scaling = 1
            df_highlighted['Size_Present_Linear'] = (df_highlighted['Bubble_Present'] / max_bubble_for_scaling) * 60
            df_highlighted['Size_Past_Linear'] = (df_highlighted['Bubble_Past'] / max_bubble_for_scaling) * 60
            df_highlighted['Size_Present_Linear'] = df_highlighted['Size_Present_Linear'].clip(lower=5)
            df_highlighted['Size_Past_Linear'] = df_highlighted['Size_Past_Linear'].clip(lower=5)
            
            df_highlighted['Y_Present_Plot'] = df_highlighted['Y_Present'].replace(0, 0.1)
            df_highlighted['Y_Past_Plot'] = df_highlighted['Y_Past'].replace(0, 0.1)

            palette = px.colors.qualitative.Plotly
            
            present_year_label = int(self.cagr_end_year_val)
            past_year_label = int(self.cagr_end_year_val - self.past_offset)
            
            for i, (idx, row) in enumerate(df_highlighted.iterrows()):
                color = palette[i % len(palette)]
                
                if pd.notna(row['X_Past']) and pd.notna(row['Y_Past']):
                    fig.add_trace(go.Scatter(
                        x=[row['X_Past']], y=[row['Y_Past_Plot']],
                        mode='markers',
                        marker=dict(color=color, size=row['Size_Past_Linear'], opacity=0.3),
                        name=f"{idx} ({past_year_label}年)",
                        hovertext="", 
                        customdata=np.array([[
                            row['Bubble_Past'], 
                            row['Y_Past'],      
                            idx                 
                        ]]), 
                        hovertemplate=self._get_hover_template_mode1(is_past=True)
                    ))
                
                fig.add_trace(go.Scatter(
                    x=[row['X_Present']], y=[row['Y_Present_Plot']],
                    mode='markers',
                    marker=dict(color=color, size=row['Size_Present_Linear'], opacity=0.8),
                    name=f"{idx} ({present_year_label}年)",
                    hovertext="", 
                    customdata=np.array([[
                        row['Group_Custom'], 
                        row['Bubble_Present'], 
                        row['X_Past'],       
                        row['Y_Past'],       
                        row['Y_Present'],    
                        idx                  
                    ]]),
                    hovertemplate=self._get_hover_template_mode1(is_past=False)
                ))
                
                if pd.notna(row['X_Past']) and pd.notna(row['Y_Past']):
                    fig.add_trace(go.Scatter(
                        x=[row['X_Past'], row['X_Present']],
                        y=[row['Y_Past_Plot'], row['Y_Present_Plot']],
                        mode='lines',
                        line=dict(color=color, dash='dot', width=1),
                        hoverinfo='none',
                        showlegend=False
                    ))

        else:
            df_to_plot_filtered = df_to_plot[df_to_plot['Group_Custom'] != 'N/A'].copy()
            if df_to_plot_filtered.empty:
                with self.mega_map_output:
                    clear_output(wait=True)
                    print(f"🚨 グラフ描画対象のデータが0件です。")
                    print(f"   (最小フィルタ件数 {self.min_patents_w.value}件 を下回ったか、有効なデータがありませんでした)")
                return

            df_to_plot_filtered = df_to_plot_filtered.reset_index()
            
            df_to_plot_filtered['Y_Present_Plot'] = df_to_plot_filtered['Y_Present'].replace(0, 0.1)

            category_order_list = sorted(list(current_color_map.keys()))
            df_to_plot_filtered['Group_Custom'] = df_to_plot_filtered['Group_Custom'].astype('category')
            df_to_plot_filtered['Group_Custom'] = df_to_plot_filtered['Group_Custom'].cat.set_categories(
                [c for c in category_order_list if c in df_to_plot_filtered['Group_Custom'].unique()],
                ordered=True
            )
            
            custom_data_cols = [
                'Group_Custom',     
                'Bubble_Present',   
                'X_Past',           
                'Y_Past',           
                'Y_Present'         
            ]
            
            fig = px.scatter(
                df_to_plot_filtered, 
                x='X_Present', 
                y='Y_Present_Plot', 
                size='Bubble_Present', 
                size_max=60, 
                color='Group_Custom', 
                color_discrete_map=current_color_map,
                category_orders={'Group_Custom': category_order_list},
                hover_name=self.axis_label, 
                hover_data=custom_data_cols, 
                log_y=True,
            )
            
            fig.update_traces(
                hoverlabel=dict(bgcolor='white'),
                hovertemplate=self._get_hover_template_mode2(is_past=False)
            )

        fig.add_vline(x=x_threshold, line_width=1, line_dash="dash", line_color="gray")
        fig.add_hline(y=y_threshold, line_width=1, line_dash="dash", line_color="gray")
        
        fig.update_layout(
            title=title,
            xaxis_title=f"← 勢い減速 | {xaxis_title_label} | 勢い加速 → (十字線: {x_threshold:.1%})",
            yaxis_title=f"← 活動鈍化 | 現在の活動量 | 活動活発 →",
            xaxis_tickformat='.0%',
            yaxis_type="log", 
            height=800, width=980,
            legend_title_text='戦略グループ' if not self.highlight_targets else '注目対象'
        )
        
        self.fig_mega = go.FigureWidget(fig)
        with self.mega_map_output:
            clear_output(wait=True)
            display(self.fig_mega)

    def on_update_mega_labels_clicked(self, b):
        """B-4. ラベル更新 ボタンの処理"""
        self.update_mega_labels_btn.disabled = True
        with self.mega_map_output:
            print("🔄 (4) 動態分析マップのラベルを更新して再描画します...") 
            
        if self.df_momentum_result.empty:
            print("🚨 エラー: まず「3. 動態分析マップを描画」を実行してください。") 
            self.update_mega_labels_btn.disabled = False
            return
            
        if self.highlight_targets:
            with self.mega_map_output:
                clear_output(wait=True)
                print("⚠️ 注目対象のハイライト中は、グループラベルの編集はできません。")
                print("▶ 「注目対象」を空にしてから、再度「3. 描画」→「4. ラベル更新」を行ってください。")
            display(self.fig_mega) 
            self.update_mega_labels_btn.disabled = False
            return

        try:
            self._redraw_mega_map()
            self._update_mega_label_editor()
            
            print("✅ ラベルを更新して再描画しました。")
            
        except Exception as e:
            with self.mega_map_output:
                clear_output(wait=True)
                print(f"🚨 ラベル更新中にエラー: {e}")
                import traceback
                traceback.print_exc()
                
        self.update_mega_labels_btn.disabled = False


    # ==========================================================
    # --- C. ドリルダウン分析 ---
    # ==========================================================
    
    def _get_drilldown_data(self):
        """[内部] C-1 で選択されたターゲットのDFと元Indexを取得する"""
        target = self.drilldown_target_w.value
        axis_col = self.analysis_axis_w.value
        
        if target == '(分析対象を選択)' or not axis_col:
            return None, None
            
        target_indices_mask = self.df_main[axis_col].apply(lambda l: target in l)
        df_filtered = self.df_main[target_indices_mask].copy()
        
        original_indices = self.df_main[target_indices_mask].index.tolist()
        
        return df_filtered, original_indices

    def on_run_sbert_map_clicked(self, b):
        """C-3. SBERT/UMAP 技術マップ描画 ボタンの処理"""
        self.run_sbert_map_btn.disabled = True
        self.run_sbert_map_btn.description = '5. UMAP/HDBSCAN 計算中...' 
        
        with self.sbert_map_output:
            clear_output(wait=True)
            target = self.drilldown_target_w.value
            print(f"🔄 (5) 「{target}」の技術ポートフォリオ (SBERT/UMAP) を実行します...")
            
            try:
                df_filtered, original_indices = self._get_drilldown_data()
                
                if df_filtered is None:
                    print("🚨 エラー: 分析対象が選択されていません。")
                    self.run_sbert_map_btn.disabled = False
                    self.run_sbert_map_btn.description = '5. 選択対象の技術マップを描画 (SBERT/UMAP)'
                    return
                if df_filtered.empty:
                    print(f"🚨 エラー: 「{target}」に該当するデータがありません。")
                    self.run_sbert_map_btn.disabled = False
                    self.run_sbert_map_btn.description = '5. 選択対象の技術マップを描画 (SBERT/UMAP)'
                    return
                
                print(f"   ... {len(original_indices)}件のSBERTベクトルをスライス中 ...")
                target_embeddings = self.sbert_embeddings[original_indices]
                target_tfidf_matrix = self.tfidf_matrix[original_indices]
                
                n_neighbors_val = int(self.drill_n_neighbors_w.value)
                min_dist_val = float(self.drill_min_dist_w.value)
                
                n_neighbors = min(n_neighbors_val, len(original_indices) - 1)
                if n_neighbors <= 1: 
                    n_neighbors = 1
                    print(f"⚠️ データ件数が少なすぎるため、UMAPの n_neighbors=1 で実行します。")
                
                print(f"   ... UMAP (n_neighbors={n_neighbors}, min_dist={min_dist_val}) で2次元に削減中 ...")
                umap_results = UMAP(
                    n_components=2, 
                    n_neighbors=n_neighbors, 
                    min_dist=min_dist_val, 
                    random_state=42
                ).fit_transform(target_embeddings)
                
                df_plot = pd.DataFrame(umap_results, columns=['x', 'y'])
                
                min_cluster_size_val = int(self.drill_min_cluster_size_w.value)
                min_samples_val = int(self.drill_min_samples_w.value)

                print(f"   ... HDBSCAN (min_cluster_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'
                )
                cluster_labels = clusterer.fit_predict(df_plot[['x', 'y']])
                df_plot['cluster_id'] = cluster_labels
                
                df_plot[self.col_map['app_num']] = df_filtered[self.col_map['app_num']].values
                df_plot[self.col_map['title']] = df_filtered[self.col_map['title']].values
                df_plot[self.col_map['abstract']] = df_filtered[self.col_map['abstract']].values
                df_plot[self.col_map['claim']] = df_filtered[self.col_map['claim']].values
                df_plot['year'] = df_filtered['year'].values
                
                print(f"   ... TF-IDFでサブクラスタを自動ラベリング中 ...")
                self.sbert_sub_cluster_map_auto = {}
                for cluster_id in sorted(df_plot['cluster_id'].unique()):
                    if cluster_id == -1:
                        self.sbert_sub_cluster_map_auto[-1] = "ノイズ / 小クラスタ"
                        continue
                        
                    cluster_rows_mask = (df_plot['cluster_id'] == cluster_id)
                    cluster_plot_indices = df_plot[cluster_rows_mask].index.tolist()
                    
                    if not cluster_plot_indices:
                        continue
                        
                    cluster_tfidf_vectors = target_tfidf_matrix[cluster_plot_indices]
                    mean_vector = np.asarray(cluster_tfidf_vectors.mean(axis=0)).flatten()
                    top_words = _get_top_words_from_dense_vector(mean_vector, self.feature_names, 5)
                    
                    self.sbert_sub_cluster_map_auto[cluster_id] = f"Cluster {cluster_id}: {top_words}"
                
                df_plot['label'] = df_plot['cluster_id'].map(self.sbert_sub_cluster_map_auto)
                
                self.df_drilldown = df_plot.copy()
                self.sbert_sub_cluster_map_custom = {} 

                self._update_sbert_label_editor()
                self._update_sbert_filter_ui() 
                self._redraw_sbert_map()
                
                self.update_sbert_view_btn.disabled = False 
                print("✅ 技術マップの描画が完了しました。")

            except Exception as e:
                clear_output(wait=True)
                print(f"🚨 SBERT/UMAP分析中にエラーが発生しました: {e}")
                import traceback
                traceback.print_exc()

            self.run_sbert_map_btn.disabled = False
            self.run_sbert_map_btn.description = '5. 選択対象の技術マップを描画 (SBERT/UMAP)'
            
    def _update_sbert_label_editor(self):
        """[内部] C-4. サブクラスタ・ラベル編集UIを構築する"""
        self.sbert_sub_cluster_map_custom = {
            widget.description.strip('[]'): widget.value 
            for widget in self.sbert_label_editor_box.children
        }
        
        auto_labels_map = self.sbert_sub_cluster_map_auto
        
        self.sbert_label_editor_box.children = [
            widgets.Text(
                description=f"[{auto_label}]", 
                value=self.sbert_sub_cluster_map_custom.get(auto_label, auto_label), 
                layout=self.layout_full
            )
            for cluster_id, auto_label in auto_labels_map.items() if cluster_id != -1
        ]

    def _update_sbert_filter_ui(self):
        """[内部] C-5. 期間フィルタUIを動的に構築する (Saturn V方式)"""
        if self.df_drilldown.empty:
            self.sbert_date_bin_filter_w.options = ['(全期間)']
            self.sbert_date_bin_filter_w.value = '(全期間)'
            return

        try:
            min_date = self.df_drilldown['year'].min()
            max_date = self.df_drilldown['year'].max()
            
            if pd.isna(min_date) or pd.isna(max_date):
                self.df_drilldown['date_bin'] = "(期間情報なし)"
                self.sbert_date_bin_filter_w.options = ['(全期間)', "(期間情報なし)"]
                self.sbert_date_bin_filter_w.value = '(全期間)'
                return
                
            min_date = pd.to_datetime(f"{int(min_date)}-01-01")
            max_date = pd.to_datetime(f"{int(max_date)}-12-31")
            
            interval = int(self.sbert_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_dates = pd.to_datetime(self.df_drilldown['year'], format='%Y')
            self.df_drilldown['date_bin'] = pd.cut(df_dates, bins=bins, labels=labels, right=False)
            
            date_bin_counts = self.df_drilldown['date_bin'].value_counts()
            new_date_options = [f"(全期間) ({len(self.df_drilldown)}件)"]
            
            for label in labels:
                count = date_bin_counts.get(label, 0)
                if count > 0: 
                    new_date_options.append(f"{label} ({count}件)")
            
            self.sbert_date_bin_filter_w.options = new_date_options
            self.sbert_date_bin_filter_w.value = new_date_options[0] 

        except Exception as e:
            with self.sbert_map_output:
                print(f"🚨 期間フィルタの構築に失敗: {e}")
            self.df_drilldown['date_bin'] = None
            self.sbert_date_bin_filter_w.options = ['(全期間)']
            
    def on_sbert_interval_changed(self, change):
        """C-5. 期間粒度Dropdownが変更されたら、期間フィルタを更新する"""
        if self.df_drilldown.empty:
            return
            
        with self.sbert_map_output:
            clear_output(wait=True)
            print(f"🔄 期間粒度を「{change['new']}年」に変更し、期間フィルタを更新します...")
            
        try:
            self._update_sbert_filter_ui()
            self._redraw_sbert_map()
            
            with self.sbert_map_output:
                clear_output(wait=True)
                print("✅ 期間フィルタを更新しました。")
                display(self.fig_sbert_map)
                
        except Exception as e:
            with self.sbert_map_output:
                clear_output(wait=True)
                print(f"🚨 期間粒度の変更中にエラー: {e}")

    def _redraw_sbert_map(self):
        """[内部] C-3 / C-5 から呼び出されるSBERTマップ共通描画関数"""
        
        if self.df_drilldown.empty:
            print("🚨 描画対象のドリルダウンデータがありません。")
            return
            
        df_to_plot = self.df_drilldown.copy()
        target = self.drilldown_target_w.value
        
        self.sbert_sub_cluster_map_custom = {
            widget.description.strip('[]'): widget.value 
            for widget in self.sbert_label_editor_box.children
        }
        if -1 in self.sbert_sub_cluster_map_auto: 
            noise_label = self.sbert_sub_cluster_map_auto[-1]
            self.sbert_sub_cluster_map_custom[noise_label] = noise_label

        auto_to_custom_map = {
            auto_label: self.sbert_sub_cluster_map_custom.get(auto_label, auto_label)
            for auto_label in self.sbert_sub_cluster_map_auto.values()
        }
        df_to_plot['label_custom'] = df_to_plot['label'].map(auto_to_custom_map).fillna('N/A')
        
        selected_date_bin_raw = self.sbert_date_bin_filter_w.value
        
        title_suffix = ""
        if selected_date_bin_raw.startswith('(全期間)'):
            mask_in_range = pd.Series(True, index=df_to_plot.index)
        else:
            date_bin_label_for_filter = selected_date_bin_raw.split(' (')[0].strip()
            mask_in_range = (df_to_plot['date_bin'].astype(str) == date_bin_label_for_filter)
            title_suffix = f" ({date_bin_label_for_filter})" 
        
        df_in_range = df_to_plot[mask_in_range]
        df_out_of_range = df_to_plot[~mask_in_range]

        fig = go.Figure()
        
        palette = px.colors.qualitative.Plotly
        all_unique_labels = sorted(df_to_plot['label_custom'].unique())
        color_map = {label: palette[i % len(palette)] for i, label in enumerate(all_unique_labels)}
        
        noise_label = self.sbert_sub_cluster_map_auto.get(-1) 
        if noise_label and noise_label in color_map:
            color_map[noise_label] = '#ced4da' 
        
        fig.add_trace(go.Scatter(
            x=df_out_of_range['x'], 
            y=df_out_of_range['y'],
            mode='markers',
            marker=dict(color='#ced4da', opacity=0.2, size=5),
            name=f'期間外',
            showlegend=True, 
            customdata=df_out_of_range[[self.col_map['title'], 'label_custom', self.col_map['app_num'], 'year']],
            hovertemplate=(
                f"<b>出願番号: %{{customdata[2]}} (%{{customdata[3]}}年)</b><br>"
                f"名称: %{{customdata[0]}}<br>"
                f"クラスタ: %{{customdata[1]}} (期間外)<extra></extra>"
            )
        ))
        
        in_range_labels = df_in_range['label_custom'].unique()
        
        for label in all_unique_labels:
            color = color_map[label]
            
            if label in in_range_labels:
                df_cluster = df_in_range[df_in_range['label_custom'] == label]
                fig.add_trace(go.Scatter(
                    x=df_cluster['x'], 
                    y=df_cluster['y'],
                    mode='markers',
                    marker=dict(color=color, size=3 if label == noise_label else 7),
                    name=label, 
                    customdata=df_cluster[[self.col_map['title'], 'label_custom', self.col_map['app_num'], 'year']],
                    hovertemplate=(
                        f"<b>出願番号: %{{customdata[2]}} (%{{customdata[3]}}年)</b><br>"
                        f"名称: %{{customdata[0]}}<br>"
                        f"クラスタ: %{{customdata[1]}}<extra></extra>"
                    )
                ))
            else:
                fig.add_trace(go.Scatter(
                    x=[None], y=[None], 
                    mode='markers',
                    marker=dict(color=color),
                    name=f"{label} (期間外)", 
                    showlegend=True
                ))

        fig.update_layout(
            title=f"技術ポートフォリオマップ: {target} (SBERT/UMAP){title_suffix}",
            width=800, 
            height=900, 
            legend_title_text='サブクラスタ',
            legend=dict(
                orientation="h", 
                yanchor="top", 
                y=-0.1, 
                xanchor="center", 
                x=0.5
            ),
            plot_bgcolor='white', 
            paper_bgcolor='white',
            xaxis=dict(
                showticklabels=False, 
                showgrid=False, 
                zeroline=False,
                showline=True, linewidth=1, linecolor='black', mirror=True 
            ),
            yaxis=dict(
                showticklabels=False, 
                showgrid=False, 
                zeroline=False,
                showline=True, linewidth=1, linecolor='black', mirror=True, 
                scaleanchor="x", 
                scaleratio=1     
            )
        )
        
        self.fig_sbert_map = go.FigureWidget(fig)
        with self.sbert_map_output:
            clear_output(wait=True)
            display(self.fig_sbert_map)
            
    def on_update_sbert_view_clicked(self, b):
        """C-5. ラベル・期間フィルタ更新 ボタンの処理"""
        self.update_sbert_view_btn.disabled = True
        with self.sbert_map_output:
            print("🔄 (6) サブクラスタ・ラベルと期間フィルタを更新して再描画します...")
            
        if self.df_drilldown.empty:
            print("🚨 エラー: まず「5. 技術マップを描画」を実行してください。")
            self.update_sbert_view_btn.disabled = False
            return
            
        try:
            self._redraw_sbert_map()
            self._update_sbert_label_editor()
            
            print("✅ サブクラスタ・ラベルと期間フィルタを更新して再描画しました。")
            
        except Exception as e:
            with self.sbert_map_output:
                clear_output(wait=True)
                print(f"🚨 ラベル/フィルタ更新中にエラー: {e}")
                import traceback
                traceback.print_exc()
                
        self.update_sbert_view_btn.disabled = False


    # ==========================================================
    # --- D. エクスポート ---
    # ==========================================================
    def on_export_mega_clicked(self, b):
        """D-7. 動態分析マップ結果をエクスポート"""
        self.export_mega_btn.disabled = True
        with self.export_output:
            clear_output(wait=True)
            print("🔄 (7) 動態分析マップの結果をCSVエクスポートします...")
            
            if self.df_momentum_result.empty:
                print("🚨 エラー: エクスポートするデータがありません。")
                print("   (先に「3. 動態分析マップを描画」を実行してください)")
                self.export_mega_btn.disabled = False
                return
            
            try:
                df_to_export = self.df_momentum_result.copy()
                
                current_custom_labels = {
                    widget.description.strip('[]'): widget.value 
                    for widget in self.mega_label_editor_box.children
                }
                current_custom_labels['N/A'] = 'N/A' 
                
                df_to_export['Group_Custom'] = df_to_export['Group_Auto'].map(current_custom_labels).fillna('N/A')
                
                filename = f"MEGA_Dynamic_Map_{self.axis_label}.csv" 
                df_to_export.to_csv(filename, encoding='utf-8-sig')
                files.download(filename)
                
                print(f"✅ {filename} のダウンロードを開始します。")

            except Exception as e:
                print(f"🚨 エクスポート中にエラー: {e}")
                
            self.export_mega_btn.disabled = False

    def on_export_drilldown_clicked(self, b):
        """D-8. ドリルダウン結果をエクスポート"""
        self.export_drilldown_btn.disabled = True
        with self.export_output:
            clear_output(wait=True)
            print("🔄 (8) ドリルダウンの結果をCSVエクスポートします...")

            if self.df_drilldown.empty:
                print("🚨 エラー: エクスポートするデータがありません。")
                print("   (先に「5. 技術マップを描画」を実行してください)")
                self.export_drilldown_btn.disabled = False
                return
                
            try:
                df_to_export = self.df_drilldown.copy()
                
                custom_labels_map = {
                    widget.description.strip('[]'): widget.value 
                    for widget in self.sbert_label_editor_box.children
                }
                if -1 in self.sbert_sub_cluster_map_auto: 
                    noise_label = self.sbert_sub_cluster_map_auto[-1]
                    custom_labels_map[noise_label] = noise_label

                auto_to_custom_map = {
                    auto_label: custom_labels_map.get(auto_label, auto_label)
                    for auto_label in self.sbert_sub_cluster_map_auto.values()
                }
                df_to_export['label_custom'] = df_to_export['label'].map(auto_to_custom_map).fillna('N/A')
                
                df_to_export = df_to_export.drop(columns=['label'], errors='ignore')

                target_name = self.drilldown_target_w.value.replace(" ", "_").replace("/", "_")
                filename = f"MEGA_Drilldown_Map_{target_name}.csv"
                df_to_export.to_csv(filename, index=False, encoding='utf-8-sig')
                files.download(filename)
                
                print(f"✅ {filename} のダウンロードを開始します。")
                
            except Exception as e:
                print(f"🚨 エクスポート中にエラー: {e}")

            self.export_drilldown_btn.disabled = False

# ==================================================================
# --- 5. アプリケーションの実行 ---
# ==================================================================
app = MEGAApp()
display(app.ui)

7.実施例

初期画面1
初期画面2
CAGR VS 活動量の動態マップ
軌跡
技術ポートフォリオ

8. おわりに

本稿では、マクロな「技術動態分析」とミクロな「技術ポートフォリオ分析」を統合したツール、MEGA (Matrix for Emerging & Growth Assessment) を紹介した。

現代において、特許データは爆発的に増加しており、全件をマニュアルでレビューし、その関係性を把握することは不可能に近い。SBERTのようなAIモデルの真価は、単なる分析の効率化だけでなく、人間では見落としてしまうような技術間の「意味的な繋がり」を発見することにある。

MEGAは、そのAIによるミクロな視点(技術マップ)と、経営的なマクロな視点(動態分析)を連結させる試みの一つである。Bセクションで「特許データから、今どのプレイヤーが、どのような勢いにあるのか」を特定し、Cセクションで「では、そのプレイヤーは具体的に何をやっているのか」を深掘りできる。

本ツールはあくまで分析の出発点である。マップに表示されたクラスタが何を意味するのかを解釈し、特許以外の情報と結びつけ、次の戦略的な一手を導き出すのは、最終的には分析者自身である。

本ツールが、その知的で創造的な分析業務の一助となることを願ってやまない。

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

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