見出し画像

静的分析を超えて:特許情報による技術動態分析


1. はじめに

特許情報は、技術開発の未来を映す鏡である。膨大な特許公報の中から、次に台頭する技術は何か、競合他社が次に打つ一手は何か、その兆候を読み解くことは、現代のビジネス戦略において極めて重要である。しかし、多くの特許情報分析は「現時点での件数」という静的な情報に留まりがちだ。本当に知りたいのは、単なる量ではなく、「勢い」である。ある技術分野が、あるいはある企業が、どれだけの勢いで特許出願を増やしているのか。その動的な変化、すなわちモメンタムを捉えることこそが、未来を予測する鍵となる。本稿では、このモメンタムを可視化し、戦略的洞察を得るために開発した分析手法、技術動態分析(Technology Dynamics Analysis)とそのPython実装(Google Colab版)について、その基本原理から具体的な使い方、結果の解釈までを詳細に解説する。

なお、「モメンタム」に着目した分析はこれが初出では勿論ない。皆様ご存知の野崎篤志師が「出願ポジショニングマップ」を考案し、我々分析者のバイブルである「赤本」で解説している。(ちなみに最新の第3版と改訂版ではロジックが異なるポジショニングマップが掲載されており、改訂版にも目を通すと勉強になることを補足しておく)

なお、私、しばやまは赤本にインスピレーションを受け、Pythonでポジショニングマップを書く記事を公開している。お手隙の際にはご笑覧いただきたい。

2. 分析の核心的概念:技術ダイナミクスマップ

本分析の目的は、単に出願件数の多い・少ないを評価することではない。その核心は、「過去から続く成長の勢い」と「現在の研究開発の活発度」という2つの異なる時間軸の指標を意図的に対比させ、そのギャップや相関から戦略的な示唆を読み解くことにある。これを可視化するのが「技術ダイナミクスマップ」である。

  • 横軸 (X軸):過去の勢い (CAGR)
    各技術や企業の「出現年」から、特許の公開ラグ(後述)を考慮した信頼性の高い年までの年平均成長率(CAGR)を示す。これは、その対象がこれまでどれだけ持続的に成長してきたかを表す「実績」の指標である。

  • 縦軸 (Y軸):現在の活動量
    公開ラグをあえて無視し、直近数年間(例: 5年)の生の出願件数を示す。これは、その対象が「今、どれだけのリソースを投下しているか」を示す「現在の注力度」の指標である。

  • バブルの大きさ:全期間の合計件数
    分析対象期間における総出願件数を示し、その対象の全体的な影響力(市場規模)を表す。

この2つの軸を組み合わせることで、私たちは単なるランキングでは見えてこない、各技術や企業の質的な違いを明らかにすることができるのである。

3. 基本原理とAIによるグループ分け

この分析の心臓部となるのが、「勢い」を測るための指標であるCAGRと、それを正確に計算するためのデータ前処理である。

「勢い」の正体:CAGR(年平均成長率)

CAGR(Compound Annual Growth Rate)は、複数年にわたる成長率を、複利計算の考え方を用いて「1年あたりの成長率」に換算した指標である。単年のブレに左右されにくく、長期的な成長トレンドを評価するのに適している。例えば、ある技術の出願件数が2020年に5件、2023年に40件だった場合、4年間で8倍に成長している。これをCAGRで計算すると、「毎年100%(倍々ゲーム)で成長した」のと同じ勢いであることが示される。

最重要の補正:特許の公開ラグ

特許は出願から1年6ヶ月(18ヶ月)経過しないと公開されない。このため、出願年ベースで集計すると、直近1〜2年のデータは、まだ公開されていない特許の分だけ、見かけ上の件数が著しく少なくなる。この不完全なデータをCAGRの計算に含めてしまうと、成長している技術の勢いを「急減速している」と誤って評価してしまう致命的なミスにつながる。これを防ぐため、本ツールではCAGRを計算する際の終了年を利用者が明示的に指定する設計となっている。

件数カウントの正確性:出願番号ベースの集計

本ツールでは、ユーザーが指定した「出願番号」カラムを基準とし、分析軸ごとに重複を排除することで、より正確な件数(Y軸、バブルサイズ、CAGRの元データ)を集計するロジックを採用している。

  • 出願人/権利者軸の場合:出願番号と権利者の組み合わせでユニーク化し、共同出願は各権利者の活動として1件ずつカウントする。

  • 技術分類軸(IPC, Fターム等)の場合:出願番号と技術分類の組み合わせでユニーク化し、特定の出願は該当分類で1件としてカウントする。

AIによる相対的なグループ分け

本ツールでは、単に全体の平均値で線を引くだけでなく、AI(K-Meansクラステリング)が全データの分布を読み取り、統計的に最も自然な4つのグループに自動で分類する。さらに、その4つのグループの中心点を「互いに比較」し、相対的な位置関係に基づいて「リーダー」「新興」「成熟」「衰退」という戦略ラベルを割り当てる。これにより、一部のデータが突出している場合でも「全員リーダー」のような偏った結果になるのを防ぎ、データが持つ構造そのものを客観的に可視化する。チャートは、このAIによる分類結果に基づいて4色に色分けされる(色はラベルごとに固定)。

4. ツールの使い方とコード

本ツールはGoogle Colab上で動作する。プログラミング知識は不要で、以下の手順で分析が完了する。

操作方法

  1. コードの実行:
    下記のコードセルを実行すると、「分析を開始する」ボタンが表示される。

  2. ファイルのアップロード:
    ボタンを押し、分析したい特許リスト(CSVまたはExcel形式)をアップロードする。ファイル読み込み後、ステップ2のUIが表示される。

  3. カラム情報の設定:
    ファイルの列名がドロップダウンリストに表示される。「出願番号」「出願日」は必須、「出願人/権利者」「IPC」「Fターム」は分析したい軸に応じて選択する。「カラム情報を確定」ボタンを押すと、ステップ3のUIが表示される。

  4. 分析パラメータの設定:

    • CAGR計算の終了年:公開ラグを考慮し、通常は現在年から2年前を推奨。

    • 縦軸(現在)の集計年数:Y軸の計算に使う直近の年数(デフォルト5年)。

    • 最小フィルタ件数:分析ノイズを除去するため、全期間の合計件数がこの値未満の対象を除外する(デフォルト10件)。

    • 区切り文字(任意):1セルに複数の値(権利者、IPC等)が含まれる場合の区切り文字(例: ;)。不要な場合は空にする。「パラメータを確定 (前処理実行)」ボタンを押すと、データの前処理が実行され、ステップ4のUIが表示される。

  5. 分析の実行:
    プルダウンメニューから分析したい軸(出願人/権利者、IPCなど)を選択し、「分析を実行」ボタンを押すと、グラフと結果テーブルが出力される。

グラフの特徴

  • 動的な表示:生成されるグラフはPlotlyによるインタラクティブなものである。各バブルにマウスカーソルを合わせると、対象の名前(企業名など)、X軸/Y軸/バブルサイズの具体的な数値、所属する戦略グループが表示される。

  • 画像保存:グラフ右上のカメラアイコンをクリックすると、表示されているグラフをPNG画像としてダウンロードできる。

分析軸の詳細

本ツールで選択可能な分析軸には、それぞれ以下の特徴がある。

  • 出願人/権利者:
    個々の企業、大学、研究機関などを分析の単位とする、最も基本的な軸である。これにより、特定の技術分野において、どのプレイヤーが積極的に投資を拡大し(勢いが大きい)、逆にどのプレイヤーが活動を縮小しているのかが直接的に可視化される。競合他社のR&D戦略や事業の方向性を分析する、いわゆる競合分析において中心的な役割を果たす。

  • IPC(国際特許分類):
    技術を機能や用途といった観点から分類した世界共通の分類である。本ツールでは、H01L 21/304であれば/より前のH01L 21のように、メイングループの階層で集計を行う。これにより、技術分野を適度な粒度で俯瞰できる。

  • Fターム(File Forming Term):
    日本の特許庁が採用している独自の分類で、IPCを補完する多角的な技術観点(目的、構造、材料など)を提供する。本ツールでは、2B123AB01であれば先頭5桁の2B123のように、テーマコードの階層で集計を行う。

  • IPC × Fターム:
    これは、世界共通の「機能・用途(IPC)」と、日本独自の「技術観点(Fターム)」を掛け合わせた、極めて解像度の高い分析軸である。例えば、「ニューラルネットワーク(IPC: G06N 3/00)」という大きな括りの中から、「画像認識用途の(Fターム: 5L096 AA01)」ものだけを抽出して、その勢いを分析することができる。これにより、特定の技術領域における、より具体的なサブテーマの動向を深掘りすることが可能となる。

コード

# ==================================================================
# --- 1. 環境構築とライブラリのインストール ---
# ==================================================================
# 必要なライブラリをインストールします (初回実行時のみ)
!pip install pandas numpy scikit-learn openpyxl ipywidgets plotly -q

# ==================================================================
# --- 2. ライブラリのインポート ---
# ==================================================================
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go 
import plotly.io as pio 
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
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

# Google Colab で Plotly のインタラクティブウィジェットを有効化
from google.colab import output
output.enable_custom_widget_manager()

# 警告メッセージを非表示に
warnings.filterwarnings('ignore')

# Plotly のデフォルトレンダーを 'colab' に設定
pio.renderers.default = "colab"

print("✅ ライブラリの準備完了")

# ==================================================================
# --- 3. グローバル変数とUI要素の定義 ---
# ==================================================================
df_main = pd.DataFrame()       # アップロードされた生データ
df_processed = pd.DataFrame()  # 前処理済みのデータ
column_mapping = {}            # ユーザーが指定したカラム名
params = {}                    # ユーザーが指定した分析パラメータ

# UIの各ステップを表示するためのOutputウィジェット
step1_output = Output()
step2_output = Output()
step3_output = Output()
step4_output = Output()
analysis_output = Output() # 最終的なグラフとテーブルの表示エリア

# UIウィジェットの共通スタイル(ラベル幅自動調整)
common_style = {'description_width': 'initial'} 
common_layout = Layout(width='500px') 

# ==================================================================
# --- 4. 分析ロジックとUI構築関数 ---
# ==================================================================

# --- データ前処理関数 ---
def preprocess_data():
    """
    アップロードされたデータを分析可能な形式に前処理する。
    区切り文字での展開、IPC/Fタームの正規化などを行う。
    """
    global df_processed
    try:
        print("🔄 データの前処理を実行中...")
        df = df_main.copy()
        delimiter = params['delimiter'].value
        app_num_col = column_mapping['app_num'].value 
        date_col = column_mapping['date'].value
        applicant_col = column_mapping['applicant'].value
        ipc_col = column_mapping['ipc'].value
        fterm_col = column_mapping['fterm'].value

        # --- 必須カラムの存在チェック ---
        required_cols = [app_num_col, date_col]
        if not all(col in df.columns for col in required_cols):
             missing = [col for col in required_cols if col not in df.columns]
             print(f"❌ エラー: 必須カラム {missing} がファイル内に見つかりません。")
             return False

        # --- 日付処理と基本カラム作成 ---
        df[date_col] = pd.to_datetime(df[date_col], errors='coerce')
        df.dropna(subset=[app_num_col, date_col], inplace=True) 
        df['year'] = df[date_col].dt.year
        df['app_num_main'] = df[app_num_col].astype(str).str.strip() # 出願番号を文字列化

        # --- 分析軸の正規化 ---
        # .str アクセサは NaN を NaN のまま扱うため安全
        df['applicant_main'] = df[applicant_col].str.strip() if applicant_col in df.columns else np.nan
        df['ipc_main'] = df[ipc_col].str.split('/').str[0].str.strip() if ipc_col in df.columns else np.nan
        df['fterm_theme'] = df[fterm_col].str.slice(0, 5).str.strip() if fterm_col in df.columns else np.nan
        
        # --- 複合軸の作成 ---
        df['ipc_fterm'] = np.nan
        valid_ipc_fterm = df['ipc_main'].notna() & df['fterm_theme'].notna()
        df.loc[valid_ipc_fterm, 'ipc_fterm'] = df.loc[valid_ipc_fterm, 'ipc_main'] + ' x ' + df.loc[valid_ipc_fterm, 'fterm_theme']

        # --- 区切り文字によるデータ展開 (Explode) ---
        # 展開後も必要なカラムリスト
        final_cols = ['app_num_main', 'year', 'applicant_main', 'ipc_main', 'fterm_theme', 'ipc_fterm']
        
        if delimiter:
            print(f"  > 区切り文字 '{delimiter}' を使用してデータを展開します。")
            
            # 展開対象となる元のカラム名 (正規化前の名前)
            explode_cols_original = [applicant_col, ipc_col, fterm_col]
            # 展開後も保持する固定カラム
            keep_cols = ['app_num_main', 'year'] 
            
            # 必要なカラムだけを持つ一時データフレームを作成
            temp_df = df[keep_cols + [col for col in explode_cols_original if col in df.columns]].copy()

            for col in explode_cols_original:
                if col in temp_df.columns:
                    # NaNを一時的に空文字列に変換 -> split/explode -> 空文字列をNaNに戻す (NaNを含むセルも正しく処理するため)
                    temp_df[col] = temp_df[col].fillna('').astype(str).str.split(delimiter)
                    temp_df = temp_df.explode(col)
                    temp_df[col] = temp_df[col].str.strip().replace('', np.nan)

            # --- 展開後に再度、正規化を実行 ---
            # (展開によって新たに生まれた行に対しても正規化を適用するため)
            temp_df['applicant_main'] = temp_df[applicant_col].str.strip() if applicant_col in temp_df.columns else np.nan
            temp_df['ipc_main'] = temp_df[ipc_col].str.split('/').str[0].str.strip() if ipc_col in temp_df.columns else np.nan
            temp_df['fterm_theme'] = temp_df[fterm_col].str.slice(0, 5).str.strip() if fterm_col in temp_df.columns else np.nan
            
            temp_df['ipc_fterm'] = np.nan # 再計算
            valid_ipc_fterm_temp = temp_df['ipc_main'].notna() & temp_df['fterm_theme'].notna()
            temp_df.loc[valid_ipc_fterm_temp, 'ipc_fterm'] = temp_df.loc[valid_ipc_fterm_temp, 'ipc_main'] + ' x ' + temp_df.loc[valid_ipc_fterm_temp, 'fterm_theme']
            
            # 必要なカラムのみを抽出
            df_processed = temp_df[[col for col in final_cols if col in temp_df.columns]].copy()
            
        else: # 区切り文字がない場合
            print("  > 区切り文字が指定されなかったため、展開(explode)をスキップします。")
            df_processed = df[[col for col in final_cols if col in df.columns]].copy()
        
        # 最終的なクリーンアップ (None文字列などをNaNに)
        df_processed.replace({'None': np.nan, 'nan': np.nan}, inplace=True)
        
        print(f"✅ データの前処理完了 (処理後 {len(df_processed)}行)")
        return True
    
    except KeyError as e:
        print(f"❌ エラー: 指定されたカラム名 '{e}' がファイル内に見つかりません。ステップ2の設定を確認してください。")
        return False
    except Exception as e:
        print(f"❌ データの前処理中にエラーが発生: {e}")
        import traceback
        traceback.print_exc()
        return False

# --- 分析実行関数 ---
def on_run_analysis(b):
    """分析実行ボタンが押されたときの処理"""
    with analysis_output: # 結果表示エリアで実行
        clear_output(wait=True)
        print("🔄 分析を実行中...")
        
        try:
            # --- 1. パラメータとデータの準備 ---
            df = df_processed.copy() # 前処理済みデータを使用
            axis_key = analysis_axis_dropdown.value
            cagr_end_year = params['cagr_end_year'].value
            y_axis_years = params['y_axis_years'].value
            min_patents_threshold = params['min_patents'].value
            app_num_col_internal = 'app_num_main' 

            # 分析軸に対応する内部カラム名と表示用ラベルを取得
            axis_map = {
                'applicant': ('applicant_main', "出願人/権利者"),
                'ipc': ('ipc_main', "IPC (メイングループ)"),
                'fterm': ('fterm_theme', "Fターム (テーマコード)"),
                'ipc_fterm': ('ipc_fterm', "IPC x Fターム")
            }
            axis_name_col, axis_name_label = axis_map[axis_key]
            
            # --- 分析軸データと出願番号の欠損を除外 ---
            df.dropna(subset=[axis_name_col, app_num_col_internal], inplace=True) 
            if df.empty:
                print(f"❌ 分析軸 '{axis_name_label}' の有効なデータ(出願番号含む)が0件です。")
                return
                
            # --- 出願番号ベースでの重複排除 ---
            print(f"  > '{axis_name_label}' 軸で重複カウントを排除...")
            subset_cols = [app_num_col_internal, axis_name_col] # 出願番号と分析軸の組み合わせ
            original_rows = len(df)
            df.drop_duplicates(subset=subset_cols, keep='first', inplace=True)
            print(f"    {original_rows}行 -> {len(df)}行 (ユニーク件数)")

            # --- 2. 時系列データの集計 ---
            # 重複排除後のデータで年ごとの件数を集計
            pivot_df = pd.pivot_table(df, index=axis_name_col, columns='year', aggfunc='size', fill_value=0)
            
            # --- 3. 分析指標(X軸, Y軸, バブルサイズ)の計算 ---
            current_year = datetime.datetime.now().year
            # Y軸: 直近N年の合計件数
            y_start_year = current_year - y_axis_years + 1
            y_axis_cols = [col for col in pivot_df.columns if col >= y_start_year and col <= current_year]
            y_axis = pivot_df[y_axis_cols].sum(axis=1) if y_axis_cols else pd.Series(0, index=pivot_df.index)
            # バブルサイズ: 全期間の合計件数
            bubble_size = pivot_df.sum(axis=1)
            # X軸: CAGR (年平均成長率)
            cagr_cols = [col for col in pivot_df.columns if col <= cagr_end_year]
            
            def calculate_cagr(row):
                """開始年から終了年までのCAGRを計算する内部関数"""
                valid_years = row[row > 0].index
                if not any(valid_years): return np.nan # データなし
                start_year = min(valid_years)
                # 計算期間が1年未満 or 開始年が終了年より後の場合は計算不能
                if start_year >= cagr_end_year: return np.nan 
                
                start_value = row[start_year]
                end_value = row[cagr_end_year]
                
                if start_value <= 0: return np.nan # 開始値が0以下
                if end_value <= 0: return -1.0    # 終了値が0以下 (-100%成長)
                
                num_years = cagr_end_year - start_year
                if num_years == 0: return np.nan # 期間が0年 (同じ年)
                
                # CAGR計算式: (終了値 / 開始値)^(1 / 年数) - 1
                return ((end_value / start_value) ** (1 / num_years)) - 1

            x_axis = pivot_df[cagr_cols].apply(calculate_cagr, axis=1) if cagr_cols else pd.Series(np.nan, index=pivot_df.index)

            # --- 4. 最終データフレームの作成とフィルタリング ---
            x_col, y_col, bubble_col = 'X軸: CAGR (過去の勢い)', 'Y軸: 現在の活動量', 'バブル: 全期間の合計件数'
            result_df = pd.DataFrame({
                axis_name_label: pivot_df.index, x_col: x_axis,
                y_col: y_axis, bubble_col: bubble_size
            })
            # 無限大をNaNに置換し、NaNを除外
            result_df.replace([np.inf, -np.inf], np.nan, inplace=True)
            result_df.dropna(subset=[x_col, y_col], inplace=True)
            # 最小フィルタ件数で絞り込み
            result_df = result_df[result_df[bubble_col] >= min_patents_threshold].copy()
            
            if result_df.empty:
                print("❌ フィルタリング後の分析結果が0件です。最小フィルタ件数を調整してください。")
                return

            # --- 5. AIによるグループ分け (相対ラベル付け) ---
            # クラスタリング用のデータ準備(NaNを除外)
            data_for_clustering = result_df[[x_col, y_col]].copy()
            data_for_clustering[x_col] = np.log1p(data_for_clustering[x_col]) # 対数変換
            data_for_clustering[y_col] = np.log1p(data_for_clustering[y_col]) # 対数変換
            data_for_clustering.replace([np.inf, -np.inf], np.nan, inplace=True) # infをnanに
            data_for_clustering.dropna(inplace=True, subset=[x_col, y_col], how='any')

            if len(data_for_clustering) < 4:
                print("⚠️ クラスタリングに必要なデータが4件未満のため、グループ分けをスキップします。")
                result_df['戦略グループ (AI分類)'] = 'N/A'
            else:
                n_clusters = 4 # グループ数は4に固定
                scaler = StandardScaler()
                kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
                # スケーリングしてKMeans実行
                labels = kmeans.fit_predict(scaler.fit_transform(data_for_clustering))
                data_for_clustering['cluster_id'] = labels
                
                # 元のデータフレームにクラスタIDを結合
                result_df = result_df.merge(data_for_clustering[['cluster_id']], left_index=True, right_index=True, how='left')
                
                # --- 相対ラベル付けロジック ---
                # 各クラスタの中心点(中央値)を計算
                cluster_centers = result_df.groupby('cluster_id')[[x_col, y_col]].median()
                # X軸とY軸でランク付け (値が大きい方が高ランク=1位)
                cluster_centers['x_rank'] = cluster_centers[x_col].rank(ascending=False)
                cluster_centers['y_rank'] = cluster_centers[y_col].rank(ascending=False)
                # ランクの合計 (値が小さいほど総合的に右上)
                cluster_centers['total_rank'] = cluster_centers['x_rank'] + cluster_centers['y_rank']

                def assign_relative_label(center_row):
                    """クラスタ中心点のランクに基づいて戦略ラベルを割り当てる内部関数"""
                    x_rank = center_row['x_rank']
                    y_rank = center_row['y_rank']
                    
                    if center_row['total_rank'] == cluster_centers['total_rank'].min():
                        return 'リーダー (Leaders)'
                    elif center_row['total_rank'] == cluster_centers['total_rank'].max():
                        return '衰退・ニッチ (Declining/Niche)'
                    elif x_rank < y_rank: # Xランクが良い (値が小さい) -> X軸寄り
                        return '新興・高ポテンシャル (Emerging)'
                    else: # Yランクが良い or 同等 -> Y軸寄り or 中間
                        return '成熟・既存勢力 (Established)'

                # クラスタIDと戦略ラベルの対応辞書を作成
                label_map = cluster_centers.apply(assign_relative_label, axis=1).to_dict()
                # ラベルを適用し、クラスタリングから除外されたデータは 'N/A (除外)' とする
                result_df['戦略グループ (AI分類)'] = result_df['cluster_id'].map(label_map)
                result_df['戦略グループ (AI分類)'].fillna('N/A (除外)', inplace=True)

            # --- 6. 可視化 (go.FigureWidget & 固定色) ---
            print("✅ 分析完了。グラフを生成中...")
            # X軸、Y軸の全体の平均値を計算 (点線用)
            x_mean, y_mean = result_df[x_col].mean(), result_df[y_col].mean()

            # プロット対象データ ('N/A (除外)' を除く)
            df_to_plot = result_df[result_df['戦略グループ (AI分類)'] != 'N/A (除外)'].copy()

            if df_to_plot.empty:
                print("⚠️ プロット対象データが0件です。グラフは表示されません。")
            else:
                # 固定色のマッピング
                color_map = {
                    'リーダー (Leaders)': 'green',
                    '新興・高ポテンシャル (Emerging)': 'gold', 
                    '成熟・既存勢力 (Established)': 'blue',
                    '衰退・ニッチ (Declining/Niche)': 'grey'
                }
                
                # Plotly Express で散布図を作成
                fig = px.scatter(
                    df_to_plot,
                    x=x_col, y=y_col, size=bubble_col,
                    color='戦略グループ (AI分類)', 
                    color_discrete_map=color_map, # 固定色を適用
                    hover_name=axis_name_label,    # カーソル合わせた時のタイトル
                    # カーソル合わせた時に表示する追加情報
                    hover_data={ 
                        x_col: ':.1%',             # CAGR (パーセント)
                        y_col: ':,',               # 活動量 (カンマ区切り)
                        bubble_col: ':,',          # 合計件数 (カンマ区切り)
                        '戦略グループ (AI分類)': True # グループ名
                    },
                    title=f"技術動態分析マップ - 分析軸: {axis_name_label}",
                    log_y=True,  # Y軸を対数スケールに
                    size_max=60  # バブルの最大サイズ
                )
                
                # 平均値の点線を追加
                fig.add_vline(x=x_mean, line_width=1, line_dash="dash", line_color="gray")
                fig.add_hline(y=y_mean, line_width=1, line_dash="dash", line_color="gray")

                # レイアウト調整 (軸ラベル、サイズ、凡例)
                fig.update_layout(
                    xaxis_title=f"← 勢い減速 | 過去の勢い (CAGR) | 勢い加速 → (平均: {x_mean:.1%})",
                    yaxis_title=f"← 活動鈍化 | 現在の活動量 (直近{y_axis_years}年) | 活動活発 →",
                    xaxis_tickformat='.0%', # X軸をパーセント表示
                    height=800, width=1200, # グラフサイズ (横長)
                    legend_title_text='戦略グループ (AI分類)' 
                )
                
                # FigureWidget を使って表示 (Colabウィジェット内で表示するため)
                fig_widget = go.FigureWidget(fig)
                display(fig_widget) 

            # --- 7. 結果テーブルの表示 ---
            print(f"\n--- 分析結果データ ({y_col} 上位20件) ---")
            # 表示用に数値をフォーマット
            display_df = result_df.sort_values(by=y_col, ascending=False).head(20).copy()
            display_df[y_col] = display_df[y_col].map('{:,.0f}'.format)
            display_df[bubble_col] = display_df[bubble_col].map('{:,.0f}'.format)
            display_df[x_col] = display_df[x_col].map('{:.1%}'.format) 
            # インデックスをリセットして見やすくする (オプション)
            # display_df.reset_index(drop=True, inplace=True) 
            display(display_df)


        except Exception as e:
            print(f"❌ 分析中に予期せぬエラーが発生しました: {e}")
            import traceback
            traceback.print_exc()

# ==================================================================
# --- 5. UIの構築と表示 ---
# ==================================================================

# --- ステップ4のUI定義 ---
analysis_axis_dropdown = widgets.Dropdown(
    options=[('出願人/権利者', 'applicant'), ('IPC (メイングループ)', 'ipc'),
             ('Fターム (テーマコード)', 'fterm'), ('IPC x Fターム', 'ipc_fterm')],
    description='分析軸:', 
    layout=common_layout, style=common_style
)
run_btn = widgets.Button(description='分析を実行', button_style='success', icon='cogs')
run_btn.on_click(on_run_analysis) 

def build_step4_ui():
    """ステップ4のUI(実行ボタン)を表示"""
    with step4_output:
        clear_output(wait=True)
        display(VBox([widgets.HTML("<h3>ステップ4: 分析の実行</h3>"),
                      widgets.HTML("<p>分析したい軸を選択し、「分析を実行」ボタンを押してください。</p>"),
                      analysis_axis_dropdown, run_btn]))
        analysis_output.clear_output() 

# --- ステップ3のUI定義 ---
def build_step3_ui():
    """ステップ3のUI(パラメータ設定)を表示"""
    global params
    with step3_output:
        clear_output(wait=True)
        current_year = datetime.datetime.now().year
        params = {
            'cagr_end_year': widgets.IntText(value=current_year - 2, description='CAGR計算の終了年:', 
                                             layout=common_layout, style=common_style),
            'y_axis_years': widgets.IntSlider(value=5, min=1, max=10, description='縦軸(現在)の集計年数:', 
                                              layout=common_layout, style=common_style),
            'min_patents': widgets.IntText(value=10, description='最小フィルタ件数:', 
                                           layout=common_layout, style=common_style),
            'delimiter': widgets.Text(value=';', description='区切り文字(任意):', placeholder='例: ; / ,', 
                                        layout=common_layout, style=common_style)
        }
        confirm_btn = widgets.Button(description='パラメータを確定 (前処理実行)', button_style='primary')
        
        def on_confirm_params(b):
            """パラメータ確定ボタンが押された時の処理"""
            confirm_btn.disabled = True # ボタンを一時無効化
            print("パラメータを確定しました。データ前処理を開始します...")
            if preprocess_data():
                build_step4_ui() # 前処理成功ならステップ4へ
            else:
                print("🚫 前処理に失敗しました。設定を確認してください。")
                confirm_btn.disabled = False # 失敗したらボタンを戻す
        
        confirm_btn.on_click(on_confirm_params)
        display(VBox([widgets.HTML("<h3>ステップ3: 分析パラメータの設定</h3>"),
                      widgets.HTML("<p>分析の計算条件と、データの区切り文字(必要な場合)を設定します。</p>"),
                      params['cagr_end_year'], params['y_axis_years'],
                      params['min_patents'], params['delimiter'],
                      confirm_btn]))
        # 後続のUIエリアをクリア
        step4_output.clear_output()
        analysis_output.clear_output()

# --- ステップ2のUI定義 ---
def build_step2_ui(columns):
    """ステップ2のUI(カラムマッピング)を表示"""
    global column_mapping
    with step2_output:
        clear_output(wait=True)
        cols = [''] + list(columns) # 未選択状態を許容するために先頭に空文字を追加
        # デフォルトで空文字を選択させる
        column_mapping = {
            'app_num': widgets.Dropdown(options=cols, value=None, description='出願番号:', 
                                        layout=common_layout, style=common_style),
            'date': widgets.Dropdown(options=cols, value=None, description='出願日:', 
                                     layout=common_layout, style=common_style),
            'applicant': widgets.Dropdown(options=cols, value=None, description='出願人/権利者:', 
                                          layout=common_layout, style=common_style),
            'ipc': widgets.Dropdown(options=cols, value=None, description='IPC:', 
                                    layout=common_layout, style=common_style),
            'fterm': widgets.Dropdown(options=cols, value=None, description='Fターム:', 
                                      layout=common_layout, style=common_style)
        }
        confirm_btn = widgets.Button(description='カラム情報を確定', button_style='primary')
        
        def on_confirm_columns(b):
            """カラム情報確定ボタンが押された時の処理"""
            # 必須項目が選択されているかチェック
            if not column_mapping['app_num'].value or not column_mapping['date'].value:
                print("❌ エラー: 「出願番号」と「出願日」は必須項目です。選択してください。")
                return
            print("✅ カラム情報を設定しました。")
            confirm_btn.disabled = True
            build_step3_ui() # ステップ3へ

        confirm_btn.on_click(on_confirm_columns)
        display(VBox([widgets.HTML("<h3>ステップ2: カラム情報の設定</h3>"),
                      widgets.HTML("<p>アップロードしたファイルの列名と、分析に必要な項目を紐付けてください。<br><b>「出願番号」「出願日」は必須です。</b>他は任意です。</p>"),
                      column_mapping['app_num'], 
                      column_mapping['date'], column_mapping['applicant'],
                      column_mapping['ipc'], column_mapping['fterm'],
                      confirm_btn]))
        # 後続のUIエリアをクリア
        step3_output.clear_output()
        step4_output.clear_output()
        analysis_output.clear_output()

# --- ステップ1のUI定義 ---
start_btn = widgets.Button(description="分析を開始する", button_style='primary', icon='play')

def on_start_button_clicked(b):
    """分析開始ボタンが押された時の処理"""
    global df_main
    with step1_output:
        start_btn.disabled = True # ボタンを無効化
        print("📁 特許リストのCSV/Excelファイルをアップロードしてください...")
        try:
            # Colabのファイルアップロード機能
            uploaded = files.upload()
            if not uploaded:
                print("\n⚠️ ファイルがアップロードされませんでした。")
                start_btn.disabled = False # ボタンを有効に戻す
                return

            file_name = next(iter(uploaded)) # アップロードされたファイル名を取得
            content = uploaded[file_name]    # ファイルの内容を取得
            
            print(f"\n🔄 '{file_name}' を読み込んでいます...")
            # ファイル形式に応じて読み込み (dtype=str で数値の自動変換を防ぐ)
            if file_name.lower().endswith('.csv'):
                df_main = pd.read_csv(io.BytesIO(content), dtype=str)
            elif file_name.lower().endswith(('.xlsx', '.xls')):
                 df_main = pd.read_excel(io.BytesIO(content), dtype=str)
            else:
                 print(f"❌ サポートされていないファイル形式です: {file_name}")
                 start_btn.disabled = False
                 return

            print(f"✅ ファイル読み込み完了 ({len(df_main)}行)")
            build_step2_ui(df_main.columns) # ステップ2のUIを構築

        except Exception as e:
            print(f"\n❌ アップロードまたはファイル読み込みに失敗: {e}")
            start_btn.disabled = False # エラー時もボタンを戻す

start_btn.on_click(on_start_button_clicked)

# ==================================================================
# --- 6. メインUIの構築と表示 ---
# ==================================================================
# 最初にステップ1のUI(ファイルアップロードボタン)だけを表示
with step1_output:
    clear_output(wait=True)
    display(VBox([widgets.HTML("<h3>ステップ1: ファイルのアップロード</h3>"), start_btn]))

# 全体のコンテナ (各ステップのOutputウィジェットを縦に並べる)
ui_container = VBox([
    widgets.HTML("<h1>技術動態分析 (Technology Dynamics Analysis) ツール</h1>"),
    widgets.HTML("<p><b>(固定色・最終版)</b></p>"),
    step1_output,
    step2_output,
    step3_output,
    step4_output,
    analysis_output # 最終的な結果表示エリア
])

print("✅ UI準備完了。分析を開始してください。")
display(ui_container)

5. 結果の解釈

分析結果の「技術ダイナミクスマップ」には、以下の要素が含まれる。

  • バブル:個々の分析対象(企業、IPCなど)を示す。位置がその特性を表し、大きさが規模を表す。

  • 点線:X軸(CAGR)とY軸(活動量)の全体の平均値を示す線。グラフを4つの象限に分割する目安となる。

  • 色分けと凡例:AIが分類した4つの戦略グループ(リーダー、新興、成熟、衰退)を示す。色はグループごとに固定されている。

AIによるグループ分けは、あくまでデータの分布に基づいた相対的な分類である点に注意が必要である。例えば、「衰退・ニッチ」領域に分類されたグループの中でも、相対的に最も活動的なものが「リーダー」ラベルを得ることがある。そのため、ラベル名だけでなく、グラフ全体の点の分布や平均線との位置関係も考慮して解釈することが重要である。

各戦略グループの典型的な解釈は以下の通りである。

  • リーダー (Leaders)
    特徴: (分析対象の中で相対的に)過去の勢いも高く、現在の活動量も高い。 解釈: 市場を牽引する技術分野や、その分野のリーディングカンパニーがここに位置しやすい。競争は激しいが、市場の中心である可能性が高い。

  • 新興・高ポテンシャル (Emerging)
    特徴: (相対的に)過去の勢いが高く、現在の活動量が低い。 解釈: 最近になって急成長を始めた技術や新規参入者。「0から1が生まれた」後の成長フェーズ。絶対規模は小さいが成長率が高い「隠れた有望株」の可能性がある。

  • 成熟・既存勢力 (Established)
    特徴: (相対的に)現在の活動量は高いが、過去の勢いが低い(またはマイナス)。 解釈: かつて市場を牽引していたが、成長が鈍化している既存プレイヤーや成熟技術。リソースシフトや戦略転換の兆候が見られる場合がある。

  • 衰退・ニッチ (Declining/Niche)
    特徴:
    (相対的に)過去の勢いも低く、現在の活動量も低い。 解釈: 技術的に成熟した分野、特定用途のニッチ技術、あるいは活動を縮小・撤退したプレイヤーなどがここに分類されることが多い。

6. 実施例

7. おわりに

本稿で紹介した技術動態分析は、特許データから動的な「勢い」を抽出し、戦略的な洞察を得るための一つのアプローチである。静的な件数集計では見過ごされがちな、技術のライフサイクルや企業の投資スタンスの変化を、より正確な件数集計とAIによる客観的な分類を通じて可視化することで、より確かな未来予測への一助となることを目指した。特に、インタラクティブなグラフは、多くの情報を効率的に探索する上で有用である。

ここに提示したコードは、あくまでこの分析思想を実装した一例である。本稿を通じて、読者一人ひとりがこの手法を発展させ、自らの特許情報分析に新たな視点を取り入れるきっかけとなれば、これに勝る喜びはない。

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

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