日本語文学作品の感情分析(学習済みニューラルモデル(BERT)と日本語評価極性辞書の比較実験ツール)

概要

目的は、青空文庫の作品を文単位で肯定・中立・否定に分類し、作品内の感情推移と手法間の一致度を定量化することである。実装は、学習済み BERT(大量の文章であらかじめ学習させた、文の意味を数値ベクトルへ変換するニューラルネットワーク。koheiduck/bert-japanese-finetuned-sentiment)と日本語評価極性辞書の2手法を同一の文集合に適用し、一致率・κ係数(偶然でも起こる一致を差し引いた一致度の指標)・混同行列(両手法の判定の組合せごとに文数を並べた表)とグラフを出力する Tkinter(Python に標準で付属する GUI 部品集)プログラムである。

目次

関連する外部ページ

1. 目的と前提

青空文庫の近代文学作品を文単位で肯定・中立・否定に分類し、学習済みニューラルモデルと評価極性辞書(語や表現が肯定的か否定的かという向き(極性)を人手で登録した語彙リスト)という原理の異なる二手法の判定を突き合わせて、作品内の感情推移と手法間の一致度を定量化することが目的である。前提として正解ラベルは存在せず、一致率とκ係数は正確さではなく手法間の整合性を測る指標にすぎない。

実験で確認できるもの

  • 文単位の3値ラベル(POSITIVE/NEUTRAL/NEGATIVE)と、BERT の確信度(0〜1)。
  • 辞書ベースの文極性値(文中で見つかった極性語の値を集計した数値)と、判定の根拠となった極性語。
  • 解析対象範囲を10分割した POSITIVE 比率・NEGATIVE 比率の推移。
  • 地の文(会話以外の叙述部分)と会話文(「」の有無で判別)でのラベル分布の差。
  • 2手法の一致率・Cohen のκ係数・3×3の混同行列。
  • 感情推移のグラフ。画面表示のほか sentiment_{作家}_{作品}.png として保存され、2作品以上を選んだ場合は sentiment_comparison.png も保存される。

利用技術や利用データの選定理由

  • 公開されている学習済みモデルをそのまま適用できるため、ラベル付けやモデルの学習を行わずに文単位の判定が得られる。
  • 入力テキストはプログラムが自動取得する。青空文庫クリーンデータセットは、パブリックドメイン(著作権の保護期間が満了し、誰でも自由に利用できる状態)と分類された作品に限定して作られている。
  • 日本語評価極性辞書も無償で公開されており、クレジットを明記すれば利用できる。
  • 文体の異なる3作家から選べるため、同じ設定でも入力によって結果がどう変わるかを比べられる。
  • 原理の異なる2手法を同一の文集合に適用するため、一方の出力を他方と突き合わせて読める。

社会的意義

  • テキストの感情分析は、商品レビューやSNS投稿の傾向把握、応対品質の管理、作品研究や読書支援などに用いられている。
  • 文学作品は、これらの用途で扱われる文章とは語彙も文体も異なり、皮肉・反語・古い言い回しを含む。判定が分かれる箇所を観察することは、結果をどこまで信頼してよいかを見極める材料になる。

2. 手法候補の一覧

「日本語の文を感情極性に分類する」という目的に対して、実際に選択肢となる手法を五つ挙げる。うち二つが本プログラムの実装に採用されている。

A 評価極性辞書による語彙集計法 本プログラムで採用
出典
小林のぞみ・乾健太郎・松本裕治・立石健二・福島俊一「意見抽出のための評価表現の収集」自然言語処理 Vol.12, No.3, pp.203-222, 2005/東山昌彦・乾健太郎・松本裕治「述語の選択選好性に着目した名詞評価極性の獲得」言語処理学会第14回年次大会論文集, pp.584-587, 2008
モデル
なし(語彙資源であり学習済みモデルではない)
概要
文を形態素解析(文を意味をもつ最小の単位へ分割し、品詞や基本形を与える処理)し、辞書に登録された評価表現の極性値(+1/−1)を数え上げて文全体の極性を決める。学習を必要とせず、判定の根拠となった語をそのまま提示できる。連続値の極性を与える高村ら(2006)の単語感情極性対応表を用いる変種もある。
B 情緒推定システム ML-Ask(辞書+文脈規則)
出典
Ptaszynski, M., Dybala, P., Rzepka, R., Araki, K. "Affecting Corpora: Experiments with Automatic Affect Annotation System", PACLING 2009/語彙資源は中村明『感情表現辞典』東京堂出版, 1993
モデル
なし(Python 実装 pymlask)
概要
約二千の感情表現辞書と文脈規則により、喜・怒・哀・怖など十種の情緒と活性度(感情の高ぶりの度合い)を推定する。二値の極性より細かい情動を扱えるが、口語・掲示板文体を主対象として設計されている。
C 事前学習済み BERT のファインチューニング(3値極性分類) 本プログラムで採用
出典
Devlin, J., Chang, M.-W., Lee, K., Toutanova, K. "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding", NAACL-HLT 2019, pp.4171-4186(arXiv:1810.04805)
モデル
koheiduck/bert-japanese-finetuned-sentiment
(基盤:cl-tohoku/bert-base-japanese-whole-word-masking)
概要
大規模コーパス(大量の文章を集めたデータ)で事前学習(穴埋めなどの課題を大量に解かせ、言語の一般的な性質を先に学ばせる段階)した双方向 Transformer(文の前後両方向を同時に参照する注意機構型のニューラルネットワーク)に分類層を載せ、極性分類用にファインチューニング(学習済みモデルを目的の課題向けに追加学習すること)したうえで、文全体を POSITIVE/NEUTRAL/NEGATIVE に分類する。語順・係り受け(語と語の修飾関係)・否定を含む文脈を踏まえた判定ができる反面、判定根拠は取り出せない。
D 感情強度回帰モデル(WRIME コーパスで学習)
出典
Kajiwara, T. et al. "WRIME: A New Dataset for Emotional Intensity Estimation with Subjective and Objective Annotations", NAACL-HLT 2021/基盤モデルは Yamada, I. et al. "LUKE", EMNLP 2020
モデル
Mizuiro-sakura/luke-japanese-large-sentiment-analysis-wrime 等
概要
書き手の主観と読み手の客観の双方から感情強度を注釈したコーパスで学習し、極性や情動の強さを、分類ではなく回帰(連続値そのものを予測する問題設定)として推定する。
E 大規模言語モデルによるゼロショット/少数事例分類
出典
Brown, T. et al. "Language Models are Few-Shot Learners", NeurIPS 2020
モデル
GPT-4o/Claude/Llama 3/ELYZA-japanese-Llama-2-7b 等
概要
ゼロショット(例を1つも示さず指示文だけで解かせる方式)や少数事例(数個の例だけを示す方式)で分類でき、文語や皮肉にも比較的対応しやすく、判定理由も文章で出力させられる。一方で出力が確率的で再現性の担保が難しい。

3. コードで実装されている手法

本プログラムが実装しているのは、上記の候補のうち 事前学習済み BERT のファインチューニング(手法1)と 評価極性辞書による語彙集計法(手法2)の二つである。同一の文集合に両手法を適用し、ラベル比率・一致率・Cohen のκ係数・混同行列を算出して突き合わせる構成をとる。

手法1:BERT による3値極性分類(AIモデル)

ソースコード
モデル定義と推論は Hugging Face transformersBertForSequenceClassificationgithub.com/huggingface/transformers)。本体からは pipeline("sentiment-analysis", ...) 経由で呼び出す。日本語トークナイザ(文をモデルの入力単位へ分割する処理)BertJapaneseTokenizer は MeCab(形態素解析器)+IPAdic(MeCab 用の標準的な解析辞書)に依存し、その Python 向けの実装である fugashiipadic が必要となる。
学習済み重み
huggingface.co/koheiduck/bert-japanese-finetuned-sentimentpytorch_model.bin 約443MB)。config.json には基盤モデルとして cl-tohoku/bert-base-japanese-whole-word-masking が記録され、12層・隠れ次元(内部で扱う表現ベクトルの次元数)768・語彙3.2万・最大系列長(一度に入力できるトークン数の上限)512、ラベル対応は 0:NEUTRAL/1:NEGATIVE/2:POSITIVE である。
事前学習データ
日本語版 Wikipedia(2019年9月1日時点のダンプ(その時点の全記事を一括で書き出したデータ)、約1,700万文)。基盤モデルの提供元である東北大学 乾・鈴木研究室が公開している条件による。
微調整データ
非公表。

手法2:日本語評価極性辞書の集計(語彙資源)

資源の所在
東北大学 乾・岡崎研究室「日本語評価極性辞書」。用言編(動詞・形容詞など活用する語を集めた編)wago.121808.pn(約5千表現)と名詞編 pn.csv.m3.120408.trim(極性をもつ名詞が約8千5百表現)を www.cl.ecei.tohoku.ac.jp/resources/sent_lex/ から取得する。
データ
人手で極性を付与した語彙リスト。
実装上の処理
MeCab+IPAdic で形態素解析したのち、最大3形態素までの表層連結による最長一致(複数の候補が当たるときは最も長い表現を採る照合方式。単独形態素は基本形も照合)で極性語を抽出する。肯定と否定が衝突する語および中立の語は極性0として照合対象から外す。抽出した極性語の直後3形態素以内にある否定表現の個数を数え、奇数個のときだけ符号を反転させ、合計または平均を文極性値としてしきい値 θ で3値に落とす。

他の候補と比べた特徴

表3-1 実装した二手法の対比
観点手法1:BERT手法2:辞書ベース
文脈の扱い語順・係り受け・否定を含めて文全体を符号化する語の集合として扱い、語順は考慮しない
説明性なぜその判定になったかを取り出せない判定に効いた語をそのまま列挙できる
未知語・比喩サブワード(単語より細かい文字列の単位)分割で処理できるが、妥当性は保証されない辞書にない語は無視される
調整可能性重みは固定。判定結果に影響する設定は最大系列長と確信度のしきい値だけ集計方式・θ・否定処理を利用者が設定できる

プログラム使用上の注意点

  • 領域のずれがある。BERT の事前学習データは現代日本語の Wikipedia であり、微調整データは公開されていない。極性辞書は現代語の評価表現から作られている。いずれも近代文学の語彙・文体を対象として作られた資源ではない。本文取得時に「新字新仮名」版(新字体・現代仮名遣いで表記された版)を優先しているが、これは表記の選択であり、語彙と文体の隔たりは残る。
  • 辞書ベースは NEUTRAL に偏る。極性語が一つも見つからない文は自動的に NEUTRAL となる。NEUTRAL 同士の一致で全体の一致率が押し上げられていないか、混同行列で必ず確認する。
  • 否定処理は単純な発見的規則(経験則にもとづく簡便な規則)である。極性語の後方3形態素以内にある「ない・ぬ・ん・ず・まい・無い・無し」の個数を数え、奇数個のときだけ符号を反転する。文末記号や鉤括弧を越えた否定は数えないため、この窓の外にある否定・反語・皮肉・「〜ないでもない」などは正しく扱えない。
  • 会話文の判定は表層的である。「」の有無だけで会話文とみなすため、引用や心内語も会話文に含まれる。
  • 文分割は「。」を基準とする。鉤括弧内の「。」では分割しない処理はあるが、『』や入れ子の引用には対応していない。「。」を含まない行は、行全体が1文として扱われる。
  • 長文は切り捨てられる。BERT へ渡す文は最大系列長でサブワード単位に切り詰められるため、長い文の末尾は判定に反映されない。
  • 確信度の下限の既定値は実質的に無効である。3値分類では最大確率が 1/3 を下回らないため、初期値 0.34 では置換がほぼ発生しない。効果を見たい場合は 0.5 以上へ上げる。
  • NEUTRAL を 0 として平均する。移動平均や段落平均では中立文が値を 0 へ引き寄せるため、中立の多い区間は「平坦」に見える。これは感情が平坦であることと同義ではない。

4. プログラムの概要

  • 構成:単一の Python ファイルである。資源の取得、GUI、二手法の推論、集計、作図までを1本に収める。
  • モデル・資源取得:初回実行時に、学習済み BERT(Hugging Face Hub(学習済みモデルを公開・配布するサイト)経由、約443MB)、青空文庫コーパス globis-university/aozorabunko-clean(datasets 経由)、日本語評価極性辞書の用言編・名詞編(東北大サーバから直接)を自動ダウンロードする。保存先はスクリプトと同じディレクトリに固定し、hf_cache/polarity_dict/ に置いて2回目以降は再利用する。
  • 入力:GUI のリストから選択した作家・作品(3作家×10作品=30件、最大3作品)の本文テキストである。画像を扱わないためカメラは用いない。
  • 処理の流れ:本文取得 → 段落と文への分割(鉤括弧内の句点では分割しない)→ 手法1の推論 → 手法2の判定 → 集計 → 作図。
  • 推論デバイス:CUDA が使え、かつ GPU のメモリ(VRAM)が6GB以上のときは GPU、それ以外は CPU で推論する。推論バッチサイズは、使用デバイスのメモリと最大系列長に応じて実効値へ引き下げられることがある。実際に使ったデバイスと実効バッチサイズはログに表示される。
  • 出力(テキスト):ログ欄に、先頭5文の両手法の判定と抽出された極性語、全体のラベル比率、地の文と会話文のラベル分布(BERT)、10区間ごとの POSITIVE/NEGATIVE 比率(BERT)、一致率・不一致率・Cohen のκ係数・3×3の混同行列、確信度0.6未満の文の一覧(確信度の低い順に20件)、文長と確信度の散布図用データ(先頭30文)を順に表示する。2作品以上を選んだ場合は作品間の比較表も表示する。
  • 出力(図):作品ごとに3段のグラフを別ウィンドウに表示する。上段は文単位の極性ラベル(+1/0/−1)の移動平均(直前の一定数の文について平均をとる平滑化)を二手法とも同一の尺度で重ねて描き、中段は段落単位の平均を棒、場面単位の平均を折れ線で描き(いずれも BERT の判定による)、下段は10区間の POSITIVE/NEGATIVE 比率を棒で描く。画像は sentiment_{作家}_{作品}.png として保存する。2作品以上なら比較図 sentiment_comparison.png も保存する。
  • GUI:tkinter で構成し、作品のリストボックス(複数選択)、7本のスライダー、集計方式のラジオボタン、否定処理のチェックボックス、解析実行ボタンと終了ボタン、スクロール付きログ欄を配置する。スライダーの初期値には推奨既定値を設定している。
  • 実行トリガー:「選択作品を解析する」ボタンの押下により、本文取得から作図・保存までを一括実行する。処理中はボタンが無効化され、終了後に再び有効となる。

作品リストと著者の切り分け

  • 作品リストは、青空文庫の図書カードに掲載されている公開中の作品から、3作家各10作品を選んでいる(夏目漱石芥川龍之介太宰治)。
  • データセットからの抽出では、メタ情報の作品名が一致し、かつ図書カードURL に人物ID(夏目漱石 000148、芥川龍之介 000879、太宰治 000035)を含む行を選ぶ。図書カードURL は cards/000148/ のように人物IDを含む。
  • 作品名だけで照合しないのは、同じ題名の作品を別の作家が持つ場合に切り分けられないためである。
  • 同じ作品に複数の版がある場合は、メタ情報の文字遣い種別が「新字新仮名」の版を優先する。該当する版がなければ、最初に見つかった版を用いる。

5. 前準備とプログラム実行手順

Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $c=[Environment]::GetEnvironmentVariable('Path','Machine'); if((Test-Path $p) -and (';'+$c+';' -notlike \"*;$p;*\") -and (';'+$c+';' -notlike \"*;$s;*\")){[Environment]::SetEnvironmentVariable('Path',\"$p;$s;$c\",'Machine')}"

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

Build Tools・CUDA Toolkit・PyTorch のインストール

本章では、C++ ビルドツール、NVIDIA CUDA Toolkit、PyTorch のインストールを行い、GPU を活用した機械学習プログラムを実行する環境を整える。扱う環境は、Windows 搭載パソコンである。

[Build Tools・CUDA Toolkit・PyTorch のインストール手順を見るには、ここをクリック]

Windows での Build Tools for Visual Studio 2026 のインストール

Build Tools for Visual Studio 2026 は、C++ ソースコードを Windows 用バイナリにコンパイルするための開発ツール群である。unsloth 等の一部 Python パッケージは、インストール時に C++ コードのビルドを必要とするため、これらのツールが必須となる。

以下のコマンドは、Build Tools が未インストールの場合は winget で新規インストールし、インストール済みの場合は setup.exe modify でコンポーネントを追加する(バージョンは変更しない)。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM VC++ ランタイム
winget install --scope machine --id Microsoft.VCRedist.2015+.x64 -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "/quiet /norestart"

REM ============================================================
REM Visual Studio Build Tools + Desktop development with C++
REM (VCTools、MSBuildTools、CMake連携、Clang、Windows 11 SDK)
REM ============================================================
REM 進行中のインストーラーを停止(ロック競合回避)
taskkill /F /IM vs_setup.exe /T >nul 2>&1
taskkill /F /IM vs_installer.exe /T >nul 2>&1
taskkill /F /IM vs_installerservice.exe /T >nul 2>&1

REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --includeRecommended --quiet --norestart --nocache
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100"
)

REM 破損時の修復(任意、動作がおかしくなった場合)
REM "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" repair --installPath "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools" --quiet --norestart

REM 導入確認(インストールパスが表示されれば正常)
"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products * -requires Microsoft.VisualStudio.Workload.VCTools -property installationPath

上記のコマンドでは、Build Tools 本体と Visual C++ 再頒布可能パッケージをインストールし、続いて以下のコンポーネントを追加している。

  • VCTools:C++ デスクトップ開発ワークロード(--includeRecommended により、MSVC コンパイラ、C++ AddressSanitizer、vcpkg、CMake ツール、Windows 11 SDK 等の推奨コンポーネントが含まれる)
  • MSBuildTools:MSBuild によるビルドツールのワークロード
  • VC.CMake.Project:Windows 向け C++ CMake ツール
  • VC.Llvm.Clang:Windows 向け C++ Clang コンパイラ
  • VC.Llvm.ClangToolset:MSBuild から Clang を使用するための clang-cl ツールセット
  • Windows11SDK.26100:Windows 11 SDK(ビルド 10.0.26100)

追加のコンポーネントが必要になった場合は Visual Studio Installer で個別にインストールできる。

Windows での NVIDIA CUDA Toolkit のインストール

NVIDIA CUDA Toolkit は、NVIDIA GPU 上で計算を行うためのコンパイラ・ライブラリ群である。PyTorch や vLLM 等が GPU を利用するために必要となる。GPU を使用しない場合、この手順は不要である。

前提条件:NVIDIA GPU、NVIDIA ドライバ、Build Tools for Visual Studio もしくは Visual Studio が必要である。

インストール中の注意:他のウインドウは閉じておくこと。

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

REM NVIDIA CUDA Toolkit 12.8 をシステム領域にインストール
winget install --scope machine --id Nvidia.CUDA --version 12.8 -e --silent --disable-interactivity --force --uninstall-previous --accept-source-agreements --accept-package-agreements --override "-s -n"

REM 環境変数TEMP, TMPの設定(一時ファイルの保存先を短いパスに変更)
mkdir C:\TEMP
setx TEMP "C:\TEMP" /M
setx TMP "C:\TEMP" /M

環境変数 TEMP および TMP を C:\TEMP に変更しているのは、後続のインストール処理で長いパス名や空白を含むパス名がエラーの原因となる場合があるためである。

Windows での PyTorch のインストール

https://pytorch.org のインストールガイドに従い、自環境の CUDA バージョンに対応したコマンドを取得して実行する。CUDA バージョンは以下で確認できる。

nvcc --version

Python 3.12、CUDA 12.6 以上の場合は、管理者権限コマンドプロンプトを起動し、以下を実行する。cu128 は CUDA 12.8 用のタグである。CUDA バージョンが異なる場合は、上記公式サイトで該当するタグを確認し、URL 末尾の cu128 を置き換えること。

pip install --no-user -U numpy torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128

以降の章では、必要に応じて題材に応じた必要なソフトウェアを追加する。

Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
winget uninstall -e --id Microsoft.VisualStudioCode --silent --disable-interactivity --accept-source-agreements
rmdir /s /q C:\ProgramData\vscode-extensions 2>nul
rmdir /s /q "%APPDATA%\Code" 2>nul
rmdir /s /q "%USERPROFILE%\.vscode" 2>nul
rmdir /s /q "%LOCALAPPDATA%\Microsoft\vscode-update" 2>nul

REM VS Code をシステム領域に新規インストール
winget install --scope machine --id Microsoft.VisualStudioCode -e --silent --accept-source-agreements --accept-package-agreements

REM 全ユーザー共有の拡張機能フォルダ
mkdir C:\ProgramData\vscode-extensions 2>nul
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" 2>nul
del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" 2>nul
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

Python プログラム実行手順

[Windows での Python プログラム実行手順を見るには、ここをクリック]

Windows での Python 実行手順(Visual Studio Codeを使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

必要なライブラリのインストール

管理者権限コマンドプロンプトを起動する (手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。

次のコマンドを実行し、関連ライブラリをインストールする。

pip install -U --no-user transformers datasets fugashi ipadic matplotlib

Python と PyTorch も別途必要である。

操作手順

  • 初回実行時は学習済みモデル・コーパス・極性辞書を自動ダウンロードするため、インターネット接続が必要である。数百 MB の通信と数分の待ち時間が発生する。コンソールに「準備完了」と表示されるまで待つ。
  • GUI が開いたら、左のリストから作品を選ぶ。初期状態では芥川龍之介『羅生門』が選択されている。Ctrl キー(Mac では Command キー)を押しながらのクリックで複数選択できる。選ぶのは3作品までとする(選択自体は制限されず、4件以上を選ぶとリスト上位の3件だけが処理される)。
  • 共通パラメータで解析文数の上限を決める。CPU のみの環境では600文で数分かかるため、まず200〜300文で試す。
  • 手法1のパラメータ(最大系列長・推論バッチサイズ・確信度の下限)を設定する。GPU がない場合はバッチサイズを8〜16へ下げる。
  • 手法2のパラメータ(集計方式・しきい値 θ・否定反転)を設定する。最初は既定値(合計・θ=0.50・反転あり)のまま実行する。
  • 「選択作品を解析する」ボタンを押す。ボタンを連打しないこと。ログ欄に進捗が逐次表示される。
  • ログの一致率・κ係数・混同行列を確認し、別ウィンドウに開いたグラフを読む。画像はスクリプトと同じディレクトリに自動保存される。
  • θ や集計方式を変えて同じ作品を再実行し、辞書ベース側の NEUTRAL 比率と一致率がどう動くかを比べる。集計方式が合計のときは、θ を 1.00 以下の範囲で動かしても判定は変わらない(理由は 8.3 に示す)。
  • 作品を2〜3件選び直して実行し、同じ傾向が別の作品でも再現するかを確かめる。再現すれば、単一作品の結果が偶然でないことの傍証となる。

6. GUI で設定可能なパラメータ

表6-1 GUI 上の設定項目(上から画面の配置順)
GUI の表示名 学術的名称 初期値 範囲(刻み) 説明
解析する作品 解析対象作品 羅生門 30作品/最大3件 作家と作品の組で指定する。2件以上を選ぶと作品間比較の表と図が追加される。4件以上を選んだ場合は先頭3件のみが処理される。
解析文数の上限 解析対象文数の上限 N 600 100–3000(100) 作品冒頭からこの文数だけを解析する。処理時間にほぼ比例するため、実行時間を決める最大の要因である。
移動平均の窓幅 単純移動平均の窓幅 w[文] 20 5–100(5) 上段グラフの平滑化幅。小さいと文ごとの揺れが残り、大きいと長い区間の傾向だけが残る。後方 w 文の平均をとる。
1場面あたりの段落数 場面の粒度[段落] 10 1–30(1) 中段グラフで、平均極性を求める際に束ねる段落の数。大きくすると場面単位の折れ線がなだらかになる。
最大系列長 最大系列長 max_length[サブワード] 512 128–512(64) BERT に入力するサブワード数の上限。超過分は切り捨てられる。512 はこのモデルの位置埋め込み(何番目の語かを表す内部表現)の上限であり、それ以上には設定できない。
推論バッチサイズ バッチサイズ batch_size 32 8–64(8) 一度にまとめて推論する文数。速度とメモリ使用量のトレードオフを決めるだけで、判定結果そのものは変わらない。メモリ不足時は下げる。使用デバイスのメモリと最大系列長によっては、選択値より小さい実効値へ自動的に引き下げられ、その旨がログに表示される。
NEUTRAL とみなす確信度の下限 確信度しきい値(softmax(出力を合計1の確率へ変換する関数)の最大確率) 0.34 0.34–0.95(0.01) 最大確率がこの値を下回る判定を NEUTRAL に置き換える。3値分類では最大確率が 1/3 を下回らないため、初期値 0.34 では実質的に置換が起きない。
文極性値の集計方式 極性値の集計関数(sum/mean) 合計 合計/平均 合計は極性語の数に比例して値が伸び、長い文ほど極端になる。平均は1語あたりの値となり値域が ±1 に収まるため、θ の意味が大きく変わる。
NEUTRAL とみなす |文極性値| の上限 θ 判定しきい値 θ 0.50 0.0–2.0(0.05) 文極性値の絶対値が θ 未満の文を NEUTRAL とする。大きくするほど中立が増え、極性判定は保守的になる。平均方式では θ>1.0 とすると全文が NEUTRAL になる。
否定表現による極性反転 否定スコープ(否定が及ぶ範囲)の処理 ON ON/OFF 極性語の後方3形態素以内にある「ない・ぬ・ん・ず・まい・無い・無し」の個数が奇数のとき、符号を反転する。OFF にすると否定文の極性がそのまま加算される。

スライダーはいずれも刻み幅つきの tk.Scale で実装されている。スライダー・ラジオボタン・チェックボックスの値は、いずれもボタン押下時に読み取られる。実行中に動かしても、その回の解析には反映されない。

7. プログラムコード

事前準備:pip install -U --no-user transformers datasets fugashi ipadic matplotlib


# -*- coding: utf-8 -*-
"""
================================================================================
日本語文学作品の感情分析ツール
  ── 学習済みニューラルモデル(BERT)と日本語評価極性辞書の比較 ──
================================================================================
【目的】青空文庫の作品本文を文単位で3値(POSITIVE / NEUTRAL / NEGATIVE)に分類し、
  原理の異なる2手法の判定を突き合わせて、作品内の感情推移と手法間の一致度
  (一致率・Cohen のκ係数・混同行列)を定量化する。正解ラベルは存在しないため、
  一致度は「精度」ではなく「手法間の整合性」を表す指標である。

【解析対象コーパス】globis-university/aozorabunko-clean(青空文庫クリーニング済み)

【動作】学習済みモデル・極性辞書・コーパスは初回実行時に自動取得し、以後は本スクリプト
  と同じディレクトリのキャッシュを再利用する。入力はテキストのみでカメラは用いない。
  GUI(Tkinter)で作品と各手法の主要パラメータを指定し、ボタン押下により本文取得 →
  文分割 → 2手法の判定 → 集計 → グラフ表示・保存までを一括実行する。

【修正方針】エラーチェックや例外処理を追加するのではなく、停止や不正確化につながる
  条件を処理本体の論理で吸収する。
================================================================================
"""

import os

# 本スクリプトが置かれているディレクトリ(カレントディレクトリではない)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

# 学習済みモデルおよびコーパスのキャッシュ先をスクリプトと同じ場所に固定する。
# (transformers / datasets を import する前に設定する必要がある)
os.environ["HF_HOME"] = os.path.join(BASE_DIR, "hf_cache")

import urllib.request
import tkinter as tk
from tkinter import scrolledtext
from collections import Counter

import torch
torch.set_grad_enabled(False)

import matplotlib
from matplotlib import font_manager
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg

import fugashi
import ipadic
from datasets import load_dataset
from transformers import pipeline

# ------------------------------------------------------------
# 0. matplotlib の日本語フォント設定(文字化け防止)
# ------------------------------------------------------------
JP_FONT_CANDIDATES = [
    "Meiryo", "Yu Gothic", "MS Gothic", "MS PGothic",   # Windows
    "Hiragino Sans",                                    # macOS
    "Noto Sans CJK JP", "IPAexGothic"                   # Linux
]
INSTALLED_FONTS = {f.name for f in font_manager.fontManager.ttflist}
JP_FONTS = [name for name in JP_FONT_CANDIDATES if name in INSTALLED_FONTS]
if JP_FONTS:
    matplotlib.rcParams["font.family"] = JP_FONTS
else:
    print("日本語フォントが見つかりません。グラフの日本語が文字化けします。")
matplotlib.rcParams["axes.unicode_minus"] = False

LABELS = ("POSITIVE", "NEUTRAL", "NEGATIVE")
LABEL_VALUE = {"POSITIVE": 1.0, "NEUTRAL": 0.0, "NEGATIVE": -1.0}

MODEL_NAME = "koheiduck/bert-japanese-finetuned-sentiment"

# 低VRAM GPUで推論中に止まりやすい条件を避けるための推論計画。
# 例外処理ではなく、使用デバイスとバッチサイズを事前に決める。
CUDA_MIN_MEMORY_GB = 6.0

BERT_LABEL_ALIASES = {
    "POSITIVE": "POSITIVE",
    "Positive": "POSITIVE",
    "positive": "POSITIVE",
    "ポジティブ": "POSITIVE",
    "肯定": "POSITIVE",
    "LABEL_2": "POSITIVE",
    "label_2": "POSITIVE",
    "2": "POSITIVE",

    "NEUTRAL": "NEUTRAL",
    "Neutral": "NEUTRAL",
    "neutral": "NEUTRAL",
    "ニュートラル": "NEUTRAL",
    "中立": "NEUTRAL",
    "LABEL_0": "NEUTRAL",
    "label_0": "NEUTRAL",
    "0": "NEUTRAL",

    "NEGATIVE": "NEGATIVE",
    "Negative": "NEGATIVE",
    "negative": "NEGATIVE",
    "ネガティブ": "NEGATIVE",
    "否定": "NEGATIVE",
    "LABEL_1": "NEGATIVE",
    "label_1": "NEGATIVE",
    "1": "NEGATIVE",
}


def canonical_bert_label(label):
    """BERT/pipeline が返すラベル表記を、本プログラム内の3値ラベルへそろえる。"""
    return BERT_LABEL_ALIASES[str(label).strip()]


def select_pipeline_device():
    """
    推論に使うデバイスを決める。
    低VRAM GPUでは、モデルは載ってもバッチ推論で停止しやすいため CPU を使う。
    """
    if torch.cuda.is_available():
        total_gb = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
        if total_gb >= CUDA_MIN_MEMORY_GB:
            return 0
    return -1


def pipeline_device_name():
    """ログ表示用のデバイス名。"""
    if PIPELINE_DEVICE >= 0:
        props = torch.cuda.get_device_properties(PIPELINE_DEVICE)
        total_gb = props.total_memory / (1024 ** 3)
        return f"CUDA:{PIPELINE_DEVICE} {props.name} ({total_gb:.1f}GB)"
    return "CPU"


def effective_batch_size(requested, max_length):
    """
    GUIで選ばれたバッチサイズを、使用デバイスと系列長に合わせて実効値へ変換する。
    判定結果はバッチサイズに依存しないため、メモリ条件だけで調整する。
    """
    requested = int(requested)

    if PIPELINE_DEVICE < 0:
        return min(requested, 32) if max_length >= 384 else requested

    total_gb = torch.cuda.get_device_properties(PIPELINE_DEVICE).total_memory / (1024 ** 3)

    if max_length >= 384:
        if total_gb < 8.0:
            return min(requested, 8)
        if total_gb < 12.0:
            return min(requested, 16)
    else:
        if total_gb < 8.0:
            return min(requested, 16)
        if total_gb < 12.0:
            return min(requested, 32)

    return requested


# ------------------------------------------------------------
# 1. 作品リスト(作家・作品名の組み合わせで指定する)
# ------------------------------------------------------------
AUTHOR_WORKS = {
    "夏目漱石": [
        "こころ", "吾輩は猫である", "坊っちゃん", "草枕", "三四郎",
        "それから", "門", "彼岸過迄", "行人", "夢十夜",
    ],
    "芥川龍之介": [
        "羅生門", "鼻", "蜘蛛の糸", "地獄変", "藪の中",
        "河童", "杜子春", "トロッコ", "或阿呆の一生", "歯車",
    ],
    "太宰治": [
        "人間失格", "走れメロス", "斜陽", "津軽", "ヴィヨンの妻",
        "富嶽百景", "女生徒", "駈込み訴え", "お伽草紙", "トカトントン",
    ],
}

AUTHOR_ID = {"夏目漱石": "000148", "芥川龍之介": "000879", "太宰治": "000035"}

WORK_ITEMS = [(a, t) for a, ts in AUTHOR_WORKS.items() for t in ts]


def normalize_work_title(title):
    """作品名照合用に空白差を吸収する。"""
    return "".join(str(title).replace(" ", " ").split())


def build_work_index():
    """
    データセット内の作品を、(作家名, 正規化作品名) から参照できる索引にする。
    同一作品に複数版がある場合は、新字新仮名を優先する。
    """
    index = {}
    for i, meta in enumerate(METAS):
        card_url = meta["図書カードURL"]
        title_key = normalize_work_title(meta["作品名"])

        for author_name, person_id in AUTHOR_ID.items():
            if f"/cards/{person_id}/" in card_url:
                key = (author_name, title_key)
                current = index.get(key)

                if current is None:
                    index[key] = i
                else:
                    current_is_modern = METAS[current]["文字遣い種別"] == "新字新仮名"
                    new_is_modern = meta["文字遣い種別"] == "新字新仮名"
                    if new_is_modern and not current_is_modern:
                        index[key] = i
    return index


# ------------------------------------------------------------
# 2. 感情極性辞書(東北大学 乾・岡崎研究室)の自動取得と読み込み
# ------------------------------------------------------------
DICT_DIR = os.path.join(BASE_DIR, "polarity_dict")
WAGO_URL = "https://www.cl.ecei.tohoku.ac.jp/resources/sent_lex/wago.121808.pn"
NOUN_URL = "https://www.cl.ecei.tohoku.ac.jp/resources/sent_lex/pn.csv.m3.120408.trim"

NEGATION_LEMMAS = {"ない", "ぬ", "ん", "ず", "まい", "無い", "無し"}
NEGATION_WINDOW = 3
MAX_NGRAM = 3

# 否定表現の探索を打ち切る境界。
# 読点「、」は「良くは、ない」のような形があるため境界にしない。
NEGATION_BOUNDARIES = {
    "。", ".", ".", "!", "!", "?", "?",
    ";", ";", "「", "」", "『", "』", "…"
}


def fetch_tsv(url):
    """未取得ならダウンロードし、タブ区切りの各行を列のリストとして返す。"""
    os.makedirs(DICT_DIR, exist_ok=True)
    path = os.path.join(DICT_DIR, url.rsplit("/", 1)[1])
    if not os.path.exists(path):
        print(f"ダウンロード中: {url}")
        urllib.request.urlretrieve(url, path)

    with open(path, encoding="utf-8-sig") as f:
        return [line.rstrip("\n").split("\t") for line in f if "\t" in line]


def noun_polarity(field):
    """
    名詞編の極性欄を +1 / 0 / -1 に変換する。
    複数候補を持つ表記は単一極性にしない。
    """
    field = field.strip()
    kinds = {c for c in field if c in "pne"}
    if len(kinds) != 1:
        return None
    return {"p": 1, "n": -1, "e": 0}[kinds.pop()]


def load_polarity_dict():
    """
    日本語評価極性辞書を読み込み、{表記: 極性値(+1/0/-1)} の対応表を返す。

    用言編はそのまま採用する。
    名詞編は同一見出しに複数語義があるため、表層ごとに極性を集約する。
    肯定・否定が衝突する語は 0 とし、判定用の照合対象からは外す。
    """
    table = {}
    wago_sign = {"ポジ": 1, "ネガ": -1}

    for label, phrase, *_ in fetch_tsv(WAGO_URL):
        label_key = label.strip()[:2]
        phrase_key = phrase.replace(" ", "").replace(" ", "")
        table[phrase_key] = wago_sign[label_key]

    noun_values = {}
    for word, polarity, *_ in fetch_tsv(NOUN_URL):
        value = noun_polarity(polarity)
        if value is None:
            continue
        word_key = word.replace(" ", "").replace(" ", "")
        noun_values.setdefault(word_key, set()).add(value)

    for word, values in noun_values.items():
        if word in table:
            continue

        nonzero_values = {v for v in values if v != 0}
        if len(nonzero_values) == 1:
            table[word] = next(iter(nonzero_values))
        else:
            table[word] = 0

    return table


def make_active_polarity_dict(table):
    """
    実際の文極性判定に使う辞書。
    極性0の語は、最長一致で肯定語・否定語を隠す原因になるため照合対象にしない。
    """
    return {word: value for word, value in table.items() if value != 0}


# ------------------------------------------------------------
# 3. 形態素解析(MeCab + IPAdic)と辞書ベース感情分析
# ------------------------------------------------------------
def tokenize(sentence):
    """(表層形, 基本形) の並びを返す。基本形が未定義(*)の語は表層形で代用する。"""
    return [
        (w.surface, w.feature[6] if w.feature[6] != "*" else w.surface)
        for w in TAGGER(sentence)
    ]


def negation_should_flip(tokens, start):
    """
    極性語の直後 NEGATION_WINDOW 形態素以内を見て、否定表現が奇数個なら反転する。
    文末記号や鉤括弧を越えた否定は拾わない。
    """
    negation_count = 0
    checked = 0
    j = start

    while j < len(tokens) and checked < NEGATION_WINDOW:
        surface, lemma = tokens[j]

        if surface in NEGATION_BOUNDARIES:
            break

        if lemma in NEGATION_LEMMAS:
            negation_count += 1

        checked += 1
        j += 1

    return negation_count % 2 == 1


def match_polarity_words(tokens, use_negation):
    """
    最長一致(最大3形態素の表層連結、単独形態素は基本形も参照)で極性語を抽出する。
    極性0の語は照合対象にしない。
    """
    hits = []
    i = 0

    while i < len(tokens):
        key, width = None, 1

        for w in range(min(MAX_NGRAM, len(tokens) - i), 0, -1):
            surface = "".join(t[0] for t in tokens[i:i + w])
            if surface in ACTIVE_POLARITY_DICT:
                key, width = surface, w
                break

        if key is None and tokens[i][1] in ACTIVE_POLARITY_DICT:
            key = tokens[i][1]

        if key is None:
            i += 1
            continue

        value = ACTIVE_POLARITY_DICT[key]

        if use_negation and negation_should_flip(tokens, i + width):
            value = -value

        hits.append((key, value))
        i += width

    return hits


def dict_classify(sentence, aggregate, threshold, use_negation):
    """
    辞書ベース手法による1文の判定。
      aggregate : "合計" または "平均"
      threshold : |文極性値| がこの値未満なら NEUTRAL
    戻り値 : (ラベル, 文極性値, 抽出された極性語のリスト)
    """
    hits = match_polarity_words(tokenize(sentence), use_negation)
    values = [v for _, v in hits]

    if not values:
        return "NEUTRAL", 0.0, hits

    value = sum(values) / len(values) if aggregate == "平均" else sum(values)

    if value > 0 and value >= threshold:
        return "POSITIVE", value, hits
    if value < 0 and value <= -threshold:
        return "NEGATIVE", value, hits
    return "NEUTRAL", value, hits


# ------------------------------------------------------------
# 4. 作品名+人物ID(図書カードURL)から本文を取得する
# ------------------------------------------------------------
def find_work_text(author_name, work_title):
    """作家名と作品名から、データセット内の該当作品の本文を返す。"""
    key = (author_name, normalize_work_title(work_title))
    return DATASET[WORK_INDEX[key]]["text"]


# ------------------------------------------------------------
# 5. 文分割(会話文「」内の句点では分割しない)+段落番号の付与
# ------------------------------------------------------------
def split_sentences_with_paragraph(text):
    """(段落番号, 文) のリストを返す。段落は本文中の改行で区切られる。"""
    items = []
    paragraphs = [p for p in text.splitlines() if p.strip()]

    for paragraph_no, paragraph in enumerate(paragraphs):
        buffer, in_quote = "", False

        for ch in paragraph:
            buffer += ch

            if ch == "「":
                in_quote = True
            elif ch == "」":
                in_quote = False
            elif ch == "。" and not in_quote:
                if len(buffer.strip()) > 1:
                    items.append((paragraph_no, buffer.strip()))
                buffer = ""

        if len(buffer.strip()) > 1:
            items.append((paragraph_no, buffer.strip()))

    return items


def is_dialogue(sentence):
    """会話文(「」を含む)かどうかを判定する。"""
    return "「" in sentence and "」" in sentence


# ------------------------------------------------------------
# 6. 一致率・Cohen のκ係数・混同行列
# ------------------------------------------------------------
def agreement_stats(labels_a, labels_b):
    """
    2系列のラベルの一致率・κ係数・混同行列・一致文数を返す。
    κ = (Po - Pe) / (1 - Pe)
    """
    n = len(labels_a)
    matched = sum(1 for a, b in zip(labels_a, labels_b) if a == b)
    po = matched / n

    count_a, count_b = Counter(labels_a), Counter(labels_b)
    pe = sum(count_a[lb] * count_b[lb] for lb in LABELS) / (n * n)

    kappa = (po - pe) / (1.0 - pe) if pe < 1.0 else 1.0

    return po, kappa, Counter(zip(labels_a, labels_b)), matched


def moving_average(values, window):
    """後方 window 個の単純移動平均。"""
    return [
        sum(values[max(0, i - window + 1):i + 1]) / min(i + 1, window)
        for i in range(len(values))
    ]


def bucket_ratios(results, key, bucket_count=10):
    """文の出現順を bucket_count 等分し、区間ごとの POSITIVE/NEGATIVE 比率(%)を返す。"""
    size = max(1, -(-len(results) // bucket_count))
    starts, pos_ratio, neg_ratio = [], [], []

    for begin in range(0, len(results), size):
        segment = results[begin:begin + size]
        starts.append(begin)
        pos_ratio.append(sum(1 for r in segment if r[key] == "POSITIVE") / len(segment) * 100)
        neg_ratio.append(sum(1 for r in segment if r[key] == "NEGATIVE") / len(segment) * 100)

    return starts, pos_ratio, neg_ratio, size


# ------------------------------------------------------------
# 7. アプリケーション本体(GUI)
# ------------------------------------------------------------
class SentimentApp:

    def __init__(self, root):
        self.root = root
        self.root.title("日本語文学作品の感情分析 ── BERT と 日本語評価極性辞書 の比較")
        self.analyzed = []
        self.current_params = {}
        self.running = False
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.build_widgets()

    # -------- GUI の構築 --------
    def build_widgets(self):
        top = tk.Frame(self.root)
        top.pack(side=tk.TOP, fill=tk.X, padx=8, pady=6)

        left = tk.LabelFrame(top, text="解析する作品(Ctrlキーで最大3作品まで複数選択可)")
        left.pack(side=tk.LEFT, fill=tk.Y)

        self.listbox = tk.Listbox(
            left,
            selectmode=tk.EXTENDED,
            width=26,
            height=14,
            exportselection=False
        )
        for author, title in WORK_ITEMS:
            self.listbox.insert(tk.END, f"{author}/{title}")
        self.listbox.selection_set(WORK_ITEMS.index(("芥川龍之介", "羅生門")))
        self.listbox.pack(side=tk.LEFT, padx=4, pady=4)

        scroll = tk.Scrollbar(left, command=self.listbox.yview)
        scroll.pack(side=tk.LEFT, fill=tk.Y)
        self.listbox.config(yscrollcommand=scroll.set)

        right = tk.Frame(top)
        right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=8)

        common = tk.LabelFrame(right, text="共通パラメータ")
        common.pack(side=tk.TOP, fill=tk.X)
        self.sc_max_sent = self.make_scale(common, "解析文数の上限(作品冒頭から)",
                                           100, 3000, 100, 600)
        self.sc_window = self.make_scale(common, "移動平均の窓幅 [文]", 5, 100, 5, 20)
        self.sc_scene = self.make_scale(common, "1場面あたりの段落数", 1, 30, 1, 10)

        bert = tk.LabelFrame(right, text="手法1:BERT(koheiduck/bert-japanese-finetuned-sentiment)")
        bert.pack(side=tk.TOP, fill=tk.X, pady=4)
        self.sc_maxlen = self.make_scale(bert, "最大系列長 [サブワード]", 128, 512, 64, 512)
        self.sc_batch = self.make_scale(bert, "推論バッチサイズ", 8, 64, 8, 32)
        self.sc_conf = self.make_scale(
            bert,
            "NEUTRAL とみなす確信度の下限(0.34=再判定なしと等価)",
            0.34, 0.95, 0.01, 0.34
        )

        lex = tk.LabelFrame(right, text="手法2:辞書ベース(日本語評価極性辞書 用言編・名詞編)")
        lex.pack(side=tk.TOP, fill=tk.X)

        agg_frame = tk.Frame(lex)
        agg_frame.pack(side=tk.TOP, anchor=tk.W)

        tk.Label(agg_frame, text="文極性値の集計方式:").pack(side=tk.LEFT)
        self.aggregate = tk.StringVar(value="合計")
        for mode in ("合計", "平均"):
            tk.Radiobutton(agg_frame, text=mode, variable=self.aggregate,
                           value=mode).pack(side=tk.LEFT)

        self.sc_theta = self.make_scale(lex, "NEUTRAL とみなす |文極性値| の上限 θ",
                                        0.0, 2.0, 0.05, 0.5)

        self.use_negation = tk.BooleanVar(value=True)
        tk.Checkbutton(
            lex,
            text="否定表現(ない・ぬ・ず 等)による極性反転を行う",
            variable=self.use_negation
        ).pack(side=tk.TOP, anchor=tk.W)

        buttons = tk.Frame(self.root)
        buttons.pack(side=tk.TOP, fill=tk.X, padx=8)

        self.btn_run = tk.Button(buttons, text="選択作品を解析する",
                                 width=24, command=self.on_run)
        self.btn_run.pack(side=tk.LEFT, padx=4, pady=4)

        self.btn_exit = tk.Button(buttons, text="終了", width=10,
                                  command=self.on_close)
        self.btn_exit.pack(side=tk.RIGHT, padx=4)

        self.text = scrolledtext.ScrolledText(
            self.root,
            width=118,
            height=26,
            font=("MS Gothic", 9)
        )
        self.text.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=6)

    def make_scale(self, parent, label, lower, upper, step, default):
        scale = tk.Scale(
            parent,
            label=label,
            from_=lower,
            to=upper,
            resolution=step,
            orient=tk.HORIZONTAL,
            length=460
        )
        scale.set(default)
        scale.pack(side=tk.TOP, anchor=tk.W)
        return scale

    def on_close(self):
        """実行中はウィンドウ破棄を行わない。"""
        if self.running:
            return
        self.root.destroy()

    def log(self, message=""):
        self.text.insert(tk.END, message + "\n")
        self.text.see(tk.END)
        self.root.update_idletasks()
        print(message)

    def snapshot_parameters(self):
        """
        解析開始時点の GUI 値を固定する。
        実行中にスライダー等を動かしても、その回の解析には反映しない。
        """
        return {
            "max_sent": int(self.sc_max_sent.get()),
            "window": int(self.sc_window.get()),
            "scene": int(self.sc_scene.get()),
            "maxlen": int(self.sc_maxlen.get()),
            "batch_requested": int(self.sc_batch.get()),
            "confidence_floor": float(self.sc_conf.get()),
            "aggregate": self.aggregate.get(),
            "theta": float(self.sc_theta.get()),
            "use_negation": bool(self.use_negation.get()),
        }

    # -------- 解析の実行 --------
    def on_run(self):
        self.running = True
        self.btn_run.config(state=tk.DISABLED)
        self.btn_exit.config(state=tk.DISABLED)

        self.text.delete("1.0", tk.END)
        self.analyzed = []
        self.current_params = self.snapshot_parameters()

        selected_indices = self.listbox.curselection()[:3]

        for index in selected_indices:
            author, title = WORK_ITEMS[index]
            results = self.analyze_work(author, title)
            self.analyzed.append((author, title, results))
            self.report_single(author, title, results)
            self.show_work_figure(author, title, results)

        if len(self.analyzed) >= 2:
            self.report_comparison()
            self.show_comparison_figure()

        self.running = False
        self.btn_run.config(state=tk.NORMAL)
        self.btn_exit.config(state=tk.NORMAL)
        self.log("\n解析が完了しました。")

    def analyze_work(self, author, title):
        self.log("=" * 100)
        self.log(f"■ 解析対象: {author}『{title}』")

        items = split_sentences_with_paragraph(find_work_text(author, title))
        limit = self.current_params["max_sent"]

        self.log(f"  作品全体の文数: {len(items)}")
        if len(items) > limit:
            items = items[:limit]
            self.log(f"  ※ 上限設定により、作品冒頭から {limit} 文のみを解析対象とします。")

        sentences = [s for _, s in items]

        max_length = self.current_params["maxlen"]
        requested_batch_size = self.current_params["batch_requested"]
        batch_size = effective_batch_size(requested_batch_size, max_length)
        confidence_floor = self.current_params["confidence_floor"]

        self.log(
            f"  BERT 推論設定: device={pipeline_device_name()}, "
            f"max_length={max_length}, batch_size={batch_size}"
        )
        if batch_size != requested_batch_size:
            self.log(
                f"  ※ GUI選択値 {requested_batch_size} は、使用デバイスに合わせて "
                f"実効値 {batch_size} で処理します。"
            )

        self.log(f"  手法1(BERT)推論中 … 文数 {len(sentences)}")

        bert_labels, bert_scores = [], []
        for begin in range(0, len(sentences), batch_size):
            predictions = CLASSIFIER(
                sentences[begin:begin + batch_size],
                batch_size=batch_size,
                truncation=True,
                max_length=max_length
            )

            for prediction in predictions:
                raw_label = canonical_bert_label(prediction["label"])
                label = raw_label if prediction["score"] >= confidence_floor else "NEUTRAL"
                bert_labels.append(label)
                bert_scores.append(prediction["score"])

            if (begin // batch_size) % 10 == 0:
                self.log(f"    {min(begin + batch_size, len(sentences))} / {len(sentences)} 文")

        self.log("  手法2(辞書ベース)判定中 …")

        aggregate = self.current_params["aggregate"]
        theta = self.current_params["theta"]
        use_negation = self.current_params["use_negation"]

        results = []
        for (paragraph_no, sentence), label, score in zip(items, bert_labels, bert_scores):
            dict_label, dict_value, hits = dict_classify(sentence, aggregate, theta, use_negation)
            results.append({
                "paragraph": paragraph_no,
                "text": sentence,
                "is_dialogue": is_dialogue(sentence),
                "bert_label": label,
                "bert_score": score,
                "dict_label": dict_label,
                "dict_value": dict_value,
                "dict_hits": hits,
            })

        return results

    # -------- 集計結果の出力 --------
    def report_single(self, author, title, results):
        n = len(results)

        self.log("\n--- 先頭5文の判定結果(BERT / 辞書ベース) ---")
        for r in results[:5]:
            kind = "会話文" if r["is_dialogue"] else "地の文"
            words = "・".join(w for w, v in r["dict_hits"])
            self.log(
                f"  [{kind}] BERT={r['bert_label']:8s}(確信度 {r['bert_score']:.3f}) "
                f"辞書={r['dict_label']:8s}(極性値 {r['dict_value']:+.2f}) : "
                f"{r['text'][:30]}..."
            )
            self.log(f"           抽出された極性語: {words if words else '(なし)'}")

        self.log("\n--- 全体のラベル比率 ---")
        self.log(f"  {'ラベル':<10s}{'BERT':>18s}{'辞書ベース':>20s}")

        bert_counter = Counter(r["bert_label"] for r in results)
        dict_counter = Counter(r["dict_label"] for r in results)

        for label in LABELS:
            b, d = bert_counter[label], dict_counter[label]
            self.log(
                f"  {label:<10s}{b:8d} 文 ({b / n * 100:5.1f}%)"
                f"{d:9d} 文 ({d / n * 100:5.1f}%)"
            )

        self.log("\n--- 地の文 vs 会話文のラベル分布(BERT) ---")
        for kind, flag in (("地の文", False), ("会話文", True)):
            subset = [r for r in results if r["is_dialogue"] == flag]
            if not subset:
                self.log(f"  {kind}(0文): 該当なし")
                continue

            counter = Counter(r["bert_label"] for r in subset)
            detail = ", ".join(
                f"{lb}={counter[lb] / len(subset) * 100:.1f}%"
                for lb in LABELS
            )
            self.log(f"  {kind}({len(subset)}文): {detail}")

        starts, pos_ratio, neg_ratio, _ = bucket_ratios(results, "bert_label")
        self.log("\n--- 感情推移(解析対象範囲を先頭→末尾へ10分割した POSITIVE / NEGATIVE 比率、BERT) ---")
        for begin, pos, neg in zip(starts, pos_ratio, neg_ratio):
            self.log(
                f"  位置 {begin:5d}〜: POS {pos:5.1f}% {'+' * int(pos / 5):<20s}"
                f" | NEG {neg:5.1f}% {'-' * int(neg / 5)}"
            )

        bert_labels = [r["bert_label"] for r in results]
        dict_labels = [r["dict_label"] for r in results]
        po, kappa, matrix, matched = agreement_stats(bert_labels, dict_labels)

        self.log("\n--- 手法間の一致度(BERT vs 辞書ベース) ---")
        self.log(f"  一致率  : {po * 100:5.1f}% ({matched} / {n} 文)")
        self.log(f"  不一致率: {(1 - po) * 100:5.1f}%")
        self.log(f"  Cohen のκ係数: {kappa:.3f}")

        self.log("  混同行列(行=BERT, 列=辞書ベース)")
        self.log("            " + "".join(f"{lb:>10s}" for lb in LABELS))
        for x in LABELS:
            self.log(f"  {x:<10s}" + "".join(f"{matrix[(x, y)]:>10d}" for y in LABELS))

        low = sorted(
            (r for r in results if r["bert_score"] < 0.6),
            key=lambda r: r["bert_score"]
        )
        self.log(f"\n--- BERT の確信度が低い文(score < 0.6):{len(low)} 件(先頭20件) ---")
        for r in low[:20]:
            kind = "会話文" if r["is_dialogue"] else "地の文"
            self.log(f"  [{kind}] {r['bert_label']:8s} {r['bert_score']:.3f} : {r['text'][:40]}")

        self.log("\n--- 文長と確信度(散布図用データ、先頭30文) ---")
        self.log("  length,score")
        for r in results[:30]:
            self.log(f"  {len(r['text'])},{r['bert_score']:.3f}")

    def report_comparison(self):
        self.log("\n" + "=" * 100)
        self.log("■ 複数作品の比較(同一の傾向が再現するかの確認)")
        self.log(
            f"  {'作品':<22s}{'文数':>7s}{'POS%':>8s}{'NEU%':>8s}{'NEG%':>8s}"
            f"{'手法一致率%':>12s}{'κ':>8s}"
        )

        for author, title, results in self.analyzed:
            n = len(results)
            counter = Counter(r["bert_label"] for r in results)
            po, kappa, _, _ = agreement_stats(
                [r["bert_label"] for r in results],
                [r["dict_label"] for r in results]
            )

            self.log(
                f"  {author + '/' + title:<22s}{n:>7d}"
                f"{counter['POSITIVE'] / n * 100:>8.1f}"
                f"{counter['NEUTRAL'] / n * 100:>8.1f}"
                f"{counter['NEGATIVE'] / n * 100:>8.1f}"
                f"{po * 100:>12.1f}{kappa:>8.3f}"
            )

        self.log(
            "  ※ 比率の順位(例:NEGATIVE が最多か)が作品間で共通していれば、"
            "単一作品の結果が偶然でないことの傍証となる。"
        )

    # -------- グラフ --------
    def show_work_figure(self, author, title, results):
        window_size = self.current_params["window"]
        scene_size = self.current_params["scene"]

        bert_values = [LABEL_VALUE[r["bert_label"]] for r in results]
        dict_values = [LABEL_VALUE[r["dict_label"]] for r in results]

        paragraph_sum, paragraph_num = Counter(), Counter()
        for r, value in zip(results, bert_values):
            paragraph_sum[r["paragraph"]] += value
            paragraph_num[r["paragraph"]] += 1

        paragraph_ids = sorted(paragraph_sum)
        paragraph_mean = [paragraph_sum[p] / paragraph_num[p] for p in paragraph_ids]

        scene_sum, scene_num = Counter(), Counter()
        for p in paragraph_ids:
            scene_sum[p // scene_size] += paragraph_sum[p]
            scene_num[p // scene_size] += paragraph_num[p]

        scene_ids = sorted(scene_sum)
        scene_mean = [scene_sum[s] / scene_num[s] for s in scene_ids]
        scene_x = [(s + 0.5) * scene_size for s in scene_ids]

        starts, pos_ratio, neg_ratio, size = bucket_ratios(results, "bert_label")

        figure = Figure(figsize=(11.5, 9.0), dpi=100)
        ax1, ax2, ax3 = figure.subplots(3, 1)

        ax1.plot(
            moving_average(bert_values, window_size),
            color="tab:blue",
            label=f"BERT:極性ラベル(±1)の{window_size}文移動平均"
        )
        ax1.plot(
            moving_average(dict_values, window_size),
            color="tab:red",
            alpha=0.7,
            label=f"辞書ベース:極性ラベル(±1)の{window_size}文移動平均"
        )
        ax1.axhline(0, color="gray", linestyle="--", alpha=0.7)
        ax1.set_xlabel("文の出現順(index)")
        ax1.set_ylabel("感情極性値(-1.0〜+1.0)")
        ax1.set_title(f"{author}『{title}』 文単位の感情極性の推移(2手法を同一尺度で比較)")
        ax1.set_ylim(-1.05, 1.05)
        ax1.legend(fontsize=8)
        ax1.grid(True, alpha=0.3)

        ax2.bar(
            paragraph_ids,
            paragraph_mean,
            width=1.0,
            color="tab:cyan",
            label="段落単位の平均感情極性値(BERT)"
        )
        ax2.plot(
            scene_x,
            scene_mean,
            color="tab:purple",
            marker="o",
            markersize=3,
            label=f"場面単位({scene_size}段落)の平均感情極性値(BERT)"
        )
        ax2.axhline(0, color="gray", linestyle="--", alpha=0.7)
        ax2.set_xlabel("段落番号")
        ax2.set_ylabel("平均感情極性値")
        ax2.set_title("段落単位・場面単位の感情極性の推移")
        ax2.legend(fontsize=8)
        ax2.grid(True, alpha=0.3)

        width = size * 0.4
        ax3.bar(starts, pos_ratio, width=width,
                label="POSITIVE 比率(%)", color="tab:orange")
        ax3.bar([x + width for x in starts], neg_ratio, width=width,
                label="NEGATIVE 比率(%)", color="tab:green")
        ax3.set_xlabel("文の出現順(区間の開始 index)")
        ax3.set_ylabel("比率(%)")
        ax3.set_title("10区間ごとの POSITIVE 比率/NEGATIVE 比率(BERT)")
        ax3.legend(fontsize=8)
        ax3.grid(True, alpha=0.3)

        figure.tight_layout()

        save_path = os.path.join(BASE_DIR, f"sentiment_{author}_{title}.png")
        figure.savefig(save_path)
        self.log(f"\nグラフを保存しました: {save_path}")

        self.open_figure_window(figure, f"{author}『{title}』の感情推移")

    def show_comparison_figure(self):
        figure = Figure(figsize=(11.5, 7.0), dpi=100)
        ax1, ax2 = figure.subplots(2, 1)

        names = [f"{a}/{t}" for a, t, _ in self.analyzed]
        positions = range(len(names))
        bar_width = 0.25
        colors = {
            "POSITIVE": "tab:orange",
            "NEUTRAL": "tab:gray",
            "NEGATIVE": "tab:green"
        }

        for offset, label in enumerate(LABELS):
            ratios = [
                Counter(r["bert_label"] for r in results)[label] / len(results) * 100
                for _, _, results in self.analyzed
            ]
            ax1.bar(
                [p + offset * bar_width for p in positions],
                ratios,
                width=bar_width,
                label=label,
                color=colors[label]
            )

        ax1.set_xticks([p + bar_width for p in positions])
        ax1.set_xticklabels(names)
        ax1.set_ylabel("比率(%)")
        ax1.set_title("作品別の感情ラベル比率(BERT)")
        ax1.legend(fontsize=8)
        ax1.grid(True, alpha=0.3)

        for (_, work_title, work_results), color in zip(
            self.analyzed,
            ("tab:blue", "tab:red", "tab:brown")
        ):
            _, pos_ratio, neg_ratio, _ = bucket_ratios(work_results, "bert_label")
            x = range(1, len(pos_ratio) + 1)

            ax2.plot(
                x,
                pos_ratio,
                color=color,
                marker="o",
                markersize=3,
                label=f"{work_title}:POSITIVE"
            )
            ax2.plot(
                x,
                neg_ratio,
                color=color,
                marker="x",
                markersize=3,
                linestyle="--",
                label=f"{work_title}:NEGATIVE"
            )

        ax2.set_xlabel("解析対象範囲の相対位置(先頭→末尾を10分割した区間番号)")
        ax2.set_ylabel("比率(%)")
        ax2.set_title("作品間で感情推移のパターンが再現するかの比較(BERT)")
        ax2.legend(fontsize=8, ncol=3)
        ax2.grid(True, alpha=0.3)

        figure.tight_layout()

        save_path = os.path.join(BASE_DIR, "sentiment_comparison.png")
        figure.savefig(save_path)
        self.log(f"比較グラフを保存しました: {save_path}")

        self.open_figure_window(figure, "作品間比較")

    def open_figure_window(self, figure, title):
        window = tk.Toplevel(self.root)
        window.title(title)

        canvas = FigureCanvasTkAgg(figure, master=window)
        canvas.draw()
        canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)


# ------------------------------------------------------------
# 8. 起動処理(コーパス・学習済みモデル・極性辞書の自動取得)
# ------------------------------------------------------------
print("青空文庫データセットをダウンロード中...")
DATASET = load_dataset("globis-university/aozorabunko-clean")["train"]
METAS = DATASET["meta"]
WORK_INDEX = build_work_index()
print(f"データセット読み込み完了:全 {len(DATASET)} 作品")

print("日本語評価極性辞書を準備中...")
POLARITY_DICT = load_polarity_dict()
ACTIVE_POLARITY_DICT = make_active_polarity_dict(POLARITY_DICT)
print(
    f"極性辞書読み込み完了:全 {len(POLARITY_DICT)} 表現 "
    f"(判定に使う極性表現 {len(ACTIVE_POLARITY_DICT)})"
)

print("形態素解析器(MeCab + IPAdic)を準備中...")
TAGGER = fugashi.GenericTagger(ipadic.MECAB_ARGS)

PIPELINE_DEVICE = select_pipeline_device()
print(f"感情分析モデル(BERT)をダウンロード中... 使用デバイス: {pipeline_device_name()}")
CLASSIFIER = pipeline(
    "sentiment-analysis",
    model=MODEL_NAME,
    tokenizer=MODEL_NAME,
    device=PIPELINE_DEVICE
)

print("準備完了。GUI ウィンドウを開きます。")

root = tk.Tk()
app = SentimentApp(root)
root.mainloop()

8. ヒントと考察

8.1 ヒント

  • 既定値のまま1回実行し、その結果を基準として記録する。記録するのは、両手法のラベル比率、一致率、κ係数、混同行列の9セルでよい。
  • 一度に変えるのは1項目とする。2項目を同時に変えると、どちらが効いたか判別できない。
  • ログ冒頭の「作品全体の文数」と解析文数の上限を突き合わせる。上限が下回っていれば、見ているのは作品冒頭の一部である。
  • 図は sentiment_{作家}_{作品}.png に上書き保存される。条件を変えて比較するなら、実行のたびに別名で退避する。
  • 移動平均の窓幅、1場面あたりの段落数、推論バッチサイズは、ログの集計結果の数値を変えない。確信度の下限は初期値0.34では置換がほとんど起きないため、効果を見るには0.5以上にする。

8.2 実験のバリエーション

  • θ を 0.00、0.50、1.00、1.05、2.00 と変える。次に集計方式を平均に変え、同じ θ で繰り返す。
  • 確信度の下限を 0.34、0.50、0.70、0.90 と変える。
  • 否定表現による極性反転を OFF にする。変わるのは辞書ベースの判定だけである。
  • 解析文数の上限を 100、300、600 と変える。
  • 同一作家の3作品を選ぶ場合と、3作家から1作品ずつ選ぶ場合の2通りで比較する。

8.3 考察ポイント

  • θ の意味は集計方式によって変わる。合計方式では文極性値が ±1 の総和すなわち整数になるため、θ を 0.00 から 1.00 まで動かしても判定は1文も変わらず、1.05 以上で初めて、極性値の合計が +2 以上(または −2 以下)になる文だけが極性判定される。肯定語2語と否定語1語の文は合計が +1 なので NEUTRAL のままである。平均方式では値が −1.0〜+1.0 に収まり、θ が 1.05 以上では全文が NEUTRAL になる。理由は dict_classify の比較条件から説明できる。
  • 辞書ベースが全文 NEUTRAL になったとき、一致率は BERT の NEUTRAL 比率と一致し、κ はちょうど 0.000 になる。一致率は両手法がともに判断を保留した文で押し上げられるため、混同行列(行が BERT、列が辞書ベース)で NEUTRAL 同士のセルが一致文に占める割合を確かめる。θ を上げると値は辞書ベースの NEUTRAL 列に集まり、確信度の下限を上げると BERT の NEUTRAL 行に集まる。
  • 否定反転を切り替えても、抽出される極性語の並びは変わらず符号だけが変わる。反転の条件は極性語の後方3形態素以内にある否定表現の個数が奇数かどうかだけであり、この窓の外にある否定や、反語・皮肉には対応しない。確信度の低い文の一覧からこうした型の文を探し、実例として挙げる。
  • 上段グラフは NEUTRAL を 0 として平均するため、中立が多い区間と、肯定と否定が拮抗する区間が同じ形になる。下段の POSITIVE 比率・NEGATIVE 比率と併せて読む。下段の10区間は作品の先頭から末尾までではなく解析対象範囲の分割であり、解析文数の上限を変えると横軸の意味が変わる。
  • 正解ラベルは存在せず、一致率もκも正確さではない。どちらの手法が正しいかはこの出力からは導けない。単一の作品で得た傾向は、同一作家の別作品と別の作家の作品で再現を確認してから一般化する。