コラムCOLUMNS

ビジネスの声を数字に変える、自然言語処理とテキストマイニングの実践ガイド

公開日:2026年9月2日

こんにちは。Anagraftの伊藤です。

本コラムは、企業の中に蓄積され続けているテキスト、たとえば問い合わせの応対履歴、アンケートの自由記述、商品レビュー、日報や議事録といった「文章のかたまり」を、集計と意思決定に使える数字へ変えるための方法を、前処理から大規模言語モデルの活用まで通しで解説する実践ガイドです。自然言語処理(コンピュータで人間の言葉を扱う技術分野)とテキストマイニング(テキストの集まりから業務に役立つ知見を取り出す実務)の両方にまたがる内容を、実務で使う順番に並べ直しました。

執筆にあたっては、説明のためのサンプルデータとして1200件の顧客の声コーパスを機械的に生成し、掲載しているコードをすべて実際に実行して、その出力とグラフをそのまま載せています。章が進んでも同じデータを追いかけられるようにしてあるので、頻度分析で上位に来た語が、共起ネットワークでどうつながり、感情分析でどう判定され、トピックモデルでどの話題に束ねられるのかを、1つの物差しで確認できます。

想定している読者は次のような方々です。

  • 顧客の声やアンケートの自由記述が大量にあるのに、目視で拾い読みする以上の活用ができていないと感じている経営層・企画部門の方
  • コールセンターや問い合わせ対応の現場を持ち、応対記録を品質改善や自動振り分けに生かしたい業務部門の責任者の方
  • データ分析の担当としてテキストデータの案件を任されたが、数値データとの勝手の違いに戸惑っている実務者の方
  • 生成AIの導入を検討する中で、従来の自然言語処理との使い分けを整理しておきたいDX推進担当の方

構成は、序章と全体の地図のあとに本編11章、終章という並びです。第1章で目的設計と分析の型を整理し、第2章で日本語テキストを集計できる形にする土台を作り、第3章から第9章で手法を難易度順に扱い、第10章でプロジェクトの回し方、第11章で5つのユースケースに落とし込みます。手法の章はそれぞれ独立して読めるように書いてあるので、いま直面している課題に近い章から読み始める使い方も想定しています。コードはPythonで書かれていますが、各章とも「その手法が何を計算しているのか」「結果をどう読み、どこで誤読しやすいのか」を散文で説明してからコードに入る順序で構成しました。

テキストの分析は、集計表を1枚作って終わりになりがちな領域です。本コラムでは、その1枚の先にある「業務に組み込んで回し続ける」ところまでを射程に入れて書きました。手法の解説にとどめず、精度の測り方と伝え方、辞書と学習データを維持する体制、ユースケースごとの手法の組み合わせまでを、同じサンプルデータをたどりながら扱っています。

$authorName
著者伊藤 瑛志

Anagraft(アナグラフト)合同会社 代表 AXプロジェクト顧問・支援
データサイエンティスト since 2013 BCG/ALBERT(現アクセンチュア)出身

目次

序章 テキストは最後に残された非構造資産

多くの企業で、売上・在庫・顧客属性といった数値データの活用は一巡しました。ダッシュボードが整備され、定例会議には集計値が並び、良し悪しは別としても「数字を見て決める」形は出来上がっています。一方で、同じ会社の中に、それよりはるかに大きな量で積み上がっているのに、ほとんど手が付いていないデータがあります。テキストです。問い合わせフォームの本文、コールセンターの応対メモ、アンケートの自由記述欄、ECサイトのレビュー、営業日報、議事録。どれも日々増え続け、読めば価値があることは誰もが知っているのに、全体を数えて意思決定に使う仕組みは持っていない。非構造化データ(行と列の表に収まっていないデータ)の代表格であるテキストは、多くの企業にとって「最後に残された未活用の資産」になっています。

放置のコストは、目に見えにくい形で発生します。顧客が不満を言葉にして送ってくれているのに、それが担当者の受信箱で完結してしまえば、同じ不満は別の顧客からも届き続けます。解約の理由がアンケートの自由記述に書かれているのに、集計されるのは選択式の設問だけであれば、対策は選択肢の粒度でしか打てません。現場が日報に書き残した兆候は、読まれなければ存在しないのと同じです。テキストを読まないことは、顧客と現場の声を聞かないことと実務上は同義になります。

それでも活用が進まなかったのには理由があります。テキストは1件ずつ読めば分かるのに、1万件になると急に手に負えなくなる。数値のように足したり平均したりできない。日本語は単語の区切りすら自明ではない。担当者の気合いで全件読む取り組みは、たいてい一度きりで終わります。必要なのは人手で読み切る努力ではなく、テキストを集計可能な形に変換する技術と、それを業務の型に落とす設計です。

本コラムで扱う「数字に変える」は、突き詰めると5つの操作に分解できます。数える(どの語・話題がどれだけ出てくるか)、つなぐ(どの語とどの語が一緒に語られるか)、判定する(その声は不満か満足か、どの区分に属するか)、まとめる(大量の文書はいくつの話題で構成されているか)、探す(この文書に似た文書はどれか)。第2章は、日本語の文章をこの5つの操作にかけられる状態へ整えるための土台の章です。そのうえで、本編の第3章から第9章が、5つの操作を実現する手法を単純なものから順に積み上げていきます。そして操作の名前が示すとおり、どれも出力は件数・比率・スコアといった数字です。数字になった瞬間に、テキストは他の業務データと同じ土俵に乗り、推移を追い、目標を置き、投資判断の材料にできるようになります。

もう1つ、この序章で強調しておきたいのは、生成AIの登場が「従来の手法を不要にした」わけではないという点です。大規模言語モデルは、テキスト活用の入口を劇的に広げました。しかし数千件を数え上げて全体の構成比を出す仕事、判定の根拠を説明する仕事、毎月同じ物差しで推移を測る仕事では、枯れた手法のほうが安く、速く、再現性が高い場面が今も数多くあります。どちらかを選ぶのではなく、両方を知って使い分けることが、これからの実務の標準になると考えています。この使い分けは第9章で正面から扱います。

Anagraftでは、テキストデータの活用構想の整理から、分析の実装、業務への組み込みまで一貫したご支援を行っています。会社概要・ご支援内容の詳細は、以下の資料からご覧いただけます。

Anagraft 会社概要資料(ダウンロードページへ)

全体の地図

本編11章の構成を一覧にします。第1章は分析の設計、第2章は日本語テキストを集計できる形に整える土台にあたり、第3章から第9章が、序章で述べた5つの操作(数える・つなぐ・判定する・まとめる・探す)のいずれかに対応します。第10章と第11章は、手法を業務に定着させるための運用とユースケースです。各章は前の章の結果を受けて進みますが、手法の説明としては独立して読めるように書いてあります。

扱う問い主な手法・道具操作
第1章 テキストマイニングの全体像と業務適用の型何のために、どのテキストを、どう分析するか5つのデータ類型、目的設計の手順設計
第2章 日本語テキストの前処理と形態素解析文章をどうやって集計の単位に区切るか正規化、MeCab・Sudachi・GiNZA、ユーザー辞書、ストップワード土台
第3章 頻度分析とキーワード抽出どの語が多く、どの語が特徴的か頻度集計、TF-IDF、n-gram、KWIC数える
第4章 共起分析とネットワークどの語とどの語が一緒に語られるか共起行列、Jaccard係数・PMI、共起ネットワーク、係り受けつなぐ
第5章 感情分析・評判分析その声は不満か、満足か極性辞書、機械学習分類、事前学習済みモデル判定する
第6章 トピックモデルで大量文書を俯瞰する全体はいくつの話題でできているかLDA、NMF、BERTopicまとめる
第7章 文書分類で仕分けを自動化する決められた区分に自動で振り分けられるか教師あり分類、交差検証、混同行列判定する
第8章 文書ベクトルと類似検索言葉が違っても意味の近い文書を探せるか分散表現、Sentence-BERT、近似最近傍探索探す
第9章 LLM時代のテキストマイニング生成AIと従来手法をどう使い分けるかゼロショット分類、構造化抽出、要約、RAG(検索で集めた文書を根拠として与えたうえで生成させる仕組み)全操作
第10章 実務プロジェクトの進め方分析を業務に組み込んで回し続けるにはアノテーション設計、評価設計、運用と辞書メンテ運用
第11章 ユースケース詳説実際の業務ではどう組み合わせるか顧客の声・コールセンター・レビュー・アンケート・社内ナレッジの5本統合

章ごとに、どの問いに答えるための章なのかを表の2列目で確認できる構成にしました。第1章で整理する5つの類型と、第11章で扱う5つのユースケースは対応させてあるので、手法の章と、その手法が実際に組み合わされている業務の場面を突き合わせられます。

第1章 テキストマイニングの全体像と業務適用の型

企業の中に蓄積されているデータのうち、売上や在庫のように行と列の表に収まっているものは、すでに何らかの形で集計され、会議資料になり、意思決定に使われています。一方で、問い合わせの応対履歴、アンケートの自由記述、レビュー、日報、議事録といった文章のかたまりは、量だけが増え続けたまま、誰も全体像を把握できていないという状態に置かれがちです。読めば意味が分かるという点では価値が明らかなのに、量が増えると読み切れなくなり、結局は担当者が印象で語る材料に留まってしまいます。

この章では、そうしたテキストを扱うための地図を先に描きます。具体的には、テキストマイニングと自然言語処理という2つの言葉の関係を整理し、企業内テキストを5つの類型に分けて性質の違いを押さえ、「数字に変える」という表現が実際にはどのような操作を指すのかを分解します。そのうえで、手法を選ぶ前に決めておくべき目的の設計と、目的から手法へ降ろしていく手順を示します。

手法そのものの解説はこの章では行いません。形態素解析も、TF-IDFも、トピックモデルも、感情分析も、それぞれ第2章以降で個別に扱います。ここで用意したいのは、それらを並べて眺めたときに「自分の課題ならどれを使うのか」を判断できる枠組みです。道具の使い方を覚えることと、どの道具を持ち出すかを決めることは別の技能であり、実務で詰まるのはたいてい後者の方だと考えています。

テキストマイニングと自然言語処理の関係を整理する

自然言語処理は、人間が日常的に使う言葉をコンピュータで扱うための技術体系全体を指します。文を単語に区切る、品詞を判定する、係り受けの構造を求める、意味の近さを数値で表す、文章を生成する、といった処理はすべてこの範囲に入ります。技術の側から見た呼び名であり、応用先が業務分析であるか、機械翻訳であるか、対話システムであるかは問いません。

これに対してテキストマイニングは、大量の文章から有用な情報や傾向を取り出すという目的の側から付けられた呼び名です。使う技術は自然言語処理のものを借りてきますが、関心の中心は「この文章群から何が言えるか」にあります。つまり自然言語処理が道具箱で、テキストマイニングはその道具箱を使って行う仕事の名前だと捉えると、両者の関係は整理しやすくなります。

この区別は言葉遊びではなく、プロジェクトの進め方に直接影響します。自然言語処理の視点だけで進めると、解析の精度そのものが目的化しやすくなります。形態素解析の分割単位を細かく調整し、分類器の正解率を数ポイント上げることに時間を使い、それが業務のどの判断を良くしたのかが説明できないまま終わる、という進み方です。逆にテキストマイニングの視点だけで進めると、出てきた図を都合よく解釈してしまう危険が生まれます。前処理の設定ひとつで単語の並びは変わりますから、技術的な中身を理解せずに結果だけを読むと、設定の副作用を発見だと思い込むことになります。

実務では、この2つを行き来しながら進めることになります。業務の問いから出発して手法を選び、手法の制約を理解したうえで問いの立て方を修正し、また業務に戻る、という往復です。本ガイドの章立ても、前半で技術の中身を押さえ、後半で運用とユースケースに戻る形にしてあります。

なお、大規模言語モデルが実用段階に入ったことで、この関係は少し変わりました。以前は、文を単語に分け、品詞を判定し、特徴量を作るという工程を自分で組み立てなければ何も始まりませんでした。現在は、文章をそのまま与えて「この問い合わせを5つの区分に分類してください」と指示すれば、それらしい答えが返ってきます。工程の多くが内側に隠れたわけです。ただしこれは、目的設計が不要になったという意味ではありません。むしろ手前の工程が省ける分、何を出力させ、その出力をどう検証するのかという設計の比重が上がっています。従来手法と大規模言語モデルの使い分けは第9章で扱いますが、判断の土台になるのは本章で整理する目的の言語化です。

企業の中にあるテキストは5つの型に分かれる

「テキストデータを分析したい」という相談は、実際には性質のまったく異なるデータをひとまとめにした言い方になっていることが多いように思います。同じ日本語の文章でも、誰が、どんなきっかけで、どれくらいの分量を書いたのかによって、必要な前処理も、取り出せる情報も、成果の出し方も変わります。企業内のテキストは、書き手と発生のきっかけを軸にすると、次の5つに整理できます。

1つ目はVoCです。顧客が自発的に発した声を指し、お客様相談室に届く意見、SNS上の投稿、ウェブサイトの意見フォームに書き込まれた文章などが含まれます。2つ目は問い合わせおよびコールセンターのログで、顧客とのやり取りをオペレーターや担当者が記録したものです。3つ目はECサイトのレビューで、購入者が商品ページ上に投稿する評価文と星の数の組み合わせです。4つ目はアンケートの自由記述で、こちらが設問を用意して引き出した回答です。5つ目は社内文書で、日報、議事録、障害報告書、業務マニュアル、営業活動の記録などがここに入ります。

この5つを分ける意味は、書き手が何を意識して書いたかが違う点にあります。顧客が自発的に書いた文章には、書き手にとって強い動機があります。強い不満か、強い満足がなければ、人はわざわざ文章を書きません。したがってVoCやECレビューには、意見の強度という偏りが最初から入っています。これに対してアンケートの自由記述は、こちらが尋ねたから書かれたものですから、偏りの方向が違います。設問の文言に引きずられた回答が集まりますし、答えたくない人は空欄で返します。コールセンターのログや社内文書はさらに性質が異なり、書き手は業務上の記録として書いていますから、感情の表現は薄く、事実の記述が中心になります。同じ「顧客の不満を知りたい」という目的でも、どの類型を見るかで見えてくる不満の種類が変わります。

この5つは、量、1件あたりの文の長さ、書き手が誰か、どんなノイズが混ざるか、あらかじめラベルが付いているか、どのくらいの頻度で増えるか、という6つの観点で違いが出ます。表にすると次のようになります。

類型1件の長さ量と更新頻度書き手主なノイズラベルの有無
VoC(顧客の自発的な声)短文から中程度。数十字から数百字中程度。イベントや不具合の発生時に急増する顧客本人誤字、口語表現、絵文字、話題の飛躍基本的に無し。分類は自分で設計する
問い合わせ・コールセンターログ長い。応対1件で数百字から数千字非常に多い。毎日一定量が積み上がるオペレーターや担当者定型の挨拶句、社内略語、音声認識の誤変換受付区分などの粗いラベルが付いていることが多い
ECレビュー短い。多くは数十字から百数十字多い。商品数に比例して増える購入者誇張表現、配送への言及、無関係な投稿星評価という数値ラベルが付いている
アンケート自由記述短い。一文で終わることも多い少ない。調査の実施時にまとまって発生する回答者無回答、単語だけの回答、設問の意図からの逸脱設問や属性が構造化データとして併存する
社内文書長い。書式も長さもばらつく多いが更新は不定期。過去分の蓄積が大きい従業員専門用語、社内固有名詞、テンプレートの定型文フォルダ構成や文書種別が実質的なラベルになる

類型ごとの性質の違いが、打つべき手を決める

表の各列は、そのまま分析設計上の判断につながります。1件の長さは、文書を単位として扱えるかどうかを左右します。アンケートの自由記述のように一文で終わる回答は、1件を1つの文書として扱うと情報量が少なすぎて、トピックの抽出が安定しません。逆にコールセンターの応対ログのように数千字に及ぶものは、1件の中に複数の話題が混在しますから、そのまま1文書として扱うと「主な話題」がぼやけます。前者は複数件をまとめて扱う工夫が要り、後者は発話単位や段落単位に分割する工夫が要ります。

ノイズの種類は、前処理の設計を決めます。オペレーターが書いたログには「お世話になっております」「確認いたします」といった定型句が大量に含まれ、頻度を数えるとこれらが上位を占めてしまいます。音声認識を通したテキストであれば、同音異義語の誤変換が固有名詞を壊します。ECレビューには配送や梱包に関する言及が混ざり、商品そのものの評価を見たいときには邪魔になります。どのノイズを、どの段階で落とすのかは、第2章の前処理と辞書設計の話につながります。

ラベルの有無は、使える手法の幅を決めます。ECレビューには星評価が付いていますから、これを正解ラベルと見なして感情分析の学習や検証に使えます。コールセンターのログに受付区分が付いていれば、それを教師データとして分類器を学習させる道が開けます。一方でVoCや社内文書にはラベルがないことが多く、その場合はまず自分で分類の枠組みを設計し、人手で一定量のラベルを付けるところから始めることになります。この作業の設計は精度を大きく左右するため、第10章でまとめて扱います。

更新頻度は、成果物の形を決めます。アンケートのように調査時にまとまって発生するデータであれば、分析は単発の報告書で完結します。コールセンターのログのように毎日積み上がるデータであれば、一度きりの分析ではなく、定期的に同じ集計を回して変化を追う仕組みが必要です。前者と後者では、作るべきものが報告書とダッシュボードというくらい違います。着手前にどちらなのかを確認しておかないと、せっかく作った分析が一度使われただけで終わります。

企業内テキストが前処理・特徴量化・分析手法を経て、件数・分類・スコア・関係図・検索という出力になるまでの工程の概念図

「数字に変える」とは、5つの操作のことである

本ガイドの表題にある「数字に変える」という表現は、抽象的に聞こえるかもしれません。実際には、テキストに対して行える数値化の操作はそれほど多くなく、序章で挙げた5つに整理できます。数える(件数化)、つなぐ(関係の可視化)、判定する(分類とスコア化)、まとめる(話題の抽出)、探す(検索可能化)の5つです。分析の企画段階では、自分がやろうとしていることがこの5つのどれなのかを言い切れるかどうかが、そのまま設計の解像度になります。

数える、すなわち件数化は、最も基本的な操作です。ある単語や表現が何件のテキストに現れたかを数え、期間や属性で比較します。「返金という語を含む問い合わせが先月比で増えている」という一文は、それだけで会議の議題になります。つなぐ、すなわち関係の可視化は、単語と単語、あるいは話題と話題の結びつきを数値で測り、図として示す操作です。「解約」という語がどんな語と一緒に現れやすいかが分かると、解約の理由に当たりを付けられます。

判定するは、1件のテキストに区分やスコアを与える操作で、あらかじめ決めた区分を割り当てる分類と、連続値を与えるスコア化の2つの形をとります。問い合わせを「料金・請求」「配送・在庫」「不具合・エラー」「使い方・操作」「対応・接客」に仕分けることができれば、各区分の件数推移という時系列データが手に入ります。スコア化のほうは、感情の肯定否定を数値で表す、緊急度を数値で表す、といった使い方をします。まとめる、すなわち話題の抽出は、区分をこちらで決めるのではなく、大量の文書が実際にはいくつの話題で構成されているのかをデータの側から推定する操作です。分類が「決めた箱に入れる」操作であるのに対し、こちらは「箱そのものをデータから作る」操作だと考えると違いがはっきりします。探す、すなわち検索可能化は、意味の近さで文書を探せるようにする操作です。過去の類似案件や関連するマニュアルを引き当てられるようにすることで、担当者の作業時間そのものを短縮します。

操作出てくる数字の形答えられる問い扱う章
数える(件数化)単語や表現ごとの出現件数、構成比、推移いま何が多く語られているか。先月から何が増えたか第3章
つなぐ(関係の可視化)単語ペアの共起強度。ネットワーク図の座標と辺の重みある話題は何と結びついて語られているか第4章
判定する(分類とスコア化)区分ごとの件数と割合。1件ごとの区分ラベルや連続値寄せられた声はどの領域に属するか。どれくらい否定的か。振り分けを自動化できるか第5章、第7章
まとめる(話題の抽出)話題ごとの代表語と、各文書がどの話題にどれだけ属するかの割合全体はいくつの話題でできているか。どの話題が大きいか第6章
探す(検索可能化)クエリと文書の類似度。上位候補の順位この案件に似た過去事例はどれか。参照すべき文書はどれか第8章、第9章

5つの操作は排他的ではなく、実務では組み合わせて使います。たとえば問い合わせログの分析では、まず判定するで領域ごとに仕分け、領域ごとに数えるで推移を見て、増加している領域についてスコアの高いものから深刻な声を絞り込み、つなぐで原因の当たりを付ける、という流れになります。重要なのは、この組み合わせを最初に描いておくことです。描かずに始めると、手元にある道具から順に試すことになり、出てきた結果をどう読むべきかが定まりません。

もう1点、5つの操作を区別する実務上の利点があります。それぞれで必要になる準備の重さが違うということです。数えるとつなぐは、辞書とストップワードを整えれば実行できますから、着手から結果までが早く済みます。まとめるも、区分を人が決めずに済むぶん着手は軽く、代わりに出てきた話題に名前を付ける判断が後ろに残ります。判定するは、区分の定義を業務側と合意し、正解ラベルを人手で付ける工程が先に入りますから、準備に時間がかかります。探すは、埋め込みモデルの選定と検索対象の整備に加えて、検索の良し悪しを測るための評価用の質問を集める必要があります。同じ「テキストを分析する」という言葉でも、着手から成果まで1週間で終わるものと、2か月かかるものが混在しています。企画の段階でここを見誤ると、後から工数が膨らみます。

目的設計が成否を決める理由

テキストマイニングの成否は、手法の選択よりも目的の設計で決まると考えています。理由は3つあります。

1つ目は、テキストからは無数の切り口が取り出せてしまうからです。数値データであれば、売上を見るのか利益を見るのかという選択はあっても、指標そのものの数は限られています。テキストは違います。同じ問い合わせログから、頻出語も、話題の分布も、感情の傾向も、時系列の変化も、担当者ごとの差も取り出せます。切り口が多いということは、目的を決めずに始めると「何となく面白い図」が量産され、そのどれもが意思決定につながらないという事態を招くということです。

2つ目は、テキストの分析には正解が用意されていないからです。売上予測であれば、実際の売上という答え合わせの材料が後から手に入ります。ところが「顧客の不満を把握する」という課題には、そもそも何をもって正しく把握できたと言えるのかの基準がありません。基準は分析の外側、つまり業務の側で決めるしかありません。「この分析の結果、どの会議で、誰が、何を判断できるようになれば成功とするのか」を先に言語化しておかないと、精度の議論そのものが成立しません。

3つ目は、前処理の設計が目的に依存するからです。第2章で詳しく扱いますが、どの語をノイズとして落とすかは、目的によって逆転します。商品の評価を見たいときには配送に関する語はノイズですが、物流の改善が目的なら配送に関する語こそが本体です。「とりあえず標準的な前処理をかけておく」という進め方が成立しないのは、標準的な前処理という概念が目的から独立して存在しないからです。

目的設計というと大げさに聞こえますが、実際には数行の文章に落とせれば十分です。分析の対象、答えたい問い、出す成果物の形、その成果物を見て誰が何を決めるのか、成功したと言える状態。この5点を、着手前に文章にしておくことをおすすめします。書けないのであれば、それはまだ分析に着手する段階ではなく、業務側との対話を続ける段階だということになります。

この5点のうち、最も抜けやすいのは4点目、つまり誰が何を決めるのかという部分です。分析の依頼は「傾向が知りたい」という形で来ることが多く、その先の意思決定までは語られません。しかし知った結果として何も変わらないのであれば、その分析は資料を1本増やしただけになります。着手前に「この結果が想定と違っていたら、業務のどこを変えることになりますか」と確認しておくと、目的の実質が見えます。変えるものが何もないという答えが返ってきたなら、分析の優先度を下げる判断もあり得ます。

目的から手法へ降ろす4つの手順

目的が定まったら、そこから手法へ降ろしていきます。手順は4段階に分けられます。

第1段階は、業務上の問いを分析可能な問いに翻訳することです。「顧客満足度を上げたい」は業務上の願いであって、分析の問いにはなっていません。これを「解約直前の3か月に寄せられた問い合わせでは、継続顧客と比べてどの話題の比率が高いか」まで具体化すると、必要なデータと手法が見えてきます。翻訳の際には、比較の軸を必ず入れることを意識すると具体化しやすくなります。何かと何かを比べる形にすると、答えが数字になるからです。

第2段階は、答えの形をあらかじめ描くことです。分析が終わったときに、どんな表やグラフが手元にあるはずかを紙に書いてしまいます。棒グラフなのか、推移の折れ線なのか、ネットワーク図なのか、順位の付いた一覧表なのか。ここまで描くと、必要な数値化の操作が5つのうちどれなのかが自動的に決まります。この段階で「どんな図になるか想像がつかない」と感じるなら、第1段階の問いがまだ曖昧だということです。

第3段階は、手法の候補を挙げて制約で絞ることです。同じ答えの形に対して、複数の手法が候補になるのが普通です。文書を区分に分けたいのであれば、キーワードのルールで分ける方法、トピックモデルで教師なしに分ける方法、教師ありの分類器を学習させる方法、大規模言語モデルにゼロショットで分類させる方法があります。どれを選ぶかは、ラベル付きデータが用意できるか、判断根拠を説明する必要があるか、毎月自動で回す必要があるか、といった制約で決まります。この使い分けの基準は、第9章で従来手法と大規模言語モデルの比較として整理します。

第4段階は、最小構成で一度通すことです。全データを対象にせず、数百件のサンプルで最初から最後まで一周させ、出てきた結果を業務側の担当者に見てもらいます。ここで「思っていたものと違う」が判明すれば、失うのは数日です。全件を処理し、モデルを作り込んでから見せると、失うのは数か月になります。この最小構成で回す進め方は、第10章の評価設計と運用の話につながります。

分析の型を一覧にする

ここまでの整理を、目的とアウトプットと代表手法の対応として一覧にします。難易度は、実装の難しさではなく、業務で成果を出すまでにかかる手間の大きさで見ています。ラベル付けの人手が必要なもの、判断の妥当性を業務側と合意する必要があるものは、コードの行数が少なくても難易度は高くなります。

目的アウトプットの形代表的な手法難易度扱う章
いま何が語られているかを把握する頻出語の一覧と構成比の棒グラフ形態素解析、単語頻度、TF-IDF低。前処理の丁寧さで質が決まる第2章、第3章
話題どうしの結びつきを見る共起ネットワーク図と共起強度の一覧共起行列、Jaccard係数、自己相互情報量中。図の読み方の合意が要る第4章
肯定と否定の傾向を測る極性スコアの分布と推移極性辞書、教師あり分類、文脈考慮のモデル中。否定表現と閾値の設計が要点第5章
大量文書の全体像を俯瞰するトピックごとの代表語と文書の割り当てLDA、NMF、埋め込みに基づくクラスタリング中。トピック数の決定と命名に判断が入る第6章
問い合わせの振り分けを自動化する区分ラベルと精度指標の表教師あり文書分類、不均衡データ対策高。学習データ作成の工数が支配的第7章
類似する文書を探せるようにする類似度順の候補一覧文書ベクトル、SBERT系の埋め込み、近傍探索中。評価用の検索クエリ集めが要る第8章
非定型の項目を文章から抜き出す構造化された表大規模言語モデルによる構造化抽出中。出力形式の固定と検算の仕組みが要る第9章
継続的に同じ指標を追う定期更新のダッシュボード上記の組み合わせと運用設計高。辞書とラベルの維持が続く第10章
数える・つなぐ・判定する・まとめる・探すという5つの操作と、第3章から第9章までの対応を示したマトリクス。第6章のトピックモデルはまとめる、第9章のLLMは全操作にかかる

よくある3つの失敗

テキストマイニングの取り組みがうまくいかないとき、原因は似た形をとることが多いように思います。代表的な3つを挙げます。

1つ目は、ワードクラウドを作って終わってしまうことです。単語を頻度に応じた大きさで並べた図は、見た目に分かりやすく、報告資料の見栄えもよいため、最初の成果物として選ばれがちです。しかしこの図から読み取れるのは「よく出てくる語」だけであり、ほとんどの場合それは業務担当者がすでに知っていることです。しかも図の印象は前処理の設定に強く依存し、ストップワードの選び方ひとつで中心に来る語が変わります。ワードクラウドを使うなら、比較の対象を並べて差分を見る形にするなど、使い方に条件を付ける必要があります。この点は第3章で詳しく扱います。

2つ目は、全件を眺めようとすることです。「まずは全部読んで傾向をつかもう」という進め方は、件数が数百件なら有効ですが、数万件になると破綻します。破綻すると分かっていても着手してしまうのは、どこを見るべきかの基準がないからです。基準がないまま読み始めると、最初の数百件で得た印象が全体の結論として固定され、後半は流し読みになります。正しい順序は逆で、まず全体を数値で粗く分割し、注目すべき部分集合を特定してから、その中身を人が読むという流れです。数値化は読む作業を代替するのではなく、どこを読むかを決めるために使います。

3つ目は、精度目標を決めずに始めることです。分類器を作る場合でも、感情スコアを付ける場合でも、「どれくらい当たれば業務で使えるのか」を先に決めておかないと、いつまでも改善を続けることになります。しかも目標がないと、精度の議論が「もっと上げられないのか」という感想の応酬になります。実際には、用途によって必要な水準はまったく違います。担当者が最終確認する前提の下書き生成であれば、多少の誤りは許容できます。自動で顧客に返信する仕組みであれば、水準は格段に上がります。どの誤りが業務上どれだけ痛いのかを整理し、そこから目標を逆算する考え方は、第10章で扱います。

この3つに共通しているのは、いずれも目的設計の不足が形を変えて現れたものだという点です。何のために数字にするのかが定まっていれば、ワードクラウドで終わることはありませんし、全件を眺める必要もなく、精度の目標も自然に決まります。

これらを避けるために有効な習慣が1つあります。分析結果を必ず業務担当者と突き合わせることです。テキストの分析結果は、数値データの集計と違って、正しさを内部だけでは確かめられません。トピックモデルが抽出した話題のまとまりが妥当かどうかを確かめるには、そのデータを日々読んでいる担当者に見てもらうのが有力な方法です。分析者だけで解釈を完結させると、業務の常識から外れた読み方をしていても気づけません。逆に、担当者に一覧を見せて「この分け方は感覚と合っていますか」と聞くだけで、設定の不備はかなりの割合で発見できます。分析の各段階に、この突き合わせの機会を意識的に組み込んでおくことをおすすめします。

第2章以降で扱うことの見取り図

本ガイドの残りの章は、大きく3つのまとまりに分かれています。

第2章から第4章は、日本語のテキストを数えられる状態にするための基礎です。表記の揺れをそろえ、文を単語に分け、不要な語を落とし、単語の頻度と結びつきを数えるところまでを扱います。非構造化データである文章を、集計可能な形に持ち込む工程だと考えてください。ここで手を抜くと、後段のどの手法を使っても結果がぶれます。

第5章から第9章は、目的別の分析手法です。感情の傾向を測る、話題の分布を俯瞰する、区分に自動で振り分ける、意味の近い文書を探す、大規模言語モデルに任せる、という順に、扱える対象が広がっていきます。後の章ほど新しい技術を使いますが、新しいものが常に良いわけではありません。再現性、説明のしやすさ、費用、処理速度という観点では、単純な手法の方が優れている場面が数多くあります。その使い分けの基準を、各章で示します。

第10章と第11章は、業務に定着させるための設計です。ラベルを人手で付ける作業の設計、精度の測り方と伝え方、辞書と学習データを維持する体制、そして5つのユースケースごとの手法の組み合わせを扱います。分析の技術そのものよりも、この部分で差が付くことが多いという実感があります。

この章では、テキストマイニングを自然言語処理の応用として位置づけ、企業内テキストを5つの類型に分けて性質の違いを確認し、数値化の操作を数える、つなぐ、判定する、まとめる、探すの5つに整理しました。そのうえで、手法を選ぶ前に目的を文章として書き切ることの重要性と、目的から手法へ降ろす4段階の手順を示しました。次の第2章では、この土台の上で最初に必要となる日本語テキストの前処理を扱います。正規化とクリーニング、形態素解析器の比較と選び方、ユーザー辞書とストップワードの設計まで、実際にコードを動かしながら確認していきます。

この章を深めたい方への参考書籍

『顧客と知識を見える化する テキストマイニング概論』(石井哲、東洋経済新報社):本章で整理したVoCや問い合わせログの活用を、企業の実務でどう位置づけるかという視点から確認したい読者に向いています。顧客の声を経営に生かすという課題設定と、そこに至るまでのテキストマイニングの発展の流れが通しで書かれており、目的設計の参考になります。

『動かして学ぶ! はじめてのテキストマイニング』(樋口耕一・中村康則・周景龍、ナカニシヤ出版):本章では手法の中身に踏み込みませんでしたが、実際に手を動かして全体の流れをつかみたい読者に向いています。アンケートの自由記述やレビューを題材に、データの準備から結果の解釈までが順を追って説明されています。

『新テキストマイニング入門』(喜田昌樹、白桃書房):テキストマイニングを非構造化データの分析として定義し直し、分析全体の中での位置づけを確認したい読者に向いています。本章で述べた「目的から手法へ降ろす」という考え方を、研究と実務の両面から補強する内容になっています。

第2章 日本語テキストの前処理と形態素解析

第1章では、テキストマイニングを5つの類型に整理し、何を知りたいのかを先に決めてから手法を選ぶという順序を確認しました。ここからは、その手法を実際に動かすための土台を作ります。テキストは、そのままの姿では集計できません。表計算ソフトの数値が最初から数値であるのに対して、テキストは「文字がつながっただけのもの」であり、どこからどこまでが1つの語なのかという情報を持っていません。集計の単位を作る作業、それがこの章で扱う前処理です。

本章から、記事全体で共通して使うサンプルデータを導入します。1200件のVoCコーパスで、問い合わせフォーム・コールセンター・レビュー・アンケート自由記述の4つの経路から集まった顧客の声を模したものです。これは実在の顧客の声ではなく、乱数シードを固定して機械的に生成した架空のサンプルです。カテゴリは料金・請求が320件、配送・在庫が261件、不具合・エラーが240件、使い方・操作が211件、対応・接客が168件で、平均の長さは52.2文字です。各件には受付経路とカテゴリに加えて、不満か好意かの2値の感情ラベルを正解として付けてあります(第3章の文脈集計や第5章の精度評価は、このラベルを正解として使います)。実データを持ち出さずに、章をまたいで同じ数字を追えるようにする目的で用意しました。以降の章では「第2章で用意したサンプルVoCデータ」として参照します。

前処理は地味な工程で、成果物として見せにくいところがあります。ただ、後続のすべての分析結果がここで決まってしまうので、実務では最も時間をかけるべき工程だと考えています。同じデータでも、前処理の設計が違えば頻度上位の語も、トピックの中身も、分類の精度も変わります。この章では、その変わり方を実際にコードを動かして数字で示します。

なぜ日本語には分かち書きが要るのか

英語は単語の間に空白があります。空白で切るだけで、そこそこ使える単位に分かれます。日本語にはその空白がありません。同じ内容の文を空白で切ってみると、この差は一目で分かります。英語の「The delivery was delayed by two weeks.」は空白で切ると7つに分かれますが、日本語の「配送が二週間ほど遅れました。」は1つのままです。文全体が1つの塊として扱われるので、このままでは語の頻度も、語と語の関係も数えられません。

そこで必要になるのが、文を語の並びに切り分ける処理です。日本語では形態素解析と呼ばれ、辞書に載っている語の並びとして文をもっともらしく説明する組み合わせを探します。「形態素」は意味を持つ最小の単位という言語学の用語ですが、実務では「集計の単位になる語」と読み替えて差し支えありません。

この切り分けは一意に決まりません。同じ文字列が複数の切り方を許すからです。有名な例に「ここではきものをぬぐ」があります。「ここでは・きもの・を・ぬぐ」とも「ここ・で・履物・を・脱ぐ」とも読めます。Janomeで実際に解析すると「ここ / で / は / きもの / を / ぬぐ」と切れました。ひらがなだけの文では、解析器も判断材料が乏しくなります。「きしゃのきしゃがきしゃできしゃした」に至っては「きし / ゃのきしゃがきしゃできしゃした」となり、ほとんど切れていません。日本語話者なら「貴社の記者が汽車で帰社した」と読めるところですが、漢字が無いと解析器は手がかりを失います。

もっとも、業務のテキストでこの種の極端な曖昧さに出会うことは多くありません。実務で問題になるのは、もっと地味な複合語の切れ方です。「東京都に住んでいます」は「東京 / 都 / に / 住ん / で / い / ます」と切れます。「東京都」がひとまとまりで欲しい場面では困ります。「選挙管理委員会に問い合わせました」は「選挙 / 管理 / 委員 / 会 / に / 問い合わせ / まし / た」となり、組織名が4つに割れます。「月額利用料金の請求書が届きません」も「月額 / 利用 / 料金 / の / 請求 / 書 / が / 届き / ませ / ん」で、「請求書」が「請求」と「書」に分かれました。これらは誤りではなく、辞書の設計としてそういう単位を採っているというだけです。ただ、集計の単位としてそれが妥当かどうかは、分析の目的によって変わります。

形態素解析を使わずに済ませる方法もあります。1文字ずつに切る方法と、隣り合う2文字を1単位とする文字bigramです。辞書も解析器も要らないので、未知の専門用語が多いデータでは今も使われます。サンプルVoCコーパス1200件(総文字数62,646文字)に3つの方式を適用して、総トークン数と語彙サイズを測りました。

from collections import Counter
from janome.tokenizer import Tokenizer
from voc_corpus import load_voc

t = Tokenizer()
df = load_voc()          # 1200件の架空VoCコーパス
texts = df["text"].tolist()

char_vocab, bigram_vocab, morph_vocab = Counter(), Counter(), Counter()
char_n = bigram_n = morph_n = 0

for s in texts:
    char_vocab.update(s)
    char_n += len(s)
    bg = [s[i:i + 2] for i in range(len(s) - 1)]
    bigram_vocab.update(bg)
    bigram_n += len(bg)
    ms = [w.surface for w in t.tokenize(s)]
    morph_vocab.update(ms)
    morph_n += len(ms)

# 出力:
# 文字単位      総トークン 62,646  語彙   319
# 文字bigram   総トークン 61,446  語彙   944
# 形態素        総トークン 39,003  語彙   309
切り方総トークン数語彙サイズ頻度上位に並ぶもの
文字単位62,646319ま、し、。、が、い、た
文字bigram61,446944した、た。、す。、まし、ます
形態素39,003309。、が、た、、、の、て

文字bigramは語彙が944と最も多くなりました。文字単位と形態素の語彙が300前後で近いのは偶然で、中身はまったく違います。文字単位の語彙は「使われている漢字とかなの種類」を数えているだけで、意味の単位にはなっていません。文字bigramは、意味のある2文字の語をたまたま拾う一方で、語の境界をまたいだ無意味な2文字も同じ重みで拾います。「請求書の内訳がわからず」を文字bigramにすると「請求 / 求書 / 書の / の内 / 内訳 / 訳が / がわ / わか / から / らず」の10個になります。このうち意味のある語は「請求」と「内訳」だけで、残り8つは境界をまたいだ雑音です。形態素解析なら「請求 / 書 / の / 内訳 / が / わから / ず」と切れて、集計に使える語だけを取り出せます。

文字bigramを使う場面がないわけではありません。辞書に無い専門用語や商品コードが大量に混ざるデータで、まず全体の傾向を掴みたいときには有効です。ただし出てきた上位語をそのまま報告資料に載せると「した」「た。」「まし」といった無意味な断片が並びます。業務のテキストマイニングでは、形態素解析を基本にして、必要に応じて文字bigramを補助的に併用する形が扱いやすいと考えています。

正規化とクリーニング:何をどこまで揃えるか

切り分けの前に、表記を揃えておく必要があります。同じ意味の語が違う文字で書かれていると、別の語として数えられてしまうからです。全角の「ID」と半角の「ID」、半角カナの「アプリ」と全角カナの「アプリ」、大文字の「CSV」と小文字の「csv」は、集計上は同じものとして扱いたい場面がほとんどです。

この揃え方の基準として、Pythonの標準ライブラリに入っているUnicode正規化のNFKC形式がよく使われます。NFKCは互換文字を標準的な文字に置き換える方式で、全角英数を半角に、半角カナを全角カナに、丸数字や単位記号を通常の文字列に変換します。実際にかけると次のようになりました。

入力NFKC適用後文字数の変化
IDID2 から 2
アプリアプリ4 から 3
バグバグ4 から 2
(株)1 から 3
株式会社1 から 4
センチ1 から 3
III1 から 3
11 から 1
m21 から 2
°C1 から 2

注意したいのは、文字数が変わる変換が含まれる点です。半角カナの濁点と半濁点は独立した文字として保持されているため、「パ」は2文字ですが、NFKC後の「パ」は1文字になります。文字位置を記録しながら処理する仕組み、たとえば元の文章のどこを引用したかを残す機能を作る場合は、正規化の前後で位置がずれることを想定しておく必要があります。

NFKCで揃えるほかに、テキストから落としておきたいものがいくつかあります。URLとメールアドレスは、それ自体が分析対象になることは稀で、解析器に渡すと細かく割れて雑音になります。絵文字は感情の手がかりになる場合もありますが、頻度集計の対象としては扱いにくいので、多くの場合は除去します。改行と連続する空白は整理します。そして、メールや問い合わせフォームのデータで最も効くのが、署名と免責文の除去です。「本メールの内容は機密情報を含みます」のような定型文は全件に同じ文字列で入るため、放置すると頻度上位を占領します。

import re
import unicodedata

URL_RE   = re.compile(r"https?://\S+")
MAIL_RE  = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
EMOJI_RE = re.compile("[\U0001f000-\U0001faff]+")
SIGN_RE  = re.compile(r"\n--\n.*", re.S)      # 署名区切りより後ろを落とす
SPACE_RE = re.compile(r"[ \t ]+")
NUM_RE   = re.compile(r"\d+")

def strip_noise(text):
    """署名ブロック・URL・メールアドレス・絵文字を落とす。"""
    s = SIGN_RE.sub("", text)
    s = URL_RE.sub(" ", s)
    s = MAIL_RE.sub(" ", s)
    return EMOJI_RE.sub("", s)

def normalize_form(text):
    """NFKCで全角半角と半角カナを揃え、改行と連続空白を整理する。"""
    s = unicodedata.normalize("NFKC", text)
    s = s.replace("\r\n", "\n").replace("\r", "\n")
    s = re.sub(r"\n+", "\n", s)
    return SPACE_RE.sub(" ", s).strip()

def normalize_number(text):
    """連続する数字を 0 にまとめる。受付番号のような一回きりの語が潰れる。"""
    return NUM_RE.sub("0", text)

絵文字の除去では、正規表現の範囲指定に落とし穴があります。よく見かける書き方に、U+2600からU+26FFの範囲を絵文字として含めるものがあります。この範囲には「★」(U+2605)や「☆」(U+2606)、「☎」(U+260E)、「⚠」(U+26A0)が入っています。実際に試すと、この広い指定では「評価は★★☆☆☆です」の星が全部消えて「評価はです」になりました。レビューデータで星評価をテキストに埋め込んでいる場合、評価情報が丸ごと失われます。U+1F000からU+1FAFFに絞った指定なら、星も警告マークも残したまま、補助面に収録された絵文字の多くを落とせます。「→」(U+2192)や「※」(U+203B)、「①」(U+2460)はどちらの指定でも残りました。この範囲の外に置かれている絵文字や記号は落ちませんので、どこまでを消すかは、対象データに実際に出てくる文字を一度印字して確かめたうえで決めることになります。

やりすぎた正規化が壊すもの

正規化は揃えるほど良い、というものではありません。揃えるという行為は情報を捨てる行為でもあるので、捨ててはいけないものまで捨ててしまう危険があります。実際に、英字を小文字化し、数字を0に置き換え、記号をすべて除去する強めの正規化をかけてみました。

原文強い正規化の結果失われたもの
iPhone 15 Pro が Wi-Fi につながりませんiphone0proがwifiにつながりません製品の世代(15)とハイフンによる語の区切り
評価は★★☆☆☆です評価はです星評価そのもの
遅い!!! 本当に困っています遅い本当に困っています感嘆符が示す強度
3日連続で同じエラーが出ます0日連続で同じエラーが出ます継続日数という重要度の手がかり
C++ と C# のどちらでも再現しますcとcのどちらでも再現します2つの言語名が区別できなくなる
料金が2倍になりました料金が0倍になりました倍率という定量情報
AI-OCRの精度が上がりませんaiocrの精度が上がりません製品カテゴリ名の形

数字の正規化はとくに判断が分かれます。受付番号や電話番号のように、1件にしか出てこない数字は語彙を無駄に膨らませるだけなので潰したほうがよいものです。一方で「3日連続」「2倍」「5回目」のような数字は、その声の深刻さを表す情報です。両方を1つのルールで処理しようとすると、どちらかを失います。実務的には、桁数で分ける方法があります。4桁以上の数字だけを0に置き換えれば、受付番号は潰れて日数や回数は残ります。あるいは、識別番号が入る位置がフォーマットで決まっているなら、正規表現でその行だけを落とすほうが安全です。

同じ考え方は記号にも当てはまります。感嘆符の連続や星の並びは、感情分析では有力な手がかりになります。第5章で扱う感情分析まで見据えるなら、記号を全部消すのではなく、感嘆符の個数を別の列に取り出してからテキストを整える、といった順序が取れます。前処理の設計は、この先どの分析をするかを決めてから行うほうが無駄が少なくなります。第1章で目的設計を先に置いたのは、こうした判断が前処理の段階から必要になるからです。

整理すると、前処理の判断は「揃えたい」と「残したい」の綱引きです。指標を1つ挙げるとすれば、その情報が業務の意思決定に使われるかどうかです。使われないなら揃える、使われるなら別の列に退避してからテキストを揃える、という順序で考えると迷いにくくなります。

日本語テキストの前処理パイプラインの8工程と、各工程で捨てる情報を示した概念図

形態素解析器の三系統と辞書の違い

日本語の形態素解析器は複数あり、どれを選ぶかで結果が変わります。本記事の実行環境で動かせるものを3系統に整理しました。

解析器辞書特徴導入のしやすさ
JanomeIPAdic系(同梱)純Pythonで書かれており、追加のインストールが不要。ユーザー辞書をCSVで簡単に足せるpipのみ
fugashiUniDic(unidic-lite)MeCabの薄いラッパー。速度が最も出る。UniDicは語を短めに切り、語彙素という抽象的な見出し語を持つpipのみ
SudachiPySudachiDict分割の粒度をA・B・Cから選べる。表記ゆれを吸収する正規化形を持つpipのみ

辞書の違いは、そのまま結果の違いになります。IPAdic系は長く使われてきた辞書で、日本語処理の解説記事や既存システムの多くがこれを前提にしています。UniDicは国立国語研究所が整備した辞書で、語を短い単位で切り、活用形を「語彙素」という抽象的な見出しにまとめます。SudachiDictはSudachiの開発元が公開している辞書で、複合語を階層的に扱えるように設計されています。

「設定を変更したら通知が届かなくなりました」という1文を3系統で解析すると、切り方は同じでも、付与される情報が違いました。「し」という語について、Janomeは品詞を「動詞・自立」、原形を「する」と返します。fugashiは品詞を「動詞・非自立可能」、原形を「為る」と返しました。SudachiPyは辞書形が「する」、正規化形が「為る」です。つまり同じ語でも、原形として何が返ってくるかが辞書によって違います。「なる」も同様で、UniDicとSudachiの正規化形は「成る」になります。集計の見出しとして漢字表記の「為る」が出てきても業務側には伝わらないので、こうした語はストップワードで落とすか、Sudachiなら辞書形のほうを使う判断が要ります。

SudachiDictには、表記ゆれを吸収する仕組みが組み込まれています。正規化形を取ると「サーバ」も「サーバー」も「サーバー」に、「打合せ」も「うち合わせ」も「打ち合わせ」に、「web」も「ウェブ」も「ウェブ」に揃いました。表記ゆれの統一を自前の置換辞書で管理しようとすると際限がなくなるので、この機能は実務で効きます。ただし「ヴァイオリン」が「バイオリン」になるように、辞書の判断が業務側の表記ポリシーと合わないこともあります。採用する前に、自社で使う主要な語について何に揃うのかを一度確認しておくとよいと思います。

もう1つ、GiNZAとspaCyの組み合わせも日本語処理でよく挙がります。spaCyは多言語の言語処理フレームワークで、GiNZAはその日本語モデルです。形態素解析だけでなく、係り受け解析や固有表現抽出まで同じ枠組みで扱える点が特徴で、内部の解析器にはSudachiが使われています。本記事の実行環境には導入していないため実行結果は載せませんが、係り受けの情報まで欲しい場合の選択肢として押さえておくと、第4章の共起分析の設計がしやすくなります。

速度も選定の材料になります。サンプルVoCコーパス1200件、62,646文字を分かち書きする時間を測ったところ、Janomeが0.93秒、fugashiが0.03秒、SudachiPyが0.05秒から0.06秒でした。Janomeは純Python実装で、fugashiはMeCabのラッパー、SudachiPyは0.6系からRust実装への結合になっているという差がそのまま出ています。fugashiを1.00としたときの相対処理時間は、Janomeが34.69、SudachiPyのモードCが1.69でした(相対値は丸める前の実測時間から計算しているため、丸めた秒数どうしの割り算とは一致しません)。Janomeにかかった時間は、fugashiの35倍前後、SudachiPyのモードCと比べても20倍前後ということになります。1200件では体感差はありませんが、数十万件を扱うバッチ処理では効いてきます。逆に言えば、数千件規模の分析であればJanomeの手軽さを優先しても支障はありません。

形態素解析器の処理時間の比較と、Sudachiの分割単位A・B・Cによる複合語の割れ方

Sudachiの分割単位A・B・Cを動かして見る

SudachiPyの特徴は、分割の粒度を3段階から選べることです。Aが最も短い単位、Cが最も長い単位、Bがその中間です。同じ文を3つのモードで解析すると、違いがはっきり出ます。

from sudachipy import dictionary, tokenizer

sd = dictionary.Dictionary(dict="core").create()
MODES = {
    "A": tokenizer.Tokenizer.SplitMode.A,
    "B": tokenizer.Tokenizer.SplitMode.B,
    "C": tokenizer.Tokenizer.SplitMode.C,
}

s = "選挙管理委員会に問い合わせました"
for name, mode in MODES.items():
    print(name, [t.surface() for t in sd.tokenize(s, mode)])

# 出力:
# A ['選挙', '管理', '委員', '会', 'に', '問い', '合わせ', 'まし', 'た']
# B ['選挙', '管理', '委員会', 'に', '問い合わせ', 'まし', 'た']
# C ['選挙管理委員会', 'に', '問い合わせ', 'まし', 'た']
入力モードAモードBモードC
選挙管理委員会選挙 / 管理 / 委員 / 会選挙 / 管理 / 委員会選挙管理委員会
在庫管理システム在庫 / 管理 / システム在庫 / 管理 / システム在庫管理システム
月額利用料金の請求書月額 / 利用 / 料金 / の / 請求 / 書月額 / 利用 / 料金 / の / 請求書月額 / 利用料金 / の / 請求書
東京都港区東京 / 都 / 港 / 区東京都 / 港区東京都港区
自然言語処理自然 / 言語 / 処理自然 / 言語 / 処理自然言語処理
初期設定ウィザード初期 / 設定 / ウィザード初期 / 設定 / ウィザード初期設定 / ウィザード

この分割単位の選択は、分析の目的で決まります。「請求書」という語で件数を数えたいなら、モードAでは「請求」と「書」に割れてしまうのでBかCが要ります。逆に「請求」という語がついた声を、請求書でも請求金額でも請求方法でも横断的に拾いたいならAが向きます。組織名や地名を単位として扱いたい報告書ならCです。

コーパス全体で見ると、A・B・Cの違いは意外に小さくなります。1200件のサンプルVoCコーパスでは、総トークン数がAで39,839、Bで38,860、Cで38,623、語彙サイズがそれぞれ315、304、301でした。差は数パーセントです。これは、このサンプルに含まれる複合語がそれほど長くないためです。製品名や部品名、法令名のような長い複合語が多い業務データでは差が大きく開きます。自社のデータでどれだけ違うのかは、実際に3モードで走らせて頻度上位を見比べるのが確実です。

解析器を変えると語彙も入れ替わります。同じ1200件で、Janomeの語彙が309語、fugashiが310語、SudachiPyのモードCが301語でした。数はほぼ同じですが、Janomeとfugashiで共通するのは289語で、Janome側にだけある語が20語あります。JanomeとSudachiでは共通が278語、Sudachi側にだけある語が23語でした。Sudachiだけに現れた語には「たらい回し」「初期設定」「専門用語」「担当者」「日割り計算」「解約金」「支払い額」「時間帯」「検索機能」などが並びます。いずれも業務のテキストでそのまま集計したい単位です。この差が、報告資料の上位語リストの見え方を変えます。

品詞で絞り、原形に揃える

分かち書きしただけの語の並びには、助詞や助動詞や句読点が大量に含まれます。サンプルVoCコーパスをJanomeで解析して品詞別に数えると、名詞が10,506語(26.9%)で最多、次いで助詞が9,089語(23.3%)、助動詞が7,051語(18.1%)、動詞が6,129語(15.7%)、記号が4,703語(12.1%)でした。形容詞は629語(1.6%)にとどまります。

サンプルVoCコーパス1200件の品詞構成。名詞・動詞・形容詞を濃い色で示した棒グラフ

助詞・助動詞・記号を合わせると20,843語で、全体の53.4%を占めます。これらは文の骨組みを作る語であり、どの文書にも同じように現れるので、内容の違いを説明しません。そこで名詞・動詞・形容詞という自立語に絞ります。さらに、名詞の中でも「こと」「もの」のような形式名詞や、「日」「回」のような接尾語、「これ」「それ」のような代名詞は、単独では意味を持たないので品詞細分類で除外します。

あわせて、活用形を原形に揃えます。「届か」「届き」「届く」「届け」はすべて「届く」として数えたいところです。Janomeでは base_form 属性で原形が取れます。

CONTENT_POS = {"名詞", "動詞", "形容詞"}
DROP_SUB    = {"非自立", "接尾", "代名詞", "数", "副詞可能"}

_T = Tokenizer()   # 生成に時間がかかるのでモジュール直下で1回だけ作る

def tokenize(text, content_only=True, base_form=True, strict=True):
    out = []
    for w in _T.tokenize(text):
        p = w.part_of_speech.split(",")
        if content_only and p[0] not in CONTENT_POS:
            continue
        if strict and p[1] in DROP_SUB:
            continue
        out.append(w.base_form if base_form and w.base_form != "*" else w.surface)
    return out

この絞り込みを段階的に適用して、総トークン数と語彙サイズがどう変わるかを測りました。

条件総トークン数語彙サイズ
全品詞・表層形のまま39,003309
全品詞・原形に統一39,003290
名詞と動詞と形容詞・表層形17,264255
名詞と動詞と形容詞・原形17,264240
名詞と動詞と形容詞・原形・品詞細分類で除外12,843188
名詞のみ・原形7,789131

自立語に絞った時点でトークン数が39,003から17,264へ、44%まで減りました。品詞細分類での除外を加えると12,843、語彙は188語になります。名詞だけに絞ると7,789トークン、131語です。どこまで絞るかは分析の種類で変わります。頻度集計やワードクラウドなら名詞中心で十分ですが、「届かない」「つながらない」といった動作の不具合を拾いたいなら動詞は残す必要があります。「遅い」「高い」「わかりにくい」といった評価の語を扱う感情分析なら形容詞も要ります。

絞り込んだあとの頻度上位を見ると、1位が「する」で1,761回、2位が「なる」で275回、3位が「ある」で267回でした。ここまでの処理では、意味の薄い動詞が上位を占めます。4位からは「説明」242回、「届く」240回、「請求」231回、「画面」216回と、業務の内容に関わる語が並びます。上位3語をどう扱うかが、次の節の主題になります。

ユーザー辞書に自社の言葉を載せる

既定の辞書には、自社の製品名や社内の略語、業界特有の用語は載っていません。載っていない語は、解析器が知っている短い語の組み合わせに分解されます。架空の製品名で試すと、その壊れ方がよく分かります。

「ミライ在庫クラウドの初期設定ウィザードで止まってしまいます」という文をJanomeにかけると、14トークンに分かれました。「ミ / ライ / 在庫 / クラ / ウド / の / 初期 / 設定 / ウィザード / で / 止まっ / て / しまい / ます」です。製品名が5つに割れ、そのうち「ミ」「ライ」「クラ」「ウド」は語ですらありません。この状態で頻度集計をすると、製品名は影も形もなく、代わりに意味不明な断片が語彙に混ざります。

これを直すのがユーザー辞書です。Janomeは簡易フォーマットのCSVに対応しており、表層形・品詞・読みの3列を並べたファイルを渡すだけで登録できます。

from janome.tokenizer import Tokenizer

# 表層形,品詞,読み の3列を、UTF-8のCSVとして書き出す
USER_WORDS = """\
ミライ在庫クラウド,カスタム名詞,ミライザイコクラウド
レシートリンク,カスタム名詞,レシートリンク
ワンタッチ請求,カスタム名詞,ワンタッチセイキュウ
在庫クラ,カスタム名詞,ザイコクラ
請求連携オプション,カスタム名詞,セイキュウレンケイオプション
配管継手,カスタム名詞,ハイカンツギテ
納期回答,カスタム名詞,ノウキカイトウ
与信枠,カスタム名詞,ヨシンワク
受発注データ,カスタム名詞,ジュハッチュウデータ
初期設定ウィザード,カスタム名詞,ショキセッテイウィザード
"""

with open("ch02_userdic.csv", "w", encoding="utf-8", newline="\n") as f:
    f.write(USER_WORDS)

plain     = Tokenizer()
with_udic = Tokenizer("ch02_userdic.csv", udic_type="simpledic", udic_enc="utf8")

s = "ミライ在庫クラウドの初期設定ウィザードで止まってしまいます"
print([w.surface for w in plain.tokenize(s)])
print([w.surface for w in with_udic.tokenize(s)])

# 出力:
# ['ミ','ライ','在庫','クラ','ウド','の','初期','設定','ウィザード','で','止まっ','て','しまい','ます']
# ['ミライ在庫クラウド','の','初期設定ウィザード','で','止まっ','て','しまい','ます']
登録した語辞書なしの分割辞書ありの分割
ミライ在庫クラウドミ / ライ / 在庫 / クラ / ウド(5分割)1語
初期設定ウィザード初期 / 設定 / ウィザード(3分割)1語
請求連携オプション請求 / 連携 / オプション(3分割)1語
配管継手配管 / 継手(2分割)1語
与信枠与信 / 枠(2分割)1語
受発注データ受発注 / データ(2分割)1語
在庫クラ在庫 / クラ(2分割)1語

集計結果への影響も測りました。同じ5文から名詞を取り出すと、辞書なしでは異なり語26語・延べ31語でしたが、辞書ありでは異なり語16語・延べ17語になりました。語彙が減ったのは、意味のない断片が消えて製品名にまとまったからです。辞書ありで消えた語には「ミ」「ライ」「クラ」「ウド」「レシート」「リンク」「ワンタッチ」「初期」「設定」「ウィザード」などが並び、代わりに「ミライ在庫クラウド」「初期設定ウィザード」「レシートリンク」「請求連携オプション」といった、そのまま報告に使える語が現れました。

ただし、ユーザー辞書は登録すればするほど良いというものでもありません。長い語を登録すると、その語が優先されて、本来別の語として拾いたかった部分が隠れます。試しに「在庫」「在庫管理」「管理画面」の3語を登録して「在庫管理画面を開きます」を解析したところ、「在庫 / 管理画面」と切れました。「在庫管理」を登録していたのに、そちらではなく「管理画面」が選ばれています。どちらで切れるかは辞書の内部コストで決まるため、登録した側の意図どおりになるとは限りません。重なり合う複合語を大量に登録すると、この種の予測しにくい挙動が増えます。

実務での進め方としては、まず辞書なしで解析して頻度上位と低頻度の断片を目で見て、明らかに壊れている語だけを登録する順序が扱いやすいと考えています。最初から数百語のリストを作り込むより、分析を回しながら少しずつ足すほうが、副作用に気づけます。そして、登録した語のリストはバージョン管理して、いつ何を足したかを残しておく必要があります。辞書を更新すると過去の集計結果と数字が合わなくなるためで、この運用の作法は第10章で改めて扱います。

ストップワードをコーパスから決める

ストップワードは、分析から除外する語のリストです。「する」「なる」「ある」「こと」「もの」のように、どの文書にも出てきて内容の違いを説明しない語を落とします。日本語には公開された汎用リストがいくつかありますが、それだけでは足りないことが多くなります。

実際に、一般的な日本語ストップワードとして挙げられることの多い44語のリストを作り、サンプルVoCコーパスに当ててみました。前処理後のコーパスは延べ12,843語・語彙188語です。44語のうち、このコーパスに実在したのは10語(ある、する、できる、ない、なる、多い、思う、気、自分、良い)だけでした。残り34語は空振りです。この10語を落とすと語彙は188語から178語へ、延べは12,843語から9,989語へ減りました。落とした語数はわずか10語なのに、延べでは22%が消えています。少数の語が大量に出現しているということです。

逆の方向から決める方法があります。文書頻度(DF)、つまりその語が何件の文書に出現したかを数えて、高すぎるものを落とす方法です。全文書に出る語は文書を区別しないので、情報を持ちません。サンプルVoCコーパスのDF上位は次のようになりました。

DF(件)DF率延べ出現数
する99683.0%1,761
なる26021.7%275
ある24320.2%267
説明22718.9%242
届く22318.6%240
請求19516.2%231
画面19516.2%216
できる18715.6%205
対応17814.8%194
お願い17614.7%176
料金16513.8%186

ここで、DFが高いという理由だけで機械的に切ると危険なことが分かります。カテゴリごとの出現率を調べたところ、「請求」はDF率16.2%ですが、料金・請求カテゴリでは60.9%の文書に出現し、他のカテゴリでは0%でした。「料金」も同様で、料金・請求カテゴリで51.6%、他は0%です。「画面」は使い方・操作カテゴリで63.5%、他のカテゴリの最大は25.4%でした。「届く」は配送・在庫で50.6%、他は最大20.0%です。これらはDFが高いにもかかわらず、どのカテゴリの声かを強く示す語です。

閾値をいくつに置くかで、落ちる語の顔ぶれは大きく変わります。DFの閾値を20%に設定すると「する」「なる」「ある」の3語だけが落ちるので、このコーパスでは問題は起きません。ところが閾値を15%まで下げると、落ちる語は8語に増えます。実測したDF率の高い順に並べると、する83.0%、なる21.7%、ある20.2%、説明18.9%、届く18.6%、請求16.2%、画面16.2%、できる15.6%です。つまり請求や画面より先に、頻度1位の「説明」と2位の「届く」が巻き込まれます。この2語は、残す理由がそれぞれ違います。「説明」は5つのカテゴリすべてに現れ、出現率は対応・接客の34.5%から配送・在庫の10.0%まで幅があります。どのカテゴリでも説明の分かりにくさが語られているという横断的な論点を示す語なので、これが消えると全カテゴリに共通する改善余地が見えなくなります。「届く」は逆で、配送・在庫の50.6%に対して使い方・操作と対応・接客では1件も出てこず、配送の話題を代表する語として働いています。横断する語も、1つのカテゴリに張り付く語も、文書頻度の数字だけを見て切ると同時に失われます。閾値を数パーセント動かしただけで、分析の主役が消えることになります。

一方で「する」は極端です。DF率83.0%で、配送・在庫の92.0%から使い方・操作の69.7%まで、どのカテゴリでも高い水準で出現します。最も低い使い方・操作でも7割の文書に出てくるため、この語からはどのカテゴリの声かを判別できません。これは落として問題ありません。

文書頻度の高低とカテゴリごとの偏りの大小で、ストップワードとして落とすかどうかを判断する2軸マトリクス

低頻度側を切る話もあります。1件の文書にしか出てこない語は、その1件を説明するだけで全体の傾向には効かず、語彙を膨らませて計算量を増やします。ただしこのサンプルでは、DFが1の語は0語で、最小のDFが15でした。テンプレートから生成した合成データなので語彙が188語しかなく、低頻度語の裾がほとんど存在しないためです。実データでは同じ1200件でも語彙が数千規模になり、1件にしか出ない語が語彙の半分以上を占めることが珍しくありません。低頻度の切り捨ては、そこで効いてきます。

ここまでを踏まえると、ストップワードの決め方は次の順序になります。まず品詞フィルタで助詞・助動詞・記号を落とします。次に汎用リストで機能的な動詞や形式名詞を落とします。そのうえでDFの上位を並べて目で見て、業務上の意味を持たない語だけを追加します。最後に低頻度側を切ります。重要なのは、DFの閾値を機械的に決めないことです。落ちる語のリストを必ず印字して、業務を知る人の目を通す工程を入れる必要があります。ストップワードは辞書と同じく、一度作って終わりではなく育てていく資産だと考えています。

前処理パイプラインをひとつの関数にまとめる

ここまでの処理をまとめて、テキストを受け取って分析用の語のリストを返す関数にします。分析のたびに手順が変わると結果の比較ができなくなるので、1つの関数に固めて、その関数のバージョンとセットで結果を記録する形にしておきます。

def clean(text):
    """段1から段3をまとめる。除去 → NFKC → 数字の正規化。"""
    return normalize_number(normalize_form(strip_noise(text)))

def preprocess(text, stopwords=frozenset()):
    """生テキストを受け取り、分析に使う語のリストを返す。"""
    return [t for t in tokenize(clean(text)) if t not in stopwords]

# GENERIC_STOP は、前節で見た汎用ストップワードのうち、このコーパスで
# 実際に落とすと決めた語の集合。中身は自社のデータに合わせて育てる。
GENERIC_STOP = {
    "こと", "もの", "ため", "とき", "よう", "それ", "これ", "ここ", "自分", "気",
    "する", "なる", "ある", "いる", "できる", "思う", "言う", "いう", "しまう",
    "いただく", "ください", "多い", "良い", "ない",
}

# 使い方
df = load_voc()
docs = [preprocess(t, stopwords=GENERIC_STOP) for t in df["text"]]

この関数を1200件に適用して、各段階で総トークン数と語彙サイズがどう変わるかを測りました。ただし、サンプルVoCコーパスは生成した時点で表記が整っているため、そのままでは正規化の効き目が測れません。そこで実データに寄せるために、意味を変えない範囲で表記ゆれと付随物を機械的に混ぜたバージョンを作りました。混ぜたのは、一部のカタカナ語を半角カナに置き換えたもの、CSVを全角にしたもの、先頭に6桁の受付番号を付けたもの、末尾にURLとメールアドレスと絵文字を付けたもの、署名ブロックを付けたもの、そして余分な改行と空白です。

前処理の各段階における総トークン数と語彙サイズの変化を示した棒グラフ
段階総トークン数語彙サイズ前の段階との比
加工なし57,680813基準
署名・URL・メールアドレス・絵文字を除去41,67277572.2%
NFKCと空白の整理41,682766100.0%
数字の正規化41,682313100.0%
自立語に限定18,62625744.7%
原形に統一18,626242100.0%
品詞細分類で除外13,75118973.8%
ストップワード除去10,89717979.2%
DFの上下で除去9,95417791.3%

この表は、どの処理が何に効くのかを分けて見せてくれます。署名とURLの除去はトークン数を28%削りましたが、語彙は813語から775語へ38語しか減っていません。定型文は同じ文字列が全件に繰り返されるので、延べには効くが語彙には効かない、という性質がそのまま出ています。NFKCと空白の整理は、トークン数をほとんど変えず(41,672から41,682へ10増えました)、語彙を9語減らしました。半角カナ表記と全角カナ表記が同じ語にまとまったぶんです。

最も語彙を削ったのは数字の正規化で、766語から313語へ453語減りました。付与した6桁の受付番号は1件ごとに違うため、それだけで数百語の語彙を作っていたことになります。トークン数はまったく変わりません。1つの数字が1つの「0」に置き換わっただけだからです。実データでも、注文番号・伝票番号・電話番号・日付が語彙の相当部分を占めることは珍しくありません。

品詞フィルタはトークン数を最も削ります。41,682から18,626へ、44.7%まで減りました。以降は語彙も延べも緩やかに減っていき、最終的に総トークン数は9,954、語彙は177語になりました。出発点からの比率は、総トークン数が17.3%、語彙が21.8%です。文書1件あたりの語数は、加工なしで平均48.1語(中央値47、最小11、最大106)だったものが、最終段では平均8.3語(中央値8、最小3、最大19)になりました。語が1つも残らなかった文書は0件です。

この「語が0件になった文書」の確認は、前処理を組んだら必ず入れておきたい検査です。短い声や絵文字だけの声は、強い前処理をかけると何も残らないことがあります。それに気づかないまま件数を数えると、語が残った文書だけが集計に乗り、分母がずれたまま比率が計算されます。前処理のあとには、文書数が減っていないか、語数が0の文書が何件あるか、極端に長い文書や短い文書がないかを毎回出力する習慣をつけると、後段の解釈違いを減らせます。

最終的に残った語の頻度上位は、1位が「説明」242回、2位が「届く」240回、3位が「請求」231回、4位が「画面」216回、5位が「対応」194回、6位が「料金」186回でした。以下、お願い176回、表示163回、連絡144回、改善138回、問い合わせる136回と続き、利用、プラン、恐れ入る、他社、切り替え、検討がこれに並びます。前処理の前は「する」「なる」「ある」が上位を占めていたことを思えば、この並びはそのまま「顧客が何について声を上げているか」の輪郭になっています。前処理は捨てる作業ですが、捨てた結果として業務上の意味が浮かび上がる、という順序でとらえるとよいと思います。

なお、この段階でDFの上限30%によって落ちた語は「受付」と「番号」の2語だけでした。ノイズとして付与した「受付番号」という定型の見出しが、そのまま高頻度語として検出されたことになります。定型文を正規表現で完全に消し切れなくても、DFによる除去が二重の網として働くことを示しています。前処理は1つの手段に頼らず、性質の違う網を重ねるほうが安全です。

日本語のテキストは、切り分けて初めて数えられるようになります。切り分け方は解析器と辞書で変わり、分割単位の選択でも変わります。正規化は表記を揃える作業ですが、揃えるほど情報を捨てるので、業務の意思決定に使う情報かどうかで線を引く必要があります。自社の製品名や業界用語はユーザー辞書に登録しないと断片に割れ、ストップワードは汎用リストだけでは足りず、文書頻度とカテゴリごとの偏りを見て決めることになります。今回のサンプルでは、これらを重ねた結果、総トークン数が57,680から9,954へ、語彙が813語から177語へ絞られました。

ここまでで、テキストが「数えられる語の並び」になりました。次の第3章では、この語の並びを実際に数えます。単語頻度、TF-IDF、n-gram、KWICといった集計の手法を扱い、あわせてワードクラウドをどう使うべきか、どう使うと誤解を招くかを整理します。入力には、本章で作った前処理の考え方をそのまま引き継ぎます。ただしストップワードの中身だけは章ごとに調整しており、第3章では章内で結果を再現できるように、本章の汎用リストから一部の語を入れ替えています。比較の基準は、パイプライン全体を通した最終段の9,954語ではありません。ストップワードの節で示した、汎用リスト44語のうち実在した10語を落とした直後の9,989語です。第3章の設定でこの段階を測ると9,867語になります。異なり語数はどちらも178語です。差の中身は、第2章にだけ残る語が「利用」「感想」「率直」「お世話」「機会」の計395回、第3章にだけ残る語が「気」「ない」「多い」「良い」「自分」の計273回です。この入れ替わりの差し引きが、そのまま延べ語数の差になっています。設定を少し変えるだけで延べ語数は動くので、集計結果は必ず前処理の設定とセットで記録する必要があります。

この章を深めたい方への参考書籍

『形態素解析の理論と実装』(工藤拓、近代科学社):MeCabの開発者による、実践・自然言語処理シリーズの第2巻です。本章で「辞書のコストで切れ方が決まる」と書いた部分の中身、つまり辞書引きのアルゴリズムと最小コスト法、未知語の扱いが体系的に説明されています。ユーザー辞書を大量に運用することになり、なぜ意図どおりに切れないのかを根本から理解したい読者に向いています。

『Pythonによるテキストマイニング入門』(山内長承、オーム社):Pythonの基本文法から、テキストデータの構造、頻度統計、テキストマイニングの処理例までを一冊でたどる入門書です。本章で扱った前処理と分かち書きを、Pythonの環境構築から順に自分の手で組み直したい読者に向いています。次章の頻度集計にもそのままつながります。

『機械学習・深層学習による自然言語処理入門』(中山光樹、マイナビ出版):テキストの前処理から単語の分散表現、テキスト分類、系列ラベリングまでを扱う実装中心の解説書です。本章の前処理が、この先の章で扱う分類やベクトル化にどうつながるのかを一続きで確認したい読者に向いています。

第3章 頻度分析とキーワード抽出

第2章では、日本語のテキストを正規化し、形態素解析器で語に区切り、ユーザー辞書とストップワードで扱う語を絞り込むところまでを確認しました。ここまでで、1件ずつの自由文が、語の並びという扱える形に変わっています。本章では、その語の並びを数え、どの語が重要なのかを判定する段階に進みます。テキストマイニングの案件で最初に作られる集計であり、報告資料に最も載りやすい部分でもあります。それだけに、素朴に数えただけの表がそのまま経営会議に上がり、誤った解釈を生むことも起きやすい領域です。本章では、数える方法を順に上げていきながら、それぞれが何を見せて何を隠すのかを実測しながら確認します。

本章で使うのは、第2章で用意したサンプルVoCデータです。1200件、1件あたり平均52.2文字で、料金・請求が320件、配送・在庫が261件、不具合・エラーが240件、使い方・操作が211件、対応・接客が168件という内訳になっています。前処理の方針は第2章と同じで、名詞と自立動詞と自立形容詞を原形に寄せて残し、ストップワードを落とす手順を通したうえで集計しました。

ただし1点だけ、第2章と条件が違うところがあります。本章では、章の中のコードだけで結果を再現できるようにストップワードの中身を入れ替えました。第2章では残していた「利用」「率直」「感想」「お世話」「機会」を、あいさつと締めの定型語として本章では落とし、逆に第2章の汎用リストで落としていた「気」「ない」「多い」「良い」「自分」は本章に残しています。本章の頻度表に「気」が現れるのはこのためです。差し引きの結果、同じ1200件を数えても、延べ語数は第2章の末尾で示した数字と一致しません。章をまたいで延べ語数を比べるときは、どのストップワードで測ったのかを必ず確認する必要があります。

素の頻度は「何が多いか」しか答えない

最初に行うのは、単純な単語頻度の集計です。1200件を前処理すると、延べ語数は9867語、異なり語数は178語となりました。1文書あたりの平均は8.22語です。この178語の中で、出現回数の多い順に並べると次のようになります。表の右2列は、その語が出てくる文書の数と、全1200件に占める割合です。

順位出現回数出現文書数文書割合
1説明24222718.9%
2届く24022318.6%
3請求23119516.2%
4画面21619516.2%
5対応19417814.8%
6料金18616513.8%
7お願い17617614.7%
8表示16315512.9%
9連絡14413611.3%
10改善13813711.4%

この表を見て、多くの方は「説明と届くと請求が多いので、そこに問題がある」と読むと思います。ところが、この読み方には少なくとも3つの落とし穴があります。順に確認します。

1つ目は、上位に定型文が混ざることです。11位以下を見ると、問い合わせる136回、プラン131回に続いて、恐れ入る128回、他社125回、切り替え125回、検討125回、気116回、早急110回、正直109回が並びます。このうち恐れ入る、他社、切り替え、検討、早急、正直は、いずれも本文の内容ではなく、書き出しと締めの決まり文句から来ています。「恐れ入りますが」「このままでは他社への切り替えを検討します」「早急に対応していただきたいです」「正直、がっかりしました」といった文が、多くの文書の末尾に同じ形で付くためです。7位のお願いも、176回の出現がちょうど176文書に対応しており、1文書に1回だけ「改善をお願いします」として現れていることが数字から読み取れます。素の頻度は、書き手の主張ではなく、書式の慣習を上位に押し上げます。

2つ目は、上位語の順位がコーパスの構成比をそのまま映すことです。3位の請求231回と6位の料金186回は、出現の100%が料金・請求カテゴリでした。このカテゴリが最大の320件だから上位に来ているのであって、1件あたりの言及の濃さで勝っているわけではありません。逆に、対応・接客カテゴリは168件と最小なので、そこに固有の語は全体順位では沈みます。実際、対応・接客カテゴリの中で数えた上位5語は、説明、電話、折り返し、問い合わせ、サポートですが、これらの全体順位は順に1位、50位、81位、88位、90位でした。カテゴリ内では中心的な語が、全体では90位まで落ちます。全体の頻度表だけを見ていると、件数の少ないカテゴリで起きていることは構造的に見えません。

3つ目は、同じ語が異なる意味で使われていても1行にまとまることです。1位の説明242回は、料金・請求65文書、対応・接客58文書、使い方・操作46文書、不具合・エラー32文書、配送・在庫26文書と、5カテゴリすべてにまたがっています。「解約金の説明が契約時になく」と「担当者の説明が丁寧で」は、同じ説明という語ですが、意味は正反対です。この点は、語を原文の文脈ごと取り出す方法として本章の後半で詳しく扱います。

頻度分布の偏りを確かめる:順位と回数を両対数で見る

単語の出現回数は、極端に偏った分布になります。上位10語だけで延べ語数の19.6%、上位20語で31.9%、上位50語で56.1%、上位100語で79.6%を占めました。178語のうち上位100語、つまり語彙の56%が、延べ語数のおよそ8割を持っていく計算です。この偏りは日本語だからではなく、自然言語一般に見られる性質で、Zipfの法則として知られています。語を頻度の高い順に並べたとき、順位と出現回数の積がおおむね一定になる、言い換えれば両対数のグラフに乗せるとおよそ傾き マイナス1 の直線になる、という経験則です。

実際に順位を横軸、出現回数を縦軸にとって、両方を対数目盛でプロットしたものが次の図です。あわせて、傾き マイナス1 の基準線と、実測値への回帰直線を重ねてあります。

単語の頻度順位と出現回数を両対数目盛でプロットした散布図。実測値、傾きマイナス1の基準線、回帰直線を重ねている

回帰直線の傾きは全体で マイナス0.656、上位100語に限ると マイナス0.525 でした。理論値の マイナス1 よりかなり緩やかです。ここは正直に書いておくべき点で、このコーパスが少数の文テンプレートから機械的に生成した架空データであることが原因です。証拠として、1回しか出現しない語、いわゆるハパックスが0語でした。実データを扱うと、異なり語の3割から5割程度が1回きりの出現になり、分布の裾がはるかに長く伸びます。人名、型番、誤変換、方言、絵文字などが延々と続く部分です。つまり、この図で見えている偏りは、実務で遭遇する偏りよりも穏やかです。

この確認には実務上の意味があります。集計を始める前に順位対頻度をプロットし、傾きと裾の形を見ておくと、前処理が効いているかどうかの当たりが付きます。傾きが極端に急であれば少数の定型文がコーパスを支配していますし、裾がやたらと長ければ表記ゆれの正規化が足りていない可能性があります。異なり語数と延べ語数の比、そしてハパックスの比率は、辞書整備の作業量を見積もるための実用的な指標にもなります。

TF-IDF:珍しさで重みをつける

素の頻度の弱点は、どの文書にも出てくる語ほど数字が大きくなることでした。これを補正する古典的な方法がTF-IDFです。考え方は単純で、その文書での出現回数、つまり TF に、その語がコーパス全体でどれだけ珍しいかを表す係数、つまり IDF を掛けます。式にすると次のようになります。

\( \mathrm{tfidf}(t,d) = \mathrm{tf}(t,d)\times\log\frac{N}{\mathrm{df}(t)} \)

ここで \( t \) は語、\( d \) は文書、\( N \) は全文書数、\( \mathrm{df}(t) \) は語 \( t \) が出現する文書の数です。\( N/\mathrm{df}(t) \) は「全文書のうち何分の1の文書にしか出てこないか」を表すので、珍しい語ほど大きくなります。対数を取っているのは、この比が桁で効いてしまうのを抑えるためです。今回のコーパスで実際に計算すると次のようになりました。

dfN/dflog(N/df)
説明2275.291.665
請求1956.151.817
お願い1766.821.920
在庫6518.462.916
折り返し4129.273.377

頻度1位の説明は係数が最も小さい1.665、41文書にしか出ない折り返しは3.377と、2倍以上の差が付きます。この係数を掛けることで、多くの文書に薄く広がる語の点数が下がり、一部の文書に集中する語の点数が上がります。具体的に1件で見てみます。「先日、在庫ありの表示で注文したのに、後から在庫切れの連絡が来ました。」という文書は、前処理を通すと 在庫 表示 注文 在庫 連絡 来る の6語になります。文書内の語数で割った TF と、上の IDF を掛けた結果は次のとおりです。

tf(生)tf(正規化)dflog(N/df)tf-idf
在庫20.333652.9160.9719
来る10.167602.9960.4993
注文10.167652.9160.4859
連絡10.1671362.1770.3629
表示10.1671552.0470.3411

この文書では在庫が突出し、表示が最下位に落ちます。素の頻度なら在庫2回に対して表示1回で、差は2倍でした。TF-IDF では2.85倍に開いています。この文書が何について書かれたものかという問いに対して、後者のほうが素直な答えを返していると言えます。同時に、来る という一般的な動詞が2位に来ている点も見逃せません。このコーパスでは「連絡が来ました」という定型でしか使われないため df が60と小さく、結果として珍しい語と判定されています。TF-IDF は意味を理解しているわけではなく、分布の偏りを測っているだけだという事実が、こういうところに出ます。

実装では scikit-learn の TfidfVectorizer を使います。ただし、この実装は教科書の式そのままではありません。既定の smooth_idf=True のとき、実際に使われる係数は \( \mathrm{idf}(t) = \ln\frac{1+N}{1+\mathrm{df}(t)} + 1 \) です。分子と分母に1を足すのは、学習時に出現しなかった語で0除算が起きるのを防ぐためで、末尾に1を足すのは、全文書に出現する語の係数が0になって情報が完全に消えるのを防ぐためです。実測した差は次のとおりで、順序関係は変わりませんが値は約1ずれます。手計算の検算をするときにここでつまずきやすいので、覚えておくと役に立ちます。

df教科書の ln(N/df)sklearn の idf_
説明2271.6652.662
請求1951.8172.813
在庫652.9163.901
折り返し413.3774.353

TfidfVectorizerの主要引数は、語彙をどう削るかの設計である

日本語のテキストを TfidfVectorizer に渡すときは、第2章で作った分かち書き済みの文字列を入力し、トークンの区切りを空白に固定します。token_pattern を指定しないと、既定の正規表現 (?u)\b\w\w+\b が使われます。この \w はUnicodeモードでは漢字もかなもカタカナも含むので、英数字だけを拾うわけではありません。問題は \w\w+ という部分で、2文字以上の連なりしかトークンとして認めないため、「語」「件」「日」のような日本語の1文字語がまとめて落ちます。空白区切りをそのまま尊重させるには、token_pattern を明示的に上書きする必要があります。

from sklearn.feature_extraction.text import TfidfVectorizer

# docs は「請求 金額 見積もり 違う」のように空白で区切った文字列のリスト
vec = TfidfVectorizer(
    token_pattern=r"(?u)\S+",   # 空白区切りをそのままトークンとして扱う
    min_df=5,                    # 5文書未満にしか出ない語を捨てる
    max_df=0.5,                  # 50%を超える文書に出る語を捨てる
    sublinear_tf=True,           # tf を 1 + ln(tf) に置き換える
    norm="l2",                   # 文書ベクトルの長さを1に揃える
)
X = vec.fit_transform(docs)
print(X.shape)   # 出力: (1200, 178)

主要な引数の効き方を、同じコーパスで実際に変えながら測りました。まず min_df です。これは「この文書数より少ない語は語彙から外す」という下限で、整数なら文書数、小数なら割合として解釈されます。実測では、min_df を5にしても語彙は178語のまま変わりませんでした。20に上げて155語、50で63語、100で19語です。5で何も落ちなかったのは、先ほど触れたとおり、このコーパスに1回きりの語が存在しないためです。実データでは min_df=2 にするだけで語彙が半分以下になることも珍しくありません。落ちる語を確認すると、min_df=20 では原因、対処、自分、使う といった df が19の語が、min_df=50 では回答、通知、聞く、検索 といった df が45から49の語が消えました。min_df=100 まで上げると見積もり、配送、商品、設定 まで落ちるので、明らかに行き過ぎです。

次に max_df です。こちらは上限で、指定した割合を超える文書に出現する語を捨てます。ストップワードを人手で列挙する代わりに、分布から自動的に落とす仕組みだと考えるとわかりやすいと思います。第2章で、文書頻度が高いという理由だけで語を機械的に切ると危険だという話をしましたが、あの議論がそのまま1つの引数として実装されたものが max_df です。実測では、max_df=0.15 で説明、届く、請求、画面 の4語が落ち、max_df=0.10 では対応、お願い、料金、表示 を含む15語、max_df=0.05 では46語が除外されました。第2章でカテゴリ別の出現率まで見たとおり、ここで落ちる語はコーパスの中心語でもあります。閾値は、落ちる語の一覧を印字して業務を知る人の目を通したうえで決めるものであって、既定値のまま走らせて済ませられるものではありません。

sublinear_tf は、TF を生の回数ではなく \( 1 + \ln \mathrm{tf}(t,d) \) に置き換える指定です。同じ語が1文書に何度も出たときに、点数が回数に比例して伸びるのを抑えます。効き方を見るときは、文書ごとの正規化を外して norm=None で測る必要があります。norm="l2" のままだと同じ文書内の他の語との兼ね合いで値が動き、TFの伸び方だけを取り出せないからです。norm=None での実測では、請求 が1回だけ出た159文書のスコアが2.813、2回出た36文書のスコアが5.626と正確に2倍になるのに対し、sublinear_tf を有効にすると4.762に留まりました。ここに挙げた2.813、5.626、4.762は、いずれも正規化前の値です。2回目の言及が1回目と同じ重みを持つのはおかしい、という直感を式に落としたものです。クレームの文章は同じ語を繰り返す傾向があるので、VoC の分析では有効にしておくほうが安定します。

norm は、文書ごとのベクトルの長さを揃えるかどうかの指定です。既定の l2 では、どの文書もベクトルの長さがちょうど1.000になります。実測すると、l2 のときベクトル長と文書の語数の相関は プラス0.069 とほぼ無相関でしたが、norm=None にすると長さは平均11.466、最小5.928、最大23.959とばらつき、語数との相関は プラス0.878 まで上がりました。正規化しないと、長く書いた人の意見が一律に重く扱われることになります。文書間の類似度を測る場合や分類器に渡す場合は l2 のまま使うのが基本です。一方、カテゴリごとに合計スコアを出して「どのカテゴリの声が大きいか」を測りたい場合には、長さを揃えない選択もあり得ます。目的によって使い分ける引数です。

設定語彙数平均スコア上位5語
既定178届く 請求 説明 対応 料金
min_df=20155請求 届く 説明 画面 対応
max_df=0.10163プラン 遅れる 見積もり 商品 気
min_df=5, max_df=0.15174対応 料金 お願い プラン 表示
use_idf=False178届く 説明 請求 画面 対応

この表で確かめておきたいのは、引数を変えると上位に出る語がはっきり入れ替わるという事実です。max_df=0.10 の行では、上位が プラン、遅れる、見積もり に置き換わっています。同じデータ、同じ手法でも、パラメータの選び方だけで報告資料の結論が変わり得るということです。したがって、レポートには使ったパラメータを必ず併記し、複数の設定で結果が安定するかを確かめてから提出する運用が要ります。

カテゴリ別の特徴語抽出:実務で一番使う形

ここまでは文書1件ごとの TF-IDF でした。しかし実務で報告物として求められるのは、たいてい「この部門に来ている声は何が特徴なのか」「先月と今月で何が変わったのか」という、集団と集団の比較です。そこで使うのが、グループごとに TF-IDF の平均を取って上位語を並べる方法です。手順そのものは単純で、文書とカテゴリの対応が付いていれば数行で書けます。

import numpy as np

vec = TfidfVectorizer(token_pattern=r"(?u)\S+", min_df=5,
                      max_df=0.5, sublinear_tf=True)
X = vec.fit_transform(docs)          # 全文書で一度だけ学習する
names = vec.get_feature_names_out()

for cat in df["category"].unique():
    idx = np.where(df["category"].values == cat)[0]
    mean = np.asarray(X[idx].mean(axis=0)).ravel()   # カテゴリ内で列平均
    top = mean.argsort()[::-1][:10]
    print(cat, [names[i] for i in top])

重要なのは、fit_transform をカテゴリごとに実行しないことです。IDF はコーパス全体で1度だけ学習し、そのうえで行を選んで平均を取ります。カテゴリごとに学習し直すと IDF の基準がばらばらになり、カテゴリ間の比較ができなくなります。実行結果は次のとおりです。

5つのカテゴリごとにTF-IDF平均が高い上位10語を横棒グラフで並べた図
カテゴリ件数TF-IDF平均が高い語(上位8語)
料金・請求320請求 料金 プラン 見積もり 違う 金額 変更 内訳
配送・在庫261届く 商品 配送 在庫 注文 梱包 納期 連絡
不具合・エラー240表示 対応 通知 遅れる アプリ エラー ログイン 続く
使い方・操作211画面 設定 管理 読む 目的 機能 検索 終える
対応・接客168電話 説明 サポート 折り返し 問い合わせ 案内 窓口 たらい

比較のため、同じカテゴリを素の頻度で並べた結果も出しました。全体として似た顔ぶれになりますが、違いは確かに出ています。不具合・エラーでは、素の頻度だと 表示、対応、画面、アプリ、エラー、通知、届く の順になり、画面が3位、届くが7位に入ります。ところが TF-IDF ではこの2語が上位10語から消えました。画面は使い方・操作カテゴリが、届くは配送・在庫カテゴリが、それぞれより強く持っている語だからです。対応・接客でも、素の頻度なら1位だった説明が2位に下がり、代わりに電話が1位、サポートが5位から3位へ上がりました。説明はすべてのカテゴリに広く出る語なので、このカテゴリの特徴とは言いにくい、という判断がスコアに反映されています。

ここでもう一点、実務者としては見逃せない結果が出ています。対応・接客の8位に「たらい」という語が入りました。これは「窓口をたらい回しにされ」という表現が、形態素解析で たらい と 回す に分割された結果です。第2章で扱ったユーザー辞書の話が、そのままキーワード抽出の品質に跳ね返っています。特徴語の一覧に意味の通らない断片が混ざっていたら、それは辞書に登録すべき複合語を見つけたということです。頻度分析は、前処理の欠陥を検出する装置としても機能します。

この形の集計は、カテゴリを部署、商品、チャネル、月に差し替えるだけでそのまま使えます。前月比の分析であれば、月をグループとして特徴語を並べ、順位が大きく動いた語だけを拾えば、変化の兆しを機械的に検出できます。経営会議に持っていくときは、上位語の一覧に加えて、それぞれの語について実際の文書を数件添えるのが有効だと考えています。語だけでは何が起きているのか判断できないためで、その根拠となる原文の見せ方が、次の節の主題になります。

n-gram:単語1つでは見えない表現を拾う

ここまでは語を1つずつ独立に数えてきました。しかし日本語では、語を切り離した瞬間に意味が失われる場面が頻繁にあります。代表が否定です。「商品が届きました」と「商品が届きませんでした」は、内容語だけを残すとどちらも 商品 と 届く になり、区別が付きません。実際に測ると、届く を含む文書は223件あり、そのうち否定の文脈で使われているものが169件、割合にして75.8%でした。素の頻度表で「届く 240回」と書かれた行は、4件のうち3件が「届かない」という苦情だったわけです。

この問題への対処のひとつがn-gram、つまり連続するn個の単位をまとめて1つの特徴として数える方法です。ここでは、第2章の前処理に否定の助動詞だけを残す変更を加えました。「ん」「ぬ」「ない」を すべて ない に正規化して残し、他の助動詞は落とします。この状態で単語の2-gram、いわゆる bi-gram を数えた結果が次のとおりです。

順位bi-gram頻度
1届く ない181
2他社 切り替え125
3切り替え 検討125
4改善 お願い119
5早急 対応110
6わかる ない94
7料金 請求83
8プラン 変更80
9商品 届く70
10請求 内訳66

1位に 届く ない が181回で立ち、6位に わかる ない が94回で入りました。単語1語の集計では絶対に出てこなかった情報です。ない を後ろに持つ組み合わせは全部で28種類あり、確認 ない47回、読める ない45回、ログイン ない41回、連絡 ない40回、理解 ない37回、契約 ない37回、反映 ない35回と続きます。これらは業務の観点では、そのまま改善要望のリストとして読めます。同時に、2位から5位に定型文の断片が並んでいることにも注意が要ります。他社 切り替え と 切り替え 検討 が同じ125回なのは、「他社への切り替えを検討します」という1つの文から2つの bi-gram が取れているためで、実質的には125文書に付いた締め文が3行を占めていることになります。

ngram_range を変えると、語彙数と行列の密度が大きく変わります。実測値は次のとおりです。

ngram_range語彙数行列の非ゼロ率1文書あたり非ゼロ数
(1, 1)1784.69%8.35
(1, 2)8451.91%16.11
(2, 2)6671.16%7.76
(1, 3)19371.18%22.93
(3, 3)10920.62%6.82

1-gram だけの178語から、bi-gram を足すと845語へ、3-gram まで含めると1937語へと、語彙は一気に増えます。実データでは数十万から数百万の次元になることも普通です。増えた特徴のほとんどは数回しか出現しないので、n-gram を使うときは min_df をセットで指定して裾を切るのが定石になります。実際、min_df=10 を掛けて ngram_range=(1, 2) で学習すると語彙は430語に収まり、配送・在庫の上位には 商品 届く という bi-gram が単語に混ざって現れました。

もうひとつの選択肢が文字n-gramです。形態素解析を通さず、テキストをそのまま文字の並びとして切ります。CountVectorizeranalyzer="char" を指定するだけで使えます。実測では、文字2-gramで944語、3-gramで1368語、2から3の範囲で2312語になりました。頻度上位は「した。」1325回、「ました」1009回、「ます。」700回、「ません」615回と、活用語尾ばかりが並びます。人が読む資料にはそのまま使えません。

それでも文字n-gramには固有の強みがあります。辞書に載っていない語や表記ゆれをまたいで拾えることです。「たらい回し」は形態素解析で分割されてしまいましたが、文字列としてなら「たらい回」を含む文書が30件と直接数えられます。同様に「届きませ」41件、「ありませ」111件のように、活用を含む表現をそのまま計測できます。実務では、分類器の入力特徴として単語n-gramと文字n-gramを併用すると精度が上がることが多く、辞書整備が追いつかない立ち上げ期にはとくに有効な手段になります。人が読む報告には単語ベース、機械が読む特徴には文字ベースを混ぜる、という使い分けが現実的だと考えています。

KWIC:頻度表の1行を文脈で開く

頻度も TF-IDF も n-gram も、最後は数字の一覧です。数字だけを見て解釈すると、かなりの確率で読み違えます。これを防ぐために古くから使われてきたのがKWIC、KeyWord In Context の略で、対象の語を中央に固定し、その左右の文脈を揃えて並べて表示する方法です。専用ライブラリは不要で、文字列検索だけで実装できます。

def kwic(texts, keyword, width=14, ids=None):
    """左文脈・キーワード・右文脈の3つ組を返す。1文書に複数回出れば複数行になる。"""
    rows = []
    for k, t in enumerate(texts):
        start = 0
        while True:
            i = t.find(keyword, start)
            if i < 0:
                break
            rows.append({
                "doc_id": ids[k] if ids is not None else k,
                "left": t[max(0, i - width):i].rjust(width, " "),
                "key": keyword,
                "right": t[i + len(keyword):i + len(keyword) + width],
            })
            start = i + len(keyword)
    return rows

この関数で 満足 を引くと、107回の出現が107文書に分布していることがわかります。コーパスに正解として付いている感情ラベルで文書の内訳を数えると、好意的な声が74件、不満の声が33件でした。頻度表の「満足 107回」という1行は、実際には3割が不満の文書から来ていたことになります。右側の文脈を機械的に集計すると、理由は一目でわかります。

# 「満足」の右文脈を先頭12文字で分類した結果
#   74回  満足しています。
#   33回  満足できる品質ではありません

「満足しています」と「満足できる品質ではありませんでした」が、同じ 満足 という語に集約されていたわけです。極性辞書を使った感情分析でも、この2つを取り違える事故が起きます。詳しくは第5章で扱いますが、頻度表を作った段階で KWIC を通しておけば、その時点で気付ける類の誤りです。

同じことは他の語でも確認できます。丁寧 は81回、79文書に出ますが、そのうち34件は不満の文書でした。「梱包が丁寧で、商品に傷ひとつありません」という文と、「丁寧な対応とは思えませんでした」という文が同居しています。早 という文字を含む文書は180件で、そのうち110件が不満の文書です。中身を見ると、「折り返しの電話が早く」という好意的な用例より、「早急に対応していただきたいです」という要求の定型のほうが多くを占めていたためでした。早い が上位に来ているから対応速度は評価されている、と読むのは完全な誤読になります。

出現文書数不満の文書好意的な文書不満側の割合
満足107337430.8%
丁寧79344543.0%
1801107061.1%
56243242.9%
104842080.8%
2231962787.9%

KWIC は目視のためだけの道具ではありません。右文脈のパターンを機械的に数えれば、そのまま集計になります。届 という文字の出現240回について右側を分類すると、否定の形が181回で75.4%を占めました。内訳は「届かないことがあり」50回、「届かず、再配達の手続き」44回、「届かないまま」44回、そして「届きませんでした」で始まるものが合わせて43回です。この4つの合計がちょうど181回になります。残る59回はすべて「届きました」で始まる肯定形でした。同じ 届 でも、右に何が続くかで意味は正反対になります。

この集計には、注意すべき手続きが1つあります。何を否定と見なすかは、判定に使う文字列を自分で列挙して決めているという点です。最初に「かない」「きません」「きませんでした」だけを並べて数えたときは、「届かず」で始まる44回が肯定側に分類され、否定はいまより44回少なく出ていました。数え漏らした表現は消えるのではなく、そのまま反対側の数字になります。文字列で判定する集計を作ったら、内訳の合計が全体と一致するか、そして肯定側に分類された行を実際に目で見て否定文が混じっていないかを、必ず確かめる必要があります。

運用としては、特徴語の一覧を出したら、上位10語程度について KWIC を CSV に書き出し、業務担当者に読んでもらうところまでを1セットにするのが有効です。分析者は語の分布を見ますが、業務担当者は原文を見た瞬間に「これは去年の仕様変更の話だ」と特定できることがあります。左右の文脈を20文字ずつ、カテゴリとチャネルの列を添えた表にしておくと、そのまま会議の資料として機能します。カテゴリをまたいで広く出る語ほど、この確認の価値が高くなります。今回のデータでは、確認 は料金・請求だけ、変更 も料金・請求だけに出る語なので解釈の幅が狭い一方、説明 と 対応 は5カテゴリすべてに出るので、文脈を見ずに解釈するのは危険です。

ワードクラウドの功罪

テキストマイニングの報告資料でおそらく最もよく使われるのが、ワードクラウドです。頻出語を大きく、そうでない語を小さく描いて敷き詰めた図で、見た目のインパクトがあり、専門知識のない相手にも「テキストを分析した」ということが一目で伝わります。この点は本当の利点で、否定するつもりはありません。問題は、その図が定量的な情報をほとんど運ばないにもかかわらず、運んでいるように見えることです。

実際に測ってみました。上位45語でワードクラウドを生成し、描画された各語のフォントサイズと、おおよその占有面積を取り出して、元の頻度と突き合わせます。ここでいう概算面積は、フォントサイズの2乗に文字数を掛けた値です。文字を正方形と見なして横に並べたときの近似になっています。結果は次のとおりです。

出現回数フォントサイズ概算面積面積÷頻度
説明242258133128550.1
届く240251126002525.0
請求231224100352434.4
画面21613636992171.3
対応19412430752158.5
料金18610622472120.8
お願い176711512385.9
表示16368924856.7

この45語の中で、頻度の最大と最小の比は3.67倍でした。ところがフォントサイズの比は10.32倍、占有面積の比にいたっては122.25倍です。頻度が3.67倍しか違わないものが、面積では122.25倍の差として描かれています。とくに顕著なのが説明242回と画面216回の関係で、頻度の差はわずか11%なのに、フォントサイズは258と136でほぼ半分、面積は3.6倍の開きになりました。人間の目は面積で量を読むので、この図を見た人は画面の問題を実際の3分の1程度に見積もることになります。

2つ目の歪みは、文字数が面積に直接効くことです。面積を頻度で割った値は、この45語の中で最大と最小が58.60倍も違いました。具体例を挙げると、料金186回の面積が22472に対して、お願い176回の面積は15123です。頻度は料金のほうが10回多いだけですが、面積は1.49倍あります。逆に、改善138回の面積7938に対してプラン131回の面積は3468で、頻度差はわずか7回なのに面積は2.29倍の開きです。3文字の語は2文字の語より横に長くなるので、同じフォントサイズでも面積が1.5倍になります。頻度と面積の相関係数は0.807、順位相関で0.848と、無関係ではないものの、量の比較に使える精度ではありません。

3つ目の、そして実務上いちばん重い問題が、2枚のワードクラウドを比較できないことです。フォントサイズはその図の中での相対値として決まるので、絶対的な頻度の情報は最初から捨てられています。実測した例を挙げます。料金・請求カテゴリの320件でワードクラウドを描くと、最大の語は 請求 で、実際の出現回数は231回、割り当てられたフォントサイズは242でした。対応・接客カテゴリの168件で描くと、最大の語は 説明 で、実際の出現回数は67回、フォントサイズは255です。231回の語と67回の語が、ほぼ同じ大きさで描かれています。この2枚を並べて「どちらの問題が大きいか」を判断することは原理的にできません。にもかかわらず、カテゴリ別や月別のワードクラウドを並べた資料は珍しくありません。

4つ目は、配置に意味がないことです。多くの実装は、大きい語から順に、空いている場所を探して詰め込むアルゴリズムで座標を決めます。乱数のシードだけを変えて3回描画すると、同じ語の座標はまったく別の場所に移りました。隣り合って描かれた2語に意味的な関係はありませんし、中央にある語が周辺の語より重要というわけでもありません。語と語の関係を図で示したいのであれば、共起関係を明示的に計算してネットワークとして描く必要があります。その方法は第4章で扱います。

それでもワードクラウドを使うなら

ここまで欠点を並べましたが、ワードクラウドを一律に禁止するのは現実的ではないと考えています。分析結果を初めて見る相手の関心を引く力は確かにありますし、「このデータには何が書かれているのか」という最初の一歩を共有する用途では役に立ちます。使うのであれば、次の4つを守れば実害はかなり抑えられます。

  • 必ず数表を添える。図の隣に、上位15語程度の出現回数、出現文書数、文書割合を並べた表を置く。図で関心を引き、表で数字を確定させる分業にする
  • 比較には使わない。前後比較やカテゴリ比較が目的なら、横棒グラフか折れ線グラフを使う。どうしてもワードクラウドを並べるなら、共通の語彙と共通のスケールで描いたうえで、差分の数表を必ず添える
  • 前処理を通した語だけを入れる。定型のあいさつや締め文句を落とさずに描くと、図の中央に「お願い」や「恐れ入る」が鎮座することになる
  • 頻度ではなく TF-IDF やカテゴリ間の差分スコアで描く。素の頻度で描くと、どのカテゴリで描いても似た顔になりやすい

次の図は、同じ上位語について、左にワードクラウド、右に横棒グラフを並べたものです。左を見ると 説明 と 届く が支配的で、料金 は中程度に見えます。右を見れば、242回、240回、231回、216回、194回、186回と、上位6語がほぼ横並びであることが即座にわかります。同じデータから作った2枚が、まったく違う印象を与えます。

左に頻出語のワードクラウド、右に同じデータの横棒グラフを並べた比較図

横棒グラフに添える数表は、出現回数だけでなく、出現文書数と、その語が最も多く出るカテゴリとその占有率まで入れておくと、読み手が誤解しにくくなります。たとえば 請求 は195文書に出て、その100%が料金・請求カテゴリなので解釈は素直です。一方 お願い は176文書に出るものの、最頻カテゴリでも占有率は25%にすぎず、5カテゴリに均等にばらけています。この2語を同じ棒グラフに並べるなら、後者には「定型文由来」という注記が要ります。数表があれば、その判断が読み手の側でも可能になります。

素の頻度・TF-IDF・カテゴリ別TF-IDF・n-gram・KWICの5手法と、それぞれが答えられる問いの対応図

本章では、テキストを数える方法を5段階で確認しました。素の頻度は「何が多いか」だけを答え、定型文とコーパスの構成比に強く引きずられます。TF-IDF は語の珍しさで重みを付け直す方法で、min_dfmax_df は語彙をどう削るかの設計そのものです。カテゴリ別の TF-IDF 平均は、実務の報告で最もよく使われる形になります。n-gram を使えば「届く ない」のような、単語1つでは消えてしまう表現を拾えます。そして KWIC で原文の文脈に戻らないかぎり、頻度表は容易に誤読されます。最後に、ワードクラウドは面積が頻度に対応せず、2枚を比較できないという性質を実測で確かめ、それでも使う場合の条件を整理しました。

ワードクラウドの3つの限界と、代わりに使う図の対応を整理した表

ここまでは、語を1つずつ、あるいは連続するn個をまとめて数えてきました。しかし顧客の声を読み解くうえで本当に知りたいのは、しばしば「どの語とどの語が一緒に出てくるか」です。請求と説明が同じ文書に出るとき、そこには「請求の説明がない」という具体的な不満があります。語と語の関係を測る方法、すなわち共起行列、Jaccard係数、自己相互情報量、そして共起ネットワークについては、第4章で扱います。

この章を深めたい方への参考書籍

『情報検索と言語処理』(徳永健伸、東京大学出版会):本章で扱った TF-IDF は、もともと情報検索の分野で文書を検索語に対して順位付けするために考案された指標です。本書は日本語の文献として、索引語の重み付け、検索システムの性能評価、言語処理技術の応用までを体系的に扱っています。TF-IDF をなぜその式で定義するのか、他にどのような重み付けがあるのかを根本から確認したい読者に向いています。

『Pythonではじめる 情報検索プログラミング』(佐藤進也、森北出版):文字、語、特徴語という順に積み上げる構成で、TF-IDF のような重み付けが「なぜその式になるのか」を実装しながら確認できます。本章で扱った語の数え方と重み付けの土台を、検索という応用側から固め直したい読者に向いています。

『Rによるやさしいテキストマイニング』(小林雄一郎、オーム社):基礎編・準備編・実践編の三部構成で、データの準備と集計結果の見せ方に多くの紙幅を割いています。本章で扱った頻度集計とその可視化を、別の実装環境から確認して型として身につけたい読者に向いています。

第4章 共起分析とネットワーク

第3章では、単語の頻度とTF-IDFを使って「どの語が目立つか」を測りました。目立つ語のリストは、報告書の冒頭に置く材料としては十分に役立ちます。ただしそこで得られるのは、あくまで語を1つずつ切り離して数えた結果です。「配送」が上位に来たことは分かっても、その「配送」がどんな語と一緒に語られていたのかは、頻度表からは読み取れません。本章では、語を単独で数えるのをやめて、語と語の組を数えます。使うデータは第2章で用意したサンプルVoCデータで、1,200件の架空の顧客の声です。

関係を数えると何が変わるのか、先に結論の形を示しておきます。頻度分析では「配送」「遅れる」「在庫」がそれぞれ上位に並ぶだけですが、関係を数えると「在庫と注文は同じ声の中でよく一緒に出るが、在庫と請求はほとんど一緒に出ない」という構造が見えます。この構造は、問い合わせの分類軸を作るときにも、改善の優先順位を議論するときにも、頻度表より直接的に効いてきます。

頻度から関係へ:共起という考え方

共起とは、2つの語が同じ範囲の中に一緒に現れることを指します。「同じ範囲」をどう定義するかは分析者が決める設計事項で、そこが結果を大きく左右します。もっとも素朴な定義は「同じ文書の中に両方出てきたら共起1回と数える」というもので、VoCのように1件が数十文字から数百文字の短いテキストであれば、この定義がまず基準になります。

共起を数えるという発想は、単語の意味をその周辺の語から捉えようとする考え方につながっています。「請求」という語だけを見ても、それが料金の高さの話なのか、請求書の書式の話なのか、二重請求の話なのかは決まりません。しかし「請求」と一緒に「内訳」「わかる」が出ていれば書式の話に寄り、「請求」と「二重」が出ていれば重複の話に寄ります。周辺の語が、その語の使われ方を特定していきます。第8章で扱う単語ベクトルも、この「周辺の語がその語を規定する」という前提の上に立っています。

実務で共起分析が効くのは、頻度分析の結果を見た人から「で、それはどういう文脈で言われているのか」と必ず問われるからです。頻度表を渡すと、その次に来る問いはほぼ決まって文脈の問いになります。共起は、その問いに対して原文を全部読み返す以外の答え方を用意する手段だと考えています。

共起の単位:文書単位・文単位・窓幅

共起の範囲の取り方には、大きく3つあります。1つ目は文書単位で、1件のテキスト全体を1つの範囲とみなします。2つ目は文単位で、句点で区切った1文を範囲とします。3つ目は窓幅による定義で、分かち書きした語の並びの上で、ある語から前後何語以内に出た語だけを共起とみなします。

この3つは、同じデータに適用しても違う数字を返します。サンプルVoCデータで実際に数えたところ、文書単位では1,200件、文単位では2,547文が処理単位となり、1文あたりの内容語は平均3.91語でした。代表的な語のペアについて、単位を変えたときの共起数は次のようになりました。

語のペア文書単位文単位窓幅1窓幅2窓幅5
料金 × 請求9083838383
配送 × 遅れる70000
請求 × 配送00000
マニュアル × 設定44460046
エラー × 表示5052333352
他社 × 検討1251250125125
追跡 × 番号3435353535

この表は、単位の選び方が結論を変えることを端的に示しています。「配送 × 遅れる」は文書単位では7件ありますが、文単位では0件です。つまりこの2語は同じ声の中に出てはいるものの、同じ文の中で結びついてはいません。配送の話と遅れの話が別々の文で語られている、あるいは「配送」という語と「遅れる」という語が別の主題に属している、ということです。文書単位だけを見て「配送の遅れが問題だ」と言い切ると、この違いを見落とします。

逆に「マニュアル × 設定」は文単位で46件ありますが、窓幅1と窓幅2では0件です。この2語は同じ文にはあるものの、語の並びの上では3語以上離れています。「マニュアルを読んでも、初期設定を終えられませんでした」のような文では、2語の間に別の語が挟まります。窓幅を狭く取ると、こうした離れた関係は落ちます。「他社 × 検討」が窓幅1で0、窓幅2で125という結果も同じ理由で、この2語の間には常に「切り替え」が挟まっています。

使い分けの目安は次のように考えています。VoCや問い合わせのように1件が短く、1件全体が1つの話題であることが多いデータでは、文書単位が扱いやすく解釈もしやすいです。1件が長く複数の話題を含むレビューや議事録では、文単位に落としたほうが、無関係な語同士が共起として拾われる誤りを減らせます。窓幅は、語の並びの近さ自体に意味がある用途、たとえば複合的な言い回しや定型表現を見つけたいときに向きます。なお日本語の場合、助詞や助動詞を除いてから窓を取ると語と語の実際の距離が縮むため、窓幅の数字は前処理の設定と切り離して解釈できません。

文書単位・文単位・窓幅という共起範囲の取り方3種類を比べた概念図

共起行列を行列積で作る

共起の数え方が決まれば、あとは計算です。実装で使うのは共起行列で、語彙数を \( V \) としたとき \( V \times V \) の正方行列になります。行 \( i \) 列 \( j \) の値が、語 \( i \) と語 \( j\) が一緒に現れた単位の数です。

この行列は、二重ループで数えなくても行列積で一度に求まります。文書 \( d \) に語 \( i \) が現れたら1、現れなければ0を入れた出現行列 \( X \) を作ると、\( X^\top X \) の \( (i, j) \) 成分は「語 \( i \) と語 \( j \) の両方が1である文書の数」になります。ここで大事なのは、\( X \) を出現回数ではなくバイナリにしておくことです。回数のままだと、1つの文書に同じ語が3回出た場合に共起数が水増しされ、文書の数ではなく語の延べ回数を数えることになってしまいます。scikit-learnの CountVectorizer には binary=True という引数があり、これを指定するだけでバイナリ化できます。

from sklearn.feature_extraction.text import CountVectorizer

# doc_units は文書ごとの内容語リスト(第2章の前処理を通したもの)
vec = CountVectorizer(analyzer=lambda x: x, binary=True, min_df=10)
X = vec.fit_transform(doc_units)          # (1200, 180) の 0/1 行列
vocab = vec.get_feature_names_out().tolist()
C = (X.T @ X).toarray()                   # (180, 180) の共起行列

# 対角成分は「その語が出た文書数」になる
# 出力: 出現行列 X: (1200, 180) / 共起行列 C: (180, 180)
# 出力: min_df=10 での語彙数: 180

analyzer=lambda x: x は、すでに分かち書き済みのリストをそのまま語として扱わせるための指定です。日本語では英語のような空白区切りが使えないので、第2章の形態素解析の結果を渡す形になります。min_df=10 は、10件未満の文書にしか出ない語を捨てる設定です。共起行列は語彙数の2乗の大きさになるため、語彙を絞らないと行列がすぐに巨大になります。

この行列の対角成分には、その語が出現した文書の数が入ります。あとで関連度を計算するときに各語の出現数が必要になるので、別に数え直さず対角から取れば済みます。サンプルVoCデータでは、延べ語数9,967、異なり語数180となり、180語すべてが15件以上の文書に出ていました。語彙が180語であれば語のペアは16,110通りで、そのうち共起が1件以上あるのは5,200ペアでした。全体の3分の1弱にしか関係がなく、残りは一度も同じ声に出てきていないことになります。

生の共起数の限界と、関連度の3指標

共起行列ができたので、値の大きい順にペアを並べてみます。結果は次のとおりでした。

順位語のペア共起文書数語Aの出現文書数語Bの出現文書数
1切り替え × 検討125125125
2他社 × 検討125125125
3他社 × 切り替え125125125
4お願い × 改善122176137
5対応 × 早急110178110
6料金 × 請求90165195
7対応 × 届く75178223
8変更 × 料金7575165

上位に並んだのは、要するによく出る語同士です。「改善をお願いします」「早急に対応していただきたいです」といった定型の言い回しに含まれる語が、そのまま上位を占めています。これは共起分析の最初の壁で、生の共起数は語の頻度そのものに強く引きずられます。頻度の高い語同士は、内容に関係がなくても偶然一緒に出る機会が多いからです。

問題なのは、この順位が「関係が強い」ことを意味しないという点です。「対応 × 届く」は75件で7位ですが、「対応」は178件、「届く」は223件の文書に出ています。それぞれ単独でこれだけ出ていれば、両方が同じ文書に出る回数が75件あることは特別な出来事ではありません。一方で「切り替え × 検討」は125件で、どちらの語も125件しか出ていません。つまり片方が出れば必ずもう片方も出ています。同じ「共起数が大きい」でも、この2つはまったく違う現象です。

この違いを扱うために、共起の回数そのものではなく、2語がどれだけ結びついているかを表す関連度の指標を使います。指標はいくつもありますが、実務でよく使われるのは次の3系統です。

1つ目はJaccard係数です。語 \( a \) が出た文書の集合を \( A \)、語 \( b \) が出た文書の集合を \( B \) としたとき、次の式で定義されます。

\( J(a,b) = \frac{|A \cap B|}{|A \cup B|} \)

分子は両方に出た文書数、分母は少なくとも一方に出た文書数です。値は0から1の間に収まり、片方が出れば必ずもう片方も出るとき1になります。頻度の絶対値ではなく「どちらかが出たうちの何割で一緒に出たか」を見るので、頻出語同士が自動的に上位に来ることはありません。

2つ目はDice係数で、次の式になります。

\( D(a,b) = \frac{2|A \cap B|}{|A| + |B|} \)

分母が和集合ではなく単純な和になっている点だけがJaccard係数と違います。Dice係数はJaccard係数の単調増加関数なので、順位は完全に一致します。実際にサンプルVoCデータで計算しても、Jaccard係数の上位10ペアとDice係数の上位10ペアは同じ顔ぶれ、同じ順序でした。値の絶対値は違いますが、「どのペアが上位か」だけを見るなら、この2つは同じ指標だと思って差し支えありません。使い分けの実質的な理由は、Dice係数のほうが値が大きめに出るので閾値を直感的に置きやすい、という程度です。

3つ目が自己相互情報量で、PMIと略します。これは「2語が独立に出ていると仮定したときに期待される共起確率」と「実際の共起確率」の比を対数で測ります。

\( \mathrm{PMI}(a,b) = \log \frac{p(a,b)}{p(a)p(b)} \)

文書数を \( N \)、語 \( a \) の出現文書数を \( n_a \)、共起文書数を \( n_{ab} \) とすると、実装上は \( \log_2 \frac{N \cdot n_{ab}}{n_a n_b} \) を計算することになります。値が0なら独立、正なら期待より多く一緒に出ている、負なら期待より少ない、と読みます。JaccardやDiceが「割合」を見るのに対し、PMIは「偶然と比べてどれだけ多いか」を見る指標です。

PMIには広く知られた弱点があります。低頻度語を過大に評価することです。式を見ると、分母に \( n_a n_b \) が入っているので、出現数の小さい語同士がたまたま数回一緒に出ただけで、PMIは大きな値になります。極端な例として、2件の文書にしか出ない語が2件とも同じ相手と共起していれば、そのペアが取りうる最大の値になります。これは統計的な裏付けのない偶然かもしれないのに、指標の上では最上位に来ます。なお指標全体で見れば、もっと珍しい語のペアがさらに高い値を取ります。PMIの絶対値が高いことは、そのペアが強く結びついていることの証明にはなりません。

補正の方法はいくつかあります。もっとも簡単なのは、共起数に下限を設けて、そこに届かないペアを最初から除外することです。もう1つはPPMIで、負の値を0に切り上げます。

\( \mathrm{PPMI}(a,b) = \max(\mathrm{PMI}(a,b), 0) \)

PPMIは、負のPMIが「一緒に出ない」ことの証拠として弱いという事情に対応しています。2語が独立より少なく共起していることを確認するには、独立より多く共起していることを確認するよりずっと多くのデータが要ります。そこで負の側は情報がないものとして扱います。ただしPPMIは低頻度語の過大評価そのものを解決するわけではないので、共起数の下限と併用するのが実務的です。サンプルVoCデータでPPMIを計算したところ、16,110ペアのうち12,620ペアが0に丸められました。全体の78%ほどが「関係の証拠なし」に潰れる計算で、行列を疎に保つ効果としては大きいと言えます。

import numpy as np

def metrics(C, n_units):
    df = np.diag(C).astype(float)          # 各語の出現単位数
    both = C.astype(float)                 # 共起数
    union = df[:, None] + df[None, :] - both
    with np.errstate(divide="ignore", invalid="ignore"):
        jac = np.where(union > 0, both / union, 0.0)
        dice = np.where((df[:, None] + df[None, :]) > 0,
                        2 * both / (df[:, None] + df[None, :]), 0.0)
        pmi = np.log2(np.where(both > 0,
                               both * n_units / (df[:, None] * df[None, :]), 1.0))
    pmi = np.where(both > 0, pmi, -np.inf)  # 共起0のペアは対象外にする
    ppmi = np.maximum(pmi, 0.0)
    ppmi = np.where(np.isfinite(ppmi), ppmi, 0.0)
    for M in (jac, dice, ppmi):
        np.fill_diagonal(M, 0.0)
    np.fill_diagonal(pmi, -np.inf)
    return {"jaccard": jac, "dice": dice, "pmi": pmi, "ppmi": ppmi}

3つの指標を同じデータで比べる

指標の性格の違いは、説明を読むより実際に計算して並べたほうが早いです。同じ共起行列に3つの指標を当てて、生の共起数で上位だったペアが各指標では何位になるかを調べました。

語のペア共起文書数共起数の順位Jaccardの順位Diceの順位PMIの順位
切り替え × 検討12516565355
他社 × 検討12523939356
お願い × 改善1224107107554
対応 × 早急1105116116472
料金 × 請求906290290955
対応 × 届く7573963961548
プラン × 変更75107676241

「対応 × 届く」は共起数で7位でしたが、Jaccardでは396位、PMIでは1548位まで落ちます。両方とも頻出語なので、共起数が大きいのは当たり前だった、という判定です。逆にPMIの上位10ペアを見ると、「アップデート × 動く」「アップデート × 安定」「動く × 安定」がいずれもPMI 6.32で並び、共起数はどれも15件、共起数での順位は600位台でした。PMI上位10ペアを構成する語の出現文書数は15件から18件で、語彙全体の出現文書数が最小15件、中央値38件、最大227件であることを踏まえると、PMIの上位は語彙の中でもっとも珍しい語で占められていることが分かります。低頻度語を持ち上げるという弱点が、そのまま数字に出ています。

この傾向は順位相関でも確認できます。共起が1件以上ある5,200ペアで計算したところ、生の共起数とJaccard係数の順位相関は0.826、生の共起数とPMIの順位相関は0.474でした。またペアを構成する2語のうち出現数の少ない側とPMIの順位相関は-0.394で、語が珍しいほどPMIが高くなる関係がはっきり出ています。同じ計算をJaccard係数で行うと0.078で、ほぼ無関係でした。Jaccard係数は語の頻度に対して中立で、PMIは低頻度側に偏る、と読めます。

共起文書数とJaccard係数、共起文書数とPMI、語の出現文書数とPMIの関係を示した3枚の散布図

PMIを使うなら、共起数の下限をかけるのが現実的です。共起20件以上に限ったうえでPMIの上位を取り直すと、「チャット × 解決」「不具合 × 修正」「フォーム × 返信」といった、意味の上でも結びつきが自然なペアが並びました。閾値の20という数字に根拠があるわけではなく、データの規模と語彙の分布を見て決める調整項目です。1,200件のコーパスで20件なら全体の1.7%程度で、これ以下だと偶然の影響が無視できないと判断しました。

ここでもう1つ、実際に計算したからこそ出てきた注意点があります。Jaccard係数の上位10ペアは、すべて値が1.0でした。「CSV × 出力」「追跡 × 番号」「値上げ × 上がる」といったペアで、片方が出れば必ずもう片方も出ています。16,110ペアのうち100ペアがこの状態でした。これはサンプルVoCデータが定型文を組み合わせて生成された架空のデータであることの反映で、実データでここまできれいに1.0が並ぶことは多くありません。ただし実データでも、Jaccard係数が極端に高いペアが出たときの読み方は同じです。その2語は分析対象としては1つの単位であり、形態素解析が複合語を分割してしまった結果である可能性が高いです。第2章で扱ったユーザー辞書に登録すべき候補を、共起分析が教えてくれている、という受け取り方ができます。

常に一緒に出るペアを除いてJaccard係数の上位を取り直すと、「折り返し × 電話」が0.745、「見積もり × 金額」が0.727、「アプリ × 更新」が0.727、「内容 × 崩れる」が0.700と続きました。値が1.0未満のこの帯が、実際に解釈の対象になる関係です。

共起ネットワークを描く:閾値と最小頻度で図は別物になる

関連度が計算できたら、共起ネットワークとして可視化します。語をノード、関連度が閾値以上のペアをエッジとするグラフを作り、力学モデルで配置します。Pythonでは networkx を使います。

import networkx as nx
from networkx.algorithms.community import greedy_modularity_communities

def build_graph(C, vocab, jac, min_df, threshold):
    df = np.diag(C)
    keep = [i for i in range(len(vocab)) if df[i] >= min_df]
    G = nx.Graph()
    for i in keep:
        G.add_node(vocab[i], df=int(df[i]))
    for a in range(len(keep)):
        for b in range(a + 1, len(keep)):
            i, j = keep[a], keep[b]
            if jac[i, j] >= threshold:
                G.add_edge(vocab[i], vocab[j], weight=float(jac[i, j]))
    return G

G = build_graph(C, vocab, jac, min_df=50, threshold=0.20)
G.remove_nodes_from([w for w in list(G.nodes()) if G.degree(w) == 0])
coms = list(greedy_modularity_communities(G, weight="weight"))

# 出力: ノード数: 65 / エッジ数: 112
# 出力: 孤立ノード数: 5 / 孤立を除いたノード数: 60
# 出力: 連結成分数: 3 / 密度: 0.0633 / 検出コミュニティ数: 10

ここで決めているパラメータは2つです。1つは min_df で、何件以上の文書に出た語をノードとして採用するかを決めます。もう1つは threshold で、Jaccard係数がいくつ以上のペアをエッジとして残すかを決めます。この2つの値を動かすと、図はまったく別のものになります。

サンプルVoCデータの共起ネットワーク図。ノードは語、エッジはJaccard係数0.20以上のペア、色はコミュニティを表す

上の図は最小出現数50件、Jaccard係数0.20以上という設定で描いたものです。65語のうち5語はどの語ともエッジを持たなかったので除き、60語112本のグラフになりました。密度は0.0633で、あり得るエッジの6%程度しか引かれていません。連結成分は3つで、大部分の語が1つのかたまりにつながり、「他社・検討・切り替え」と「お願い・改善・機会」が独立した小さな島になりました。

共起ネットワークを扱ううえで、もっとも注意が要るのがパラメータ依存です。同じデータ、同じ関連度指標を使っていても、最小出現数と閾値を動かすだけで図の見た目は劇的に変わります。実際に4通りの設定で描き比べました。

最小出現数Jaccard閾値ノード数エッジ数孤立を除くノード数連結成分数コミュニティ数
25件0.051411019141112
25件0.20141326136817
50件0.206511260310
50件0.356532371313
最小出現数とJaccard閾値の4通りの組み合わせで描いた共起ネットワークの比較図

閾値を0.05まで下げると、141語すべてが1つの連結成分になり、エッジは1,019本引かれます。図としては密に絡み合った塊になり、どの語がどの語と結びついているかは目で追えません。逆に閾値を0.35まで上げると、エッジは32本しか残らず、37語が13個の断片に分かれます。それぞれの断片は明確ですが、全体の構造は見えません。同じデータから、「すべてがつながっている」という図と「ほとんどつながっていない」という図の両方が作れてしまうということです。

この性質は、共起ネットワークが恣意的だという批判の根拠にもなっています。実際、閾値を動かして「見栄えのする図」を選ぶことは技術的には簡単です。だからこそ、分析の記録として最小出現数と閾値を必ず明示し、値を選んだ理由を書き残すべきだと考えています。「エッジ数が100本前後になるように調整した」「連結成分が1つにならない範囲で最小の閾値を取った」といった選び方でも、書いてあれば読み手は再現できますし、別の値で描き直すこともできます。書かずに図だけを出すのが、いちばん避けたい形です。

実務的な目安として、ノード数は50から80、エッジ数は100から200あたりに収めると、A4に貼って読める図になりやすいです。それより多いと読めず、少ないと情報が足りません。ただしこれは図の可読性の話であって、分析として正しい設定という意味ではありません。読めるようにするための調整と、意味のある関係を選ぶための調整は別物です。

中心性とコミュニティ:図から何を読むか

図を眺めるだけでは主観が入るので、グラフの構造は数値でも押さえます。よく使うのが中心性で、種類がいくつかあります。次数中心性は、その語が何本のエッジを持つかを、あり得る最大本数で割った値です。多くの語と結びついている語ほど高くなります。

次数中心性次数出現文書数
見積もり0.1356899
在庫0.1186765
支払い0.1186766
表示0.11867155
請求0.11867195

もう1つが媒介中心性で、任意の2語を結ぶ最短経路のうち、その語を通るものの割合を測ります。自分自身の結びつきは多くなくても、離れたかたまり同士をつなぐ位置にある語が高くなります。

媒介中心性次数出現文書数
請求0.19417195
表示0.17457155
商品0.1136591
在庫0.1007765
違う0.0972678
管理0.0914554
画面0.09063195

2つの表を比べると、指標が測っているものの違いが分かります。「見積もり」は次数中心性で1位ですが、媒介中心性の上位10には入っていません。金額まわりの語と密に結びついている一方、そのかたまりの外へ橋を架けてはいない、ということです。逆に「画面」は次数が3しかないのに媒介中心性が0.0906で7位に入りました。少ないエッジが、たまたま別々のかたまりをつないでいる位置にあります。次数7の「在庫」と次数3の「画面」が媒介中心性では0.1007と0.0906で大きく変わらないという事実が、2つの指標が別のものを測っていることを端的に示しています。

実務では、媒介中心性が高い語を「話題の交差点」として扱うと、結果を説明しやすくなります。「請求」は料金の話にも配送の話にも顔を出す語で、そこを改善すると複数の話題に効く可能性がある、という読み方です。ただしこれは仮説の出発点であって、それ自体が結論ではありません。中心性が高い語を選んで原文を読み直すという使い方が、いちばん堅いです。

コミュニティ検出は、エッジの密なノード群を自動でグループに分ける処理です。networkx の greedy_modularity_communities は、モジュラリティという指標(同じ条件でランダムにつなぎ直した場合と比べて、グループ内にエッジがどれだけ多く集まっているかを測る値)を貪欲法で最大化する手法で、グループ数を指定しなくても分割してくれます。上の設定では60語が10個のコミュニティに分かれました。次の表には、そのうち語数の多い上位8個を挙げます。

コミュニティ語数含まれる語
114届く、対応、利用、早急、配送、商品、遅れる、丁寧、思う、ない、状態、納期、契約、梱包
211見積もり、状況、違う、金額、案内、早い、支払い、高い、計画、立てる、電話
36請求、料金、プラン、変更、手続き、反映
46表示、連絡、注文、在庫、来る、エラー
55画面、設定、読む、管理、使える
65読める、内容、担当、シンプル、変わる
74かかる、わかる、確認、内訳
83他社、検討、切り替え

表に載せていない残り2個は、どちらも3語のまとまりで、「お願い、改善、機会」と「データ、登録、アプリ」でした。コミュニティ1は配送と対応の話題、コミュニティ3は料金とプラン変更の話題、コミュニティ5は管理画面の操作の話題と読めます。もとのデータにカテゴリのラベルが付いていることを知らなくても、この分割からおおよその話題構造が推測できます。ただしコミュニティ検出のアルゴリズムは複数あり、手法が違えば分割も変わります。またグラフのパラメータを変えれば、上の表で見たとおりコミュニティ数は10から17まで動きます。「このデータには10個の話題がある」と言い切れる性質のものではありません。話題の抽出を主目的にするなら、第6章で扱うトピックモデルのほうが直接的です。

共起ネットワークの誤読を避ける

共起ネットワークは見た目のインパクトが強く、報告資料に載せると場が盛り上がりやすい図です。だからこそ、誤読されやすくもあります。よくある誤読を3つ挙げます。

1つ目は、線を因果として読むことです。「配送」と「解約」の間に線があっても、それは「配送の問題が解約を引き起こす」という意味ではありません。共起が示すのは、2つの語が同じ範囲に一緒に現れたという事実だけで、そこに向きも順序も時間も含まれていません。因果を主張するには、時系列のデータを取って前後関係を確かめるか、施策を打って前後を比べるかが要ります。共起ネットワークは因果の証拠ではなく、どこを調べるかを決めるための道具です。

2つ目は、レイアウトの座標に意味を読み取ることです。力学モデルによる配置は乱数の初期値から始まる反復計算なので、乱数の種を変えれば座標は変わります。同じグラフを3通りの乱数で配置してみると、「料金」の座標はそれぞれ、横 +0.583 縦 +0.511、横 -0.510 縦 -0.093、横 -0.820 縦 -0.016 となりました。一方でエッジの数は112本のまま、「料金」の次数は3のままです。つまり「右上にあるから重要」「中央にあるから中心的」といった読み方は成立しません。図の上で意味を持つのは、線がつながっているかどうかと、その太さだけです。位置は計算の副産物です。

3つ目は、図そのものを結論にすることです。共起ネットワークは、次に何を調べるかを決めるための入口であって、そこから何が言えるかは原文に戻らないと決まりません。図の上で「請求」と「わかる」がつながっていることを見つけたら、そのペアを含む原文を実際に読みに行きます。第3章で扱ったKWICは、まさにこの用途に使えます。図を作って終わりにすると、解釈の部分がすべて読み手の想像で埋められてしまいます。

もう1つ細かい注意として、エッジがないことは関係がないことの証明ではありません。閾値以下だっただけかもしれませんし、語彙の絞り込みで片方の語が落ちていたのかもしれません。「線がない=無関係」と読まれると困る場面では、閾値の値を図の中に書き込んでおくのが確実です。

係り受け:共起では取れない「何が どうした」

共起の限界は、方向がないことです。「商品」と「届く」に線が引かれていても、商品が届いたのか届かなかったのかは分かりません。「料金」と「上がる」も同じで、値上げへの不満なのか、単に料金体系の説明なのかは共起からは判定できません。これを解決する方向にあるのが係り受け解析で、文の中の語と語の修飾関係を木構造として取り出します。「商品が届きませんでした」であれば、「商品」が主語として「届く」に係り、その「届く」に否定の助動詞が付いている、という構造が得られます。共起が「同じ場所にあった」を返すのに対して、係り受けは「何が、どうした」を返します。

日本語の係り受け解析には GiNZA や spaCy、CaboCha といった実装がありますが、本稿の実行環境には GiNZA と spaCy を導入していないため、実際に動かした結果は載せません。考え方と、得られるものの違いを説明するにとどめます。係り受け解析を使うと、述語とその項の組を大量に取り出せるので、「不満の対象」と「不満の内容」を分けて集計できるようになります。VoC分析の文脈では、「何について」と「どうだった」を分けて数えられることの価値が大きいです。

係り受け解析器がない環境でも、助詞の並びを手がかりにすれば、簡易的な近似はできます。名詞のあとに「が」「を」「に」「は」「も」のいずれかが続き、そのあとに自立語の動詞または形容詞が現れたら、その組を「名詞+助詞+述語」として拾う、という規則です。サンプルVoCデータに適用した結果を示します。

ROLES = ("が", "は", "を", "に", "も")

# 連続する名詞をまとめ(請求+金額 → 請求金額)、
# 名詞の直後の助詞を見て、後続の最初の述語に係るとみなす。
# 述語の直後4語以内に「ない」系があれば否定フラグを立てる。
for k, t in enumerate(merged):
    if t["pos0"] != "名詞" or t["pos1"] != "複合":
        continue
    if k + 1 >= len(merged):
        continue              # 文末の名詞は係り先がないので飛ばす
    nxt = merged[k + 1]
    if nxt["pos0"] != "助詞" or nxt["surface"] not in ROLES:
        continue
    target = next((p for p in preds if p[0] > k), None)
    if target is not None:
        out.append((t["surface"], nxt["surface"], target[1], target[2]))

# 出力: 抽出した組の総数: 3229 / 異なり組数: 91
# 出力: 述語が否定形だった組: 803 (24.9%)
# 出力: 助詞の内訳  が: 1767 / に: 667 / を: 647 / も: 99 / は: 49

最初に実装したときは、述語が「する」ばかりになりました。「切り替えを検討します」の「検討」はサ変接続の名詞(「する」を付けると動詞になる名詞)、「し」が動詞なので、素朴に動詞の原形を取ると述語が「する」になってしまいます。3,229組のうち1,041組が「する」に潰れていました。そこで、述語が「する」でその直前がサ変接続の名詞なら、2つをまとめて1つの述語として扱うよう直しました。同じ処理で「する」は176組まで減り、「検討する」「お願いする」「対応する」「更新する」といった述語が正しく立つようになりました。

抽出結果の上位は次のとおりです。否定を区別しない場合と、区別した場合を並べます。

否定を区別しない件数肯定否定
切り替え を 検討する1251250
改善 を お願いする1191190
早急 に 対応する1101100
商品 が 届く702743
通知 が 届く50050
対応 が 遅れる50500
内訳 が わかる47047
請求金額 が 違う45450

「商品 が 届く」は70件ありますが、そのうち43件は否定形です。共起分析では「商品」と「届く」の間に1本の線が引かれるだけで、届いた話と届かなかった話が同じ線に押し込まれます。述語まで見て初めて、この2つが分かれます。述語ごとに否定率を計算すると、「届く」は235件中181件で77.0%、「読む」は87件中60件で69.0%、「できる」は74件すべてが否定でした。逆に「なる」は207件中32件で15.5%にとどまります。同じ語でも否定率がここまで違うので、否定を無視した集計は語ごとに違う量の誤差を持つことになります。この点は第5章の感情分析でさらに詳しく扱います。

簡易版の限界も、実行してみるとはっきりします。「担当者によって案内の内容が違い、どちらが正しいのかわかりません。」という文では、「内容 が 正しい」という誤った組が出ました。本来は「内容 が 違う」であるべきところ、この文の「違い」が名詞として解析されたため、規則が後続の「正しい」を述語として拾ってしまいました。また「使いやすいとは言えません。」や「遅いということはなく、むしろ早いくらいでした。」からは、何も抽出できませんでした。名詞と助詞の並びがないためです。文の意味としてはどちらも不満を含むのに、規則の網に掛かりません。

助詞パターンによる抽出は、係り受け解析の代わりにはなりません。近い語だけを見ているので、文が複雑になると係り先を取り違えます。それでも、共起だけを見ているよりは確実に情報が増えます。導入判断の目安としては、まず助詞パターンで試して、誤りの量が許容できないと分かった時点で係り受け解析器の導入を検討する、という順序が現実的だと考えています。係り受け解析器は形態素解析より処理が重く、辞書やモデルの管理も増えるので、必要性を確かめてから入れるほうが運用が楽になります。

同じ文から共起分析と係り受け解析がそれぞれ何を取り出すかを対比した概念図

共起分析は、頻度分析では見えない語と語の関係を数える手法です。共起の範囲を文書・文・窓幅のどれに取るかで結果が変わること、生の共起数は頻出語同士を上位に押し上げるので Jaccard 係数や PMI といった関連度指標が要ること、PMI は低頻度語を過大評価するので共起数の下限や PPMI で補正すること、この3点が計算側の要点です。可視化側では、共起ネットワークの図が最小出現数と閾値に強く依存するため、値を明示しないと再現も検証もできないという点がもっとも重要でした。線は因果ではなく、座標にも意味はありません。

また共起は方向を持たないので、「商品が届いた」と「商品が届かなかった」を区別できません。助詞パターンによる簡易抽出でも、否定を分けるだけで解釈は大きく変わりました。否定の扱いは、テキストから評価表現の向きを判定する際の主要な課題です。次の第5章では、極性辞書ベースと機械学習ベースの感情分析を比べながら、日本語の否定や反語をどう扱うか、業務で使う閾値をどう決めるかを扱います。

この章を深めたい方への参考書籍

『Pythonで学ぶネットワーク分析 ColaboratoryとNetworkXを使った実践入門』(村田剛志、オーム社):本章で使った networkx を、ネットワーク分析の基礎から順に扱っています。中心性の種類ごとの意味の違いや、コミュニティ検出の手法の使い分けを、動かせるコードとあわせて確認したい読者に向いています。

『複雑ネットワーク:基礎から応用まで』(増田直紀・今野紀雄、近代科学社):本章では中心性やコミュニティを道具として使いましたが、その背後にある理論を体系的に押さえたい場合の一冊です。共起ネットワークの図から何を主張してよく、何を主張してはいけないかを判断するための土台になります。

『テキストアナリティクス』(金明哲、共立出版):テキストを統計的に扱う手法を、前処理から指標、クラスター分析、分類まで通して解説しています。本章で扱った共起と関連度指標を、テキスト分析全体の中でどう位置づけるかを確認したい読者に向いています。

第5章 感情分析・評判分析

第4章では、単語どうしのつながりを共起行列とネットワークで捉え、どの語とどの語が同じ文脈に現れるかを数えました。そこで見えたのは「何が話題になっているか」までで、その話題が good として語られているのか bad として語られているのかは扱っていません。この章では、テキストに含まれる評価の向きを数字にする方法を扱います。実務では感情分析、評判分析、あるいはネガポジ判定と呼ばれる領域です。

感情分析は、経営会議で最も誤解されやすい手法だと考えています。「顧客の声をAIで分析して満足度を出しました」という報告に対し、その数字が何を測っているのかを説明できる場面は多くありません。この章では、辞書ベースと機械学習ベースの両方を実際に動かし、日本語で何がうまくいかないかを実測の数字で示したうえで、業務でどう閾値を置くかまで踏み込みます。使うデータは第2章で用意したサンプルVoCデータです。

感情分析が測っているものと、測っていないもの

感情分析という言葉は、性質の違う4つの課題をまとめて指しています。分析設計の入口でこれを混ぜると、後工程がすべてずれます。

1つ目は極性で、テキストが好意的な向きか否定的な向きかという二値、あるいはそこに中立を加えた三値の判定を指します。この章で主に扱うのはこれです。2つ目は強度で、「少し不満」と「激怒している」を区別するものです。3つ目は感情カテゴリで、喜び・怒り・悲しみ・驚きといった種類に分けます。極性が同じネガティブでも、怒りと悲しみでは打つべき手が違います。4つ目が評価対象、いわゆるアスペクトです。「配送は速いが梱包が雑」という1文には、速いという好意的評価と雑という否定的評価が同居しており、対象を分けなければどちらの数字にもなりません。

そして最も重要な注意点として、これらはいずれも満足度ではありません。満足度は本来、当人に「満足しましたか」と尋ねて得る主観的な自己申告です。感情分析が測っているのは、書かれたテキストに含まれる評価表現の向きにすぎません。不満を持っていても穏やかな文面で書く人はいますし、満足していても改善要望だけを書き送る人もいます。「感情分析でNPSの代わりになりますか」という問いに対しては、なりませんと答えるのが正確です。感情分析が答えられるのは「寄せられた声のうち、否定的な表現を含むものが何割か」であって、「顧客が満足しているか」ではありません。

この区別を守るだけで、報告の書き方が変わります。「顧客満足度は68点です」ではなく、「今月寄せられた1200件のうち、否定的な表現を含む声が69.5%でした」と書きます。後者は検証可能で、後から定義を確認できます。

測っているもの問いの形典型的な出力
極性この文は好意的か否定的かpositive / negative / neutral
強度どのくらい強い評価か連続値のスコア、または5段階
感情カテゴリどの感情か喜び・怒り・悲しみ・驚きなど
アスペクト何に対する評価か配送・料金・サポートなどの対象名
測っていないものこの人は満足しているか感情分析では得られない
感情分析で測れる極性・強度・感情カテゴリ・アスペクトと、測れない満足度を分けた概念図

極性辞書ベースの仕組みを最小構成で作る

辞書ベースの感情分析は、極性を持つ語を表にしておき、文中に現れた語の点数を足し合わせる方式です。学習データが要らず、何を根拠にそう判定したかを1語単位で示せて、計算も軽いという3つの利点があります。逆に、辞書に無い語は一切拾えず、文脈も見ません。

日本語で使える公開資源としては、乾・岡崎研究室が公開している日本語評価極性辞書が広く知られています。2008年12月版として公開されているもので、用言編が約5千件、名詞編が約8千5百表現を収録し、クレジットを明記すれば商用利用も可能とされています。利用条件は配布元の記載が正なので、業務で使う前にそのページを確認する必要があります。もう1つ、感情カテゴリ側の資源としてML-Askがあります。これはMichal Ptaszynski氏が開発した日本語の感情分析システムで、テキストを10種類の感情カテゴリに分類し、否定表現のパターンも併せて扱います。Pythonからは pymlask として利用できます。本記事ではこれらをダウンロードして実行することはせず、実在と概要の紹介にとどめます。

代わりに、記事の中で小さな極性辞書を自作しました。ポジティブ語35語、ネガティブ語36語の合計71語です。語は形態素解析の原形で登録し、スコアは強い評価を2、弱い評価を1として符号で向きを表します。辞書ベースの挙動を見るには、この規模で十分に説明がつきます。

from janome.tokenizer import Tokenizer

POS_WORDS = {
    "良い": 2, "満足": 2, "安心": 2, "丁寧": 2, "簡単": 2,
    "早い": 1, "速い": 1, "迅速": 2, "便利": 2, "快適": 2,
    "解決": 2, "改善": 2, "安定": 1, "助かる": 2, "安い": 1,
    # ... 合計35語
}
NEG_WORDS = {
    "悪い": -2, "遅い": -1, "遅れる": -2, "高い": -1, "不満": -2,
    "不具合": -2, "エラー": -2, "破損": -2, "困る": -2, "面倒": -2,
    "残念": -2, "がっかり": -2, "崩れる": -1, "消える": -2, "問題": -1,
    # ... 合計36語
}
POLARITY = {**POS_WORDS, **NEG_WORDS}

_TOKENIZER = Tokenizer()

def tokenize(text):
    out = []
    for tok in _TOKENIZER.tokenize(text):
        pos = tok.part_of_speech.split(",")[0]
        base = tok.base_form if tok.base_form != "*" else tok.surface
        out.append((tok.surface, base, pos))
    return out

def score_plain(text):
    total, hits = 0, []
    for surface, base, _pos in tokenize(text):
        for key in (base, surface):
            if key in POLARITY:
                total += POLARITY[key]
                hits.append((key, POLARITY[key]))
                break
    return total, hits

実行してみます。「担当者の説明が丁寧で、疑問がその場で解消しました。」という文では、丁寧が+2、疑問が-1に当たって合計+1となり、positive と判定されました。ところが次の文で早くも問題が出ます。「配送の予定日を過ぎても商品が届きませんでした。改善をお願いします。」という明らかな苦情に対して、当たったのは改善の+2だけで、判定は positive でした。届きませんでしたという核心部分は、届くという語が辞書に無いため拾われず、末尾の定型句だけがスコアを決めています。

この誤りは辞書ベースの弱点をよく表しています。辞書は語の一般的な向きを持っているだけで、その語がどんな文脈で使われたかを知りません。改善という語は達成の報告では好意的ですが、要望の文では不満の裏返しであり、実務のVoCでは後者が圧倒的に多いという事実は辞書の側には書かれていません。

日本語がつまずかせる7つの型

日本語の感情分析が難しいと言われるとき、その中身は次の7つに分解できます。実際に誤るところを見るため、7類型をカバーする16文に人手で正解を付け、自作辞書にかけました。

類型例文難しさの正体
否定使いやすいとは言えません。肯定的な語が否定されている
二重否定不満がないわけではありません。否定が2回かかって元に戻る
反語これで改善したと言えるでしょうか。疑問の形で否定を述べている
逆接料金は安いが、配送が遅いです。2つの評価のどちらが主張か
比較A社よりは良いです。それでも改善の余地があります。比較対象があって絶対評価ではない
皮肉さすがのサポート品質ですね。三回同じ説明をさせられました。表層の語と意図が逆
絵文字画面が固まります😢 早く直してほしいです。語ではない記号が評価を担う

結果は、16文のうち正解できたのは3文だけで、13文を誤りました。正解できたうちの1つはたまたま不満の-2がそのまま残っただけです。

類型ごとに見ると、否定は3文すべて誤り、反語も比較も皮肉も絵文字も全滅でした。とくに深刻なのは、誤りの向きが偏っていることです。「サポートの対応は丁寧ではありませんでした」は+2で positive、「素晴らしい対応をありがとうございます。もう二度と使いません」も+2で positive と判定されました。不満の声を好意的な声として数え上げてしまうので、集計値は実態より楽観的にずれます。ネガティブを見つけたい業務では、最も避けたい向きの誤りです。

一方で1200件のVoC全体に対しては、正解率0.790という数字が出ました。この章で挙げる正解率は、スコアが0ちょうどで判定を保留した文をすべて negative 扱いとして数えたものです。VoCは不満が多数を占めるため、迷ったら negative に寄せるのが運用に近いと考えて、この数え方に統一しました。保留を除いて判定できた分だけで数えると0.783となり、値が変わります。どちらを採ったかを書かずに正解率だけを出すと、比較ができなくなります。

16文ではほぼ全滅なのに全体では8割近く当たるのは、実データの多くが素直な言い回しでできているからです。全体の正解率は「難しい文をどれだけ落としているか」を隠します。

否定のスコープと見出し語の単位を直して辞書を育てる

7類型のうち、最も機械的に対処できるのが否定です。日本語の否定は述語の後ろに現れるため、否定のスコープ、つまり否定がどこまで及ぶかを、否定語から手前へさかのぼる形で決められます。ここでは、否定語を見つけたら読点や句点で区切られた同じ節の中を手前に走査し、最初に見つかった極性語の符号を反転させる、という規則を実装しました。「ませ」と「ん」のように否定語が連続するときは1つの否定として扱います。この扱いにより、二重否定は反転が2回かかって自然に元へ戻ります。

否定語の集合を2つ用意している点だけ補足します。NEGATION は形態素解析が返す原形で引くための集合、NEGATION_SURFACE は文中に現れたままの表層形で引くための集合です。「ませ」「なかっ」「なけれ」は活用した形なので原形では引けず、逆に「なし」のような形は表層形の一覧に入れていません。両方を見ることで取りこぼしを減らしています。

NEGATION = {"ない", "ぬ", "ん", "ず", "なし"}                    # 原形で引く
NEGATION_SURFACE = {"ない", "なかっ", "なく", "ませ", "ず", "ぬ", "ん", "なけれ"}  # 表層形で引く

def score_negation(text, max_span=8):
    toks = tokenize(text)
    scores = [0] * len(toks)
    for i, (surface, base, _pos) in enumerate(toks):
        for key in (base, surface):
            if key in POLARITY:
                scores[i] = POLARITY[key]
                break

    def is_negation(idx):
        surface, base, pos = toks[idx]
        return (surface in NEGATION_SURFACE or base in NEGATION) \
            and pos in ("助動詞", "形容詞", "助詞")

    for i in range(len(toks)):
        if not is_negation(i):
            continue
        if i > 0 and is_negation(i - 1):
            continue          # 「ませ」+「ん」は1つの否定として扱う
        j, steps = i - 1, 0
        while j >= 0 and steps < max_span:
            if toks[j][2] == "記号":
                break         # 読点・句点で節が切れたら止める
            if scores[j] != 0:
                scores[j] = -scores[j]
                break
            j -= 1
            steps += 1
    return sum(scores)

なお、ここでは説明のためスコアだけを返す形にしています。実際に動かしたスクリプトでは、どの語が反転したかを後から確認できるよう、合計スコアと当たった語の一覧と反転した語の一覧の3つを返すようにしました。辞書ベースを運用に載せるなら、判定の根拠を残す作りにしておくほうが後の検証が楽になります。

この処理を入れると、「サポートの対応は丁寧ではありませんでした」は丁寧の+2が反転して-2となり negative に、「この価格で満足できる品質ではありませんでした」も満足の+2が反転して negative になりました。「不満がないわけではありません」は、ないで+2に、ませんでもう一度-2に戻って negative のままです。難所16文の誤りは13文から11文へ減りました。

1200件全体では、正解率が0.790から0.858へ上がりました。positive を検出対象として、positive と判定したもののうち実際に positive だった割合(適合率)は0.641から0.747へ、実際の positive のうち拾えた割合(再現率)は0.708から0.809へ、その2つをまとめた指標(F1)は0.673から0.777へ改善しています。これらの指標の定義と読み方は第7章で改めて詳しく扱います。スコアが0になって判定を保留した件数も404件から374件へ減りました。数十行の規則でこの幅が動くのは、日本語のVoCに否定表現がそれだけ多く含まれているということです。

否定語スコープの簡易処理を入れる前後で、VoC1200件の正解率と難所16文の正解率を比較した棒グラフ

ただし、改善したのは否定だけです。類型別に見ると、否定は0勝3敗から2勝1敗になった一方、反語・比較・皮肉・絵文字はいずれも0勝のまま動いていません。これらは語の符号を反転させるだけでは届かない領域で、文の意図や書き手の態度を読む必要があります。辞書ベースの改良で届く範囲はここまでだという線を、この実測が示しています。

辞書ベースを運用に載せる前に、もう1つ必ずやっておきたい計測があります。登録した語のうち、実際に何語が対象データに当たったかを数えることです。1200件のVoCに対して自作辞書71語を当てたところ、実際に1回以上ヒットしたのは31語だけでした。残りの40語は一度も出番がありません。

よく当たった語は、改善が138回、がっかりが109回、満足が107回、遅れるが90回、違うと丁寧がともに81回でした。一方で「使いやすい」「分かりにくい」「素晴らしい」「面倒」といった、いかにも出てきそうな語が0回です。原因は語彙不足ではなく、辞書の見出し語と形態素解析の切り方がずれていることでした。

# 出力:
# 使いやすいとは言えません
#   -> ['使う', 'やすい', 'と', 'は', '言える', 'ます', 'ん']
# 画面の操作も分かりにくいです
#   -> ['画面', 'の', '操作', 'も', '分かる', 'にくい', 'です']

「使いやすい」は「使う」と「やすい」に、「分かりにくい」は「分かる」と「にくい」に割れています。つまり辞書に「使いやすい」と書いても、その形のトークンは永久に現れません。そこで、接尾辞である「やすい」を+2、「にくい」と「づらい」を-2として辞書に3語だけ追加しました。結果は、正解率が0.858から0.880へ、F1が0.777から0.816へ上がり、判定保留は374件から347件へ減りました。難所16文の誤りも11文から9文へ減っています。ここで、否定処理のときと同等だったものと、そうでなかったものを分けて見ておきます。同等だったのは難所16文の側で、否定処理が誤りを13文から11文へ2件減らしたのに対し、接尾辞3語の追加も11文から9文へ2件減らしました。一方で1200件全体への効き方は違います。否定処理は正解率を0.790から0.858へ、F1を0.673から0.777へ押し上げましたが、接尾辞3語の追加による上げ幅は正解率でもF1でもその3分の1程度にとどまります。数十行の規則ほどの効果はありませんが、3語を書き足すだけで得られる改善としては十分に大きいと言えます。

ここから引き出せる実務上の含意は2つあります。1つは、辞書ベースを採用するなら「当たらない語を数える」工程を定例に組み込むことです。もう1つは、辞書の見出し語を形態素解析器の出力単位に合わせて作ることです。第2章で扱った解析器の選択とユーザー辞書の設計は、この章の精度に直結しています。

機械学習ベースに切り替え、係数で説明できる状態を保つ

辞書ベースの限界が見えたので、教師あり学習に移ります。第2章で用意したサンプルVoCデータには sentiment のラベルが付いているので、これを正解として学習させます。1200件を7対3で分け、学習840件、テスト360件としました。テストの内訳は negative 250件、positive 110件です。

特徴量は、形態素解析で分かち書きしたうえでのTF-IDFです。ここで1つ設計上の判断をしています。通常は内容語だけを残しますが、否定を担う助動詞の「ない」「ん」を落とすと否定が扱えなくなるため、名詞・動詞・形容詞・副詞・感動詞に加えて助動詞を残しました。さらに、単語のつながりを拾うために1語と2語の連なりの両方を特徴量にしています。

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

# df は第2章で読み込んだサンプルVoCデータの1200件、
# tokenize は同章で作った形態素解析の関数(表層形・原形・品詞を返す)
KEEP_POS = {"名詞", "動詞", "形容詞", "副詞", "感動詞", "助動詞"}

def wakati(text):
    return " ".join(b for _s, b, p in tokenize(text) if p in KEEP_POS)

X_train, X_test, y_train, y_test = train_test_split(
    df["text"].tolist(), df["sentiment"].tolist(),
    test_size=0.3, random_state=20260901, stratify=df["sentiment"])

vec = TfidfVectorizer(analyzer="word", token_pattern=r"\S+",
                      ngram_range=(1, 2), min_df=2)
Xtr = vec.fit_transform(wakati(s) for s in X_train)
Xte = vec.transform(wakati(s) for s in X_test)

clf = LogisticRegression(max_iter=1000, class_weight="balanced",
                         random_state=20260901)
clf.fit(Xtr, y_train)

特徴量は868個になりました。同じテスト360件で比べると、辞書ベースは正解率0.869、適合率0.760、再現率0.836、F1が0.797という成績でした。機械学習ベースは正解率1.000で、適合率・再現率・F1もすべて1.000です。

辞書ベースと機械学習ベースの混同行列を、定型的なテスト360件と混在文300件の2条件で並べた4面の図

ここで手を止める必要があります。正解率1.000は、データがテンプレートに依存していないか、評価条件が適切かをまず確認すべき値です。今回のサンプルVoCデータは、カテゴリごとの文テンプレートを乱数で組み合わせて生成した架空のデータです。学習データとテストデータが同じテンプレート群から作られているため、線形モデルは定型句を覚えるだけで満点を取れてしまいます。学習件数を減らした実験でも、50件で0.936、100件で0.994、200件で1.000に達しました。実データでこの伸び方をすることはまずありません。

実務でも同じ現象は起こります。同一の問い合わせフォームから集めた声だけで学習と評価をすると、定型的な言い回しが多いために精度が高く出ます。その状態で報告し、通話ログのような別チャネルへ適用したとたんに崩れる、という筋書きです。評価データは運用で実際に来るテキストの分布に近づけます。それができないなら、精度の数字に「どういうデータで測ったか」を必ず添えます。この2点は精度報告の最低条件だと考えています。

ロジスティック回帰を選んだ理由は、精度だけではありません。学習した重みをそのまま語ごとに読み出せるため、モデルが何を根拠にしているかを人に説明できるからです。今回学習したモデルで、positive 側に強く効いた語は「利用」「すぐ」「満足する」「やすい」「早い」「ありがとう」でした。negative 側では「ます」と「ん」の連なり、「対応」「改善・お願い」「恐れ入る」「連絡」「請求」「正直・がっかり」が上位に並びます。分かち書きを原形で行っているため、特徴量として現れるのは活用形の「ませ」ではなく原形の「ます」です。

この一覧には、示唆のある情報と危険な兆候の両方が含まれています。示唆のほうは、「改善・お願い」という2語の連なりが negative 方向に効いていることです。辞書ベースでは改善が単独で+2に当たって誤りの元になっていましたが、機械学習ベースは「改善をお願いします」が苦情の締めくくりだと学習データから学び取っています。文脈を数字で覚えられるのが機械学習の強みです。

一方で危険な兆候もあります。「恐れ入る」が negative 側の上位に来ているのは、サンプルVoCデータで不満の書き出しに「恐れ入りますが、」が使われているためで、感情そのものとは関係がありません。「請求」が negative 側にあるのも、料金・請求カテゴリの不満比率が高いという偏りを拾っているだけで、請求という語自体に否定的な意味はありません。係数の上位を人が読むと、こういう「相関しているが因果ではない特徴」が見つかります。これを見つけたら、その語を特徴量から外す、あるいは学習データの集め方を見直す判断につながります。

1件ごとの根拠も同じ仕組みで出せます。TF-IDFの値と係数の積を取れば、その文書のどの語が判定をどちら側へ押したかが並びます。判定に不服が来たとき、この一覧を示せるかどうかで運用の受け入れられ方が変わります。

import numpy as np

# vec / clf / Xte / X_test は、前のコードブロックで作ったもの
feat = np.array(vec.get_feature_names_out())
coef = clf.coef_[0]

def explain(k, topn=5):
    """テスト集合のk番目の文書について、寄与の大きい語を並べる。"""
    contrib = Xte[k].toarray()[0] * coef
    order = np.argsort(np.abs(contrib))[::-1][:topn]
    return [(feat[i], round(float(contrib[i]), 3)) for i in order if contrib[i] != 0]

k = 1
print(X_test[k])
print(explain(k))

# 出力:
# 通知が届かないことがあり、対応が遅れました。改善をお願いします。
# [('改善 お願い', -0.201), ('対応', -0.182), ('た 改善', -0.169),
#  ('ある', 0.166), ('届く ない', -0.148)]

最後に、この分類器を「日本語がつまずかせる7つの型」の節で人手の正解を付けた難所16文にもかけておきます。辞書ベースと同じ検証文を使えば、両者を同じ土俵で比べられるからです。結果は誤り4文で、否定処理を入れた辞書ベースの11文より大きく減りました。学習データから文脈ごと覚えられる分、辞書が単語単位で取りこぼしていた型を拾えています。誤ったのは「サポートの対応は丁寧ではありませんでした」(否定)、「不満がないわけではありません」(二重否定)、「A社よりは良いです。それでも改善の余地があります」(比較)、「素晴らしい対応をありがとうございます。もう二度と使いません」(皮肉)の4文です。いずれも、肯定的な語が文の前半に置かれ、後半でひっくり返される形になっています。語の並びを覚えるだけでは、文の後半による反転までは追い切れません。次の節では、学習データを1件も使わない事前学習モデルが、この16文でどういう成績になるかを見ます。誤りの総数だけでなく、どの型を落とすかが入れ替わる点に注目してください。

言い回しが混ざると何が起きるか

定型データで満点が出てしまうため、運用に近い条件を別に作りました。良い点と悪い点が1つの声に同居する混在文を300件合成し、逆接の後ろに置かれた節を全体の評価とみなして正解を付けています。日本語では主張が後ろに来る傾向があるためです。

結果は、辞書ベースが正解率0.493、機械学習ベースが0.540でした。どちらも当てずっぽうに近い水準です。混同行列を見ると、辞書ベースは negative 135件のうち64件を positive と誤判定し、機械学習ベースは79件を誤判定しています。原因ははっきりしていて、どちらの手法も語の集合しか見ていないため、良い評価語と悪い評価語が同数ずつ入った文では「どちらが主張か」を決める材料を持たないからです。

この結果は失敗ではなく、手法の適用限界を測ったものです。VoCの中に「Xは良いがYが悪い」という形の声が多いなら、1文書1ラベルの設計自体が合っていません。この章の後半で扱うアスペクト単位の分解か、第4章で触れた係り受けの利用へ進む必要がある、という判断材料になります。

事前学習済みモデルという選択肢

3つ目の選択肢が、大量のテキストで事前学習された言語モデルを感情分析に使う方法です。自前で学習データを作らなくても、公開されている日本語の感情分析モデルをそのまま適用できます。今回は Hugging Face から llm-book/bert-base-japanese-v3-marc_ja を取得して実行しました。cl-tohoku/bert-base-japanese-v3 を基に、日本語ベンチマークJGLUEに含まれるMARC-jaという商品レビューのデータセットでファインチューニングされたモデルで、書籍『大規模言語モデル入門』(技術評論社、2023年)で紹介されているものです。ライセンスは Apache License 2.0 です。

from transformers import pipeline

clf = pipeline("sentiment-analysis",
               model="llm-book/bert-base-japanese-v3-marc_ja",
               device=-1)   # CPUで動かす

print(clf("使いやすいとは言えません。")[0])
# 出力: {'label': 'negative', 'score': 0.995...}

「日本語がつまずかせる7つの型」の節で人手の正解を付けた難所16文にかけたところ、誤りは5文でした。辞書ベースが11文、TF-IDFとロジスティック回帰の組み合わせが4文だったのと比べると、学習データを1件も用意していないにもかかわらず健闘しています。類型別では、否定を3文すべて正解し、逆接も2文とも正解、絵文字も2文とも正解しました。辞書ベースが全滅した領域を、事前学習で得た言語知識だけで拾っています。

次の表は、いま述べた誤りの数ではなく、正解の数で並べたものです。辞書ベースの列は否定処理を入れたあとの版で、誤り11文の裏返しにあたる正解5文が合計になります。素朴な版(正解3文・誤り13文)とは別の数字なので、混同しないようにしてください。

類型辞書ベース(否定処理あり)の正解数機械学習ベースの正解数事前学習モデルの正解数
否定(3文)223
二重否定(2文)110
反語(2文)021
逆接(2文)122
比較(2文)011
皮肉(2文)011
絵文字(2文)022
難所を含まない文(1文)111
合計(16文)51211

ただし2つ注意があります。1つは、二重否定を2文とも落としたことです。「不満がないわけではありません」を確信度0.981で positive と判定しました。もう1つはその確信度そのものです。誤った5文でも、モデルが返したスコアは0.981から0.999の範囲にありました。正解した11文のスコアが0.911から0.999でしたから、誤判定のときにも、正解したときと同じ水準の高い確率スコアが返ってきます。スコアが高いから正しい、という読み方はできません。人手確認の対象をスコアで絞る運用を設計するなら、この性質を先に把握しておく必要があります。

混在文300件では正解率0.540で、TF-IDFとロジスティック回帰と同水準でした。ここでも「どちらの節が主張か」という問題は解けていません。加えて、このモデルは商品レビューで学習されているため、問い合わせフォームや社内文書へそのまま持ち込むとレビュー特有の言い回しに引っ張られる可能性があります。事前学習モデルは初期立ち上げには有効ですが、自社データでの検証を省く理由にはなりません。

業務での閾値設計はコストから決める

ここからは、出てきたスコアをどう業務に接続するかです。まず避けるべきことを4つ挙げます。

第一に、スコアをそのまま平均しないことです。辞書ベースのスコアを1200件で平均すると-0.067になりました。この数字だけを見れば「ほぼ中立」に見えますが、内訳は positive の文が平均+1.762、negative の文が平均-0.869で、真逆の声が打ち消し合って0付近に落ち着いているだけです。しかもスコアが0ちょうどの文が374件、全体の31.2%を占めます。この分布に平均を当てても、意味のある1つの数にはなりません。件数の割合で報告するほうが、はるかに解釈がぶれません。

第二に、確信度の低い帯をそのまま集計に入れないことです。定型のテスト360件と混在文300件を合わせた660件で、機械学習ベースの予測確率の分布を取りました。全体の正解率は0.791ですが、確率0.35から0.65の帯に入った277件、全体の42.0%だけを取り出すと正解率は0.542まで落ちます。逆にこの帯を除いた383件では0.971でした。細かく刻むと、確率0.2未満の243件は正解率1.000、0.8以上の110件も1.000である一方、0.35から0.8のあいだに入った290件は0.46から0.58にとどまりました。

機械学習モデルが出したpositive確率のヒストグラムを正解ラベル別に重ね、確信度の低い帯を網掛けで示した図

つまりモデルは「自信のある文」については実際に正確で、「迷っている文」でだけ間違えています。この性質を利用すれば、確率が両端にある文は自動処理に回し、真ん中の帯だけを人が確認する運用が組めます。この例では、42%を人が見れば残り58%は97%の正確さで自動処理できます。何割を人が見るかは、確認にかけられる工数から逆算します。

第三に、閾値を0.5のまま使わないことです。0.5という値は、2つの誤りのコストが等しいときにだけ最適です。実務でその前提が成り立つことはほとんどありません。今回の660件で、positive を拾う運用の閾値を動かすと次のようになりました。

閾値positiveと判定した件数適合率再現率F1
0.34070.6761.0000.806
0.43930.6770.9670.796
0.52950.7320.7850.758
0.61530.8690.4840.621
0.71140.9820.4070.576
0.81101.0000.4000.571

負の評価を拾う側も同じように動かせます。negative 側の確率で0.5を使うと365件が抽出され、適合率は0.838、再現率は0.795になります。0.6にすると267件に絞られ、適合率0.966・再現率0.670です。0.7では253件まで減り、適合率1.000・再現率0.657でした。

ここで、どこを選ぶかは統計の問題ではなくビジネスの問題です。判断材料は2つのコストです。1つは見逃しコストで、対応すべき不満を見落としたときに失うものを指します。解約に直結する声を見逃せば、その顧客の取引額が失われます。もう1つは確認コストで、抽出された件を人が読む手間です。1件あたり2分、担当者の稼働が1日3時間なら、1日に見られるのは90件です。

この2つを並べれば閾値は決まります。見逃しコストが確認コストを大きく上回るなら、再現率を優先して閾値を下げ、抽出件数が増えることを受け入れます。逆に、確認できる人手が限られていて拾った件は必ず対応すると決めているなら、適合率を優先して閾値を上げます。上の表でいえば、閾値0.7にすると再現率は0.407まで落ちますが、抽出された114件はほぼすべて本物なので、限られた工数を無駄なく使えます。閾値はモデルが決めるものではなく、業務側が決めるものです。

第四に、絶対値ではなく変化を見ることです。感情分析の絶対値には、辞書の設計・学習データの偏り・チャネルの構成といった要因が混ざり込んでいます。「ネガティブが69.5%」という数字が高いのか低いのかは、単独では判断できません。ところが同じ定義・同じモデルで測り続ければ、変化には意味があります。ここから示すネガティブ比率は、期別もカテゴリ別も、モデルの出力ではなくサンプルVoCデータに最初から付いている sentiment ラベルの比率です。手法ごとの精度の違いを変化の話に持ち込まないよう、正解ラベルで揺らぎの幅だけを見る形にしました。サンプルVoCデータを受付順に12等分して期別のネガティブ比率を出すと、全期間で0.695、期ごとの標準偏差は0.051、最小0.59から最大0.78の幅で動きました。この振れ幅を先に把握しておけば、たとえば1期で0.11ポイント動いたときに、それが通常の揺らぎの範囲か、調べるべき変化かを判断できます。

あわせて、必ずカテゴリ別に割って見ます。サンプルVoCデータでは、ネガティブ比率が不具合・エラー0.804、料金・請求0.722、配送・在庫0.663、使い方・操作0.659、対応・接客0.583と大きく違いました。チャネル別では0.674から0.710の範囲に収まり、こちらは差が小さくなっています。全体の1つの数字を追いかけていると、不具合の悪化が接客の改善で相殺されて見えなくなります。

興味深いことに、同じデータを辞書ベースの平均スコアで見ると順序が逆転しました。対応・接客が+0.821、使い方・操作が+0.460、料金・請求が+0.125、配送・在庫が-0.249、不具合・エラーが-1.208です。不具合が最も低いことは一致しますが、対応・接客はネガティブ比率が0.583と過半を超えているのに、平均スコアは大きくプラスです。丁寧・解決・改善といった語がこのカテゴリの文に多く現れるためで、平均という集約がいかに情報を壊すかがそのまま出ています。

アスペクトベース感情分析という考え方

最後に、この章で繰り返し壁になった「1つの声に複数の評価が入る」問題への正攻法を扱います。アスペクトベース感情分析は、文書全体に1つの極性を割り当てるのではなく、評価の対象ごとに極性を取る考え方です。

「配送は速いのですが、梱包が雑で箱が破損していました。」という文を、文書単位で処理すると合計-1で negative になります。判定としては正しいものの、配送への好意的な評価が消えてしまいます。これを対象ごとに分ければ、配送は+1で positive、梱包は-2で negative という2つの記録になり、両方が残ります。

本格的な実装では係り受け解析で評価語と対象語の関係を取りますが、まずは節に割って対象語を含む節のスコアを割り当てるだけでも、実用的な出発点になります。

import re

ASPECTS = {
    "配送": ["配送", "発送", "納期", "到着", "届く", "届き", "再配達", "追跡"],
    "梱包": ["梱包", "包装", "外箱", "緩衝"],
    "料金": ["料金", "請求", "金額", "見積", "価格", "支払", "解約金", "日割り"],
    "画面操作": ["画面", "管理画面", "操作", "設定", "マニュアル", "手順", "検索"],
    "サポート": ["サポート", "担当", "問い合わせ", "窓口", "対応", "折り返し", "案内"],
    "安定性": ["エラー", "不具合", "ログイン", "同期", "通知", "アプリ", "更新"],
    "在庫": ["在庫", "入荷", "品切れ"],
}

SPLIT = re.compile(r"[。.!?!?]|(?<=が、)|(?<=ですが、)|(?<=けれど、)|、")

def aspect_sentiment(text):
    out = []
    for clause in [p for p in SPLIT.split(text) if p and p.strip()]:
        score = score_negation(clause)
        for a, kws in ASPECTS.items():
            if any(k in clause for k in kws):
                out.append((a, score, clause))
    return out

# 出力: 配送は速いのですが、梱包が雑で箱が破損していました。
#   アスペクト=配送  スコア= 1 (positive) 根拠節: 配送は速いのですが
#   アスペクト=梱包  スコア=-2 (negative) 根拠節: 梱包が雑で箱が破損していました
#   文全体を1つのスコアにまとめると: -1 (negative)

1200件全体をこの方式で集計すると、対象ごとの温度差がはっきり出ました。ここで比率の分母に注意が要ります。この簡易実装では、極性語が1つも当たらなかった節はスコア0になり、ポジティブでもネガティブでもない中立として脇に置かれます。したがって以下のネガティブ比率は、その対象に言及した節すべてに対する割合ではなく、極性を判定できた節に対する割合です。たとえばサポートは601節で言及されていますが、そのうち極性を判定できたのは182節で、ネガティブ比率0.659はその182節に対する120節の割合です。言及のあった601節のうちどれだけが否定だったか、を表す数字ではありません。

評価対象言及した節の数極性を判定できた節の数ネガティブ比率(極性を判定できた節のうち)全体との差
在庫105391.000+0.334
安定性2551210.868+0.202
サポート6011820.659-0.006
料金5391800.650-0.016
画面操作3921750.583-0.083
配送3481240.581-0.085
梱包61610.525-0.141

判定できた節全体でのネガティブ比率は0.666です。「全体との差」の列は、丸める前の全体値との差を取ったものです。安定性と在庫が平均を大きく上回っていることが分かります。

この表は、文書単位のスコアからは出てこない情報です。文書単位では1つの数字しか得られませんが、対象別に割れば「言及量が最も多いのはサポートだが、比率で最も悪いのは在庫と安定性」という、打ち手に直結する記述になります。件数と比率を必ず並べるのも重要で、在庫は105件の言及のうち39節が判定でき、そのすべてが否定でした。比率は1.000ですが、判定できた節の数は7つの対象の中で最も少ないです。比率だけを見て在庫を最優先にすると、601件の言及があるサポートを後回しにする判断になりかねません。判定できた節の数が少ないほど比率は極端に振れやすいので、この列を表から落としてはいけません。

なお、この簡易実装には限界があります。節の分割を句読点と一部の接続表現だけで行っているため、「管理画面の操作は分かりやすくなりました」という節が中立と判定されるようなことが起きます。精度を上げるには、係り受け解析で評価語がどの対象に係っているかを取るか、対象と極性の組を正解データとして用意して学習させることになります。後者は教師あり学習の枠組みなので、第7章で扱う文書分類の設計がそのまま応用できます。

アスペクト単位と文書単位とで感情分析の結果の粒度が変わることを示した概念図

この章では、感情分析が測っているのは評価表現の向きであって満足度ではないことを確認したうえで、辞書ベースと機械学習ベースと事前学習モデルの3つを同じデータで動かしました。71語の自作辞書は否定のスコープ処理で正解率が0.790から0.858へ、接尾辞3語の追加でさらに0.880へ上がり、機械学習ベースは定型データで満点を出す一方で、良い点と悪い点が混在する文では0.540まで落ちました。どの手法も万能ではなく、どこで誤るかを測ってから使うものだ、というのがこの章の結論です。

運用面では、スコアを平均せず件数の割合で報告すること、確信度の低い帯を人が見る設計にすること、閾値を見逃しコストと確認コストから決めること、絶対値でなく変化を追いカテゴリ別に割ることの4点を挙げました。これらはいずれも、分析の精度ではなく分析の使い方に関する設計です。感情という枠を外した一般の文書分類、たとえば問い合わせを担当部署へ振り分けるような課題は第7章で扱います。次の第6章では、あらかじめラベルを決めずに大量の文書を俯瞰する方法として、トピックモデルに進みます。

この章を深めたい方への参考書籍

『意見分析エンジン 計算言語学と社会学の接点』(大塚裕子・乾孝司・奥村学、コロナ社):本章で扱った評判分析を、意見をどう定義しどう集めるかという設計の側から確認したい読者に向いています。極性の判定だけでなく、意見の保持者と対象をどう取り出すかという枠組みが整理されており、アスペクトベースの考え方の背景を押さえられます。

『テキストマイニングの基礎技術と応用』(那須川哲哉・吉田一星・宅間大介・鈴木祥子・村岡雅康・小比田涼介、岩波書店):テキストアナリティクスシリーズの第2巻です。本章で見た辞書ベースの限界や、業務にどう接続するかという観点を、基礎技術と適用事例の両面から確認できます。閾値設計や運用の話を体系立てて読みたい場合の足場になります。

『言語処理のための機械学習入門』(高村大也 著、奥村学 監修、コロナ社):自然言語処理シリーズの第1巻です。本章の後半で使ったTF-IDFとロジスティック回帰が、なぜその形になっているのかを数式から追いたい読者に向いています。分類器の学習と評価の考え方が丁寧に書かれており、係数を読んで説明するという作業の根拠を理解できます。

第6章 トピックモデルで大量文書を俯瞰する

第5章では、1件ずつの文書に不満か満足かのラベルを付ける感情分析を扱いました。極性辞書でも機械学習でも、出力は「この1件はネガティブ」という判定です。ところが経営の会議で最初に問われるのは、1件ごとの良し悪しではなく「全部で何が話題になっているのか」であることが多いように思います。数千件の自由記述をすべて読む前に、全体の見取り図が必要になります。この要求に応えるのがトピックモデルです。なお本章では、モデルが推定する成分をトピックと呼び、それを日本語の業務用語で言い換えるときに話題と書きます。どちらも同じものを指しています。

序章と第1章で挙げた5つの操作でいえば、本章が扱うのは「まとめる」、つまり話題の抽出にあたります。あらかじめ決めた箱に1件ずつ入れていく「判定する」とは別の操作で、箱そのものをデータの側から作るところが違います。本章では、トピックモデルが何を計算しているのかを数式に頼らずに説明し、第2章で用意したサンプルVoCデータに実際に当てて結果を読みます。トピック数の決め方、LDAとNMFの違い、近年よく名前が挙がるBERTopicの位置づけ、そして最後に経営報告への落とし方まで扱います。教師ありの文書分類は第7章に送ります。

トピックモデルは2階建ての確率を推定している

トピックモデルの中身は、2つの確率分布の組み合わせです。1階と2階に分けて考えると理解しやすくなります。

1階は「文書の側」です。1つの文書は、たった1つの話題でできているのではなく、複数の話題が一定の比率で混ざったものだと考えます。たとえば「請求書の内訳がわからず、何にいくらかかったのか確認できません。プラン変更をしたのに、前のプランの料金で請求されています。丁寧な対応とは思えませんでした。このままでは他社への切り替えを検討します」という1件を、この章の後半で実際にLDAにかけたところ、請求と内訳をめぐる話題が39%、料金プランの変更をめぐる話題が29%、連絡や他社検討をめぐる話題が31%という混合として推定されました。この比率が文書ごとに1組ずつ推定されます。

2階は「トピックの側」です。1つのトピックは、それ自体が語彙全体にわたる確率分布として定義されます。「料金プラン」というトピックがあるとして、それは「料金」という語を10.4%、「プラン」を8.5%、「請求」を5.7%の確率で出す、といった具合に、辞書に載っている全ての語に確率を割り振ります。合計するとちょうど1になります。トピックとは、名前でもラベルでもなく、語彙上の確率の配分そのものです。

この2階建ての構造から、モデルが想定する文章の作られ方が定まります。まず、その文書の話題の混合比率を決めます。次に、1語書くたびに、その比率に従ってどのトピックから書くかをくじ引きで選びます。選ばれたトピックの語彙分布に従って、実際の単語を1つ選びます。これを文書の長さの分だけ繰り返します。これが潜在ディリクレ配分法、いわゆるLDAが仮定している生成過程です。名前にディリクレとあるのは、この混合比率と語彙分布のそれぞれが、ディリクレ分布という確率分布から引かれると仮定しているためです。あとで指定するdoc_topic_priortopic_word_priorは、まさにこのディリクレ分布の形を決めるパラメータにあたります。もちろん人間はこんな手順で文章を書いていません。それでも、この仮定を置くと「観測された単語の並びを最もよく説明する話題の混合比率と語彙分布は何か」という問いが計算可能な形になります。トピックモデルがやっているのは、この逆算です。

ここで押さえておきたいのは、推定されるのが2つの行列だという点です。1つは文書数×トピック数の行列、もう1つはトピック数×語彙数の行列です。前者を見れば「この1件はどの話題の混合か」がわかり、後者を見れば「この話題はどんな語でできているか」がわかります。分析の実務では、この2つの行列を別々の目的で使い分けます。

文書からトピックへ、トピックから語へという2段の確率が積み上がるトピックモデルの構造図

クラスタリングとの違いは、割り当てが硬いか柔らかいか

「大量の文書をグループに分ける」と聞くと、k-meansなどのクラスタリングを思い浮かべる方が多いと思います。実際、目的は似ています。違いは割り当ての性質にあります。

クラスタリングは1件を1グループに入れます。ある問い合わせがクラスタ3に属するなら、それはクラスタ1でもクラスタ5でもありません。これをハード割り当てと呼びます。対してトピックモデルは、1件に対して「トピック1が31%、トピック2が39%、トピック3が29%」のような比率を返します。これがソフト割り当てです。

この差は、実務では見た目以上に効きます。顧客の声は、1件の中に複数の不満が同居していることが珍しくありません。「請求の内訳がわからない」と「プラン変更が反映されない」が1つの投稿に書かれているとき、ハード割り当てはどちらか一方を捨てます。捨てられた側は集計から消え、報告書の件数が実態より小さく出ます。ソフト割り当てなら、両方に部分的な重みを残せます。

もっとも、報告のためには最終的にどこかで硬い数字にする必要があります。実務でよく使うのは、最大の比率を持つトピックを主トピックとして1つ選び、件数を数える方法です。ただしこの操作は情報を捨てています。今回のサンプルデータでは、主トピックの比率が0.9を超える文書が826件と全体の68.8%を占めた一方、0.5未満、つまり過半を占めるトピックが存在しない文書も35件、2.9%ありました。後者を「主トピック」で数えるのは無理があります。件数を出すときは、比率のしきい値を決めて、それを下回る文書は「複合」として別枠にするのが安全です。

LDAを当てる:入力はBoWでなければならず、3つのパラメータが効く

ここからは実際に手を動かします。第2章で用意したサンプルVoCデータ1200件を、第2章で決めた方針どおりに形態素解析し、名詞・動詞・形容詞の原形だけを残した分かち書きにそろえます。

from sklearn.decomposition import LatentDirichletAllocation
from sklearn.feature_extraction.text import CountVectorizer

from ch06_common import load_docs   # 第2章の前処理をそのまま使う

df, docs = load_docs()               # docs は「通知 届く 対応 遅れる 説明」の形

cv = CountVectorizer(token_pattern=r"(?u)\b\w+\b", min_df=5, max_df=0.5)
X = cv.fit_transform(docs)
print(X.shape)      # 出力: (1200, 184)
print(X.nnz)        # 出力: 9751
print(int(X.sum())) # 出力: 10292

1200件の文書が、184語の語彙を軸にした行列になりました。非ゼロの要素は9751個で、行列全体に占める割合は4.42%です。1文書あたりの平均トークン数は8.58語しかありません。VoCの自由記述は短いので、こういう疎な行列になるのが普通です。

ここで大事なのは、入力にCountVectorizerを使っている点です。第3章ではTF-IDFを重視しましたが、トピックモデルではTF-IDFを使いません。理由はモデルの仮定にあります。LDAは「この文書でこの語が何回出たか」という整数の回数を、確率分布から繰り返しくじを引いた結果として説明するモデルです。回数が3ならくじを3回引いた、1なら1回引いた、という読み方をします。TF-IDFの値は0.573や0.366といった小数で、「0.573回くじを引いた」という解釈ができません。

from sklearn.feature_extraction.text import TfidfVectorizer

tv = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b", min_df=5, max_df=0.5)
Xt = tv.fit_transform(docs)

print(Xt[0].data[:5])   # 出力: [0.573 0.366 0.396 0.498 0.363]  小数
print(X[0].data[:5])    # 出力: [1, 1, 1, 1, 1]                  整数の回数

scikit-learnのLatentDirichletAllocationにTF-IDF行列を渡してもエラーにはならず、それらしい結果が返ってきます。ここが厄介なところで、動いてしまうぶん誤りに気づきにくくなります。ただしそのとき推定されているものは、モデルが想定した意味とは別物です。トピックの語の確率を「その話題でその語が出る頻度」として読む前提が崩れるため、解釈の根拠がなくなります。TF-IDFを使いたいなら、後で扱うNMFのほうが素直です。

学習の設定は次のとおりです。

lda = LatentDirichletAllocation(
    n_components=5,          # トピック数 K
    doc_topic_prior=0.05,    # 文書側の事前分布パラメータ(アルファ)
    topic_word_prior=0.01,   # トピック側の事前分布パラメータ(ベータ)
    learning_method="batch",
    max_iter=100,
    random_state=20260901,
)
W = lda.fit_transform(X)             # 文書×トピック (1200, 5)
W = W / W.sum(axis=1, keepdims=True) # 行和を1にそろえる
H = lda.components_                  # トピック×語 (5, 184)
H = H / H.sum(axis=1, keepdims=True)

print(round(lda.perplexity(X), 2))   # 出力: 104.37

トピックモデルを初めて回すと、結果が思ったほど「きれい」に出ないことがあります。多くの場合、原因はトピック数か、ここで指定した3つのハイパーパラメータです。それぞれが何を動かすのかを実測で確認しました。

doc_topic_priorは、文書の中でトピックがどれくらい混ざるかを事前に決める重みです。小さくすると「1文書はごく少数のトピックでできている」という仮定が強くなり、大きくすると「どの文書もいろいろな話題が混ざっている」という仮定に寄ります。K=5で値だけを変えたところ、次のようになりました。

doc_topic_prior最大トピック比率の平均比率0.9超の文書数
0.050.855826件
0.500.6170件
0.950.5170件

0.05では8割超の確信を持って1トピックに寄せた文書が826件出るのに対し、0.95では最大比率の平均が0.517まで下がり、どの文書もぼんやり混ざった状態になります。トピック別の件数を出したい用途なら小さめ、1件の中の複数論点を残したい用途なら大きめ、という選び方になります。なお、指定できる値の範囲は実装によって違うので、そこは確かめてから使う必要があります。ディリクレ分布のパラメータは、数学の定義としては正の値でありさえすればよく、1より大きい値も取れます。ところがscikit-learnの実装は、この値を0以上1以下に制限しています。1.9.0で実際に1.5を渡して確かめたところ、範囲は0.0から1.0であるという内容のInvalidParameterErrorが返り、学習が始まる前に止まりました。topic_word_priorも同じ制限です。0そのものはこの検査を通りますが、渡すとパープレキシティがnanになるため、実際には0より大きく1以下の範囲で選ぶことになります。

topic_word_priorは、トピックの語彙分布がどれくらい尖るかを決めます。小さいほど少数の語に確率が集中し、大きいほど多くの語に薄く広がります。上位10語が占める確率の合計を見ると、0.001と0.01ではどちらも0.344でほぼ差がなく、0.5まで上げると0.323に下がりました。今回は語彙が184語と小さいため効きが穏やかですが、語彙が数万語の実データでは、この値が大きすぎると上位語がぼやけて名前を付けられなくなります。

learning_methodは推定のやり方の指定です。batchは毎回の反復で全文書を使い、onlineは文書を小分けにして逐次更新します。同じ100反復で学習データのパープレキシティを比べると、batchが104.37、onlineが99.39でした。ただし、この1回の値だけでonlineのほうが良い推定をしていると判断してはいけません。学習に使ったデータそのものに対する当てはまりを1条件で測っただけの値ですし、この後の節で見るとおり、パープレキシティは設定を動かすと素直に下がる一方で、出てくるトピックが業務で使えるかどうかとは別の指標だからです。2つの推定方法は精度の優劣で選ぶものではなく、扱う規模で選ぶものだと考えています。文書数が数万件までならbatchで十分間に合います。onlineは数十万件以上、あるいはメモリに全部載らない規模のときに選ぶものだと考えています。

高確率語を読んでトピックに名前を付ける:ここは自動化できない

学習が終わったら、トピックごとに確率の高い語を並べます。

import numpy as np

vocab = np.array(cv.get_feature_names_out())
for k in range(5):
    idx = np.argsort(H[k])[::-1][:10]
    print(f"[トピック{k}] 文書内シェア {W[:, k].mean() * 100:.1f}%")
    print("  " + " / ".join(f"{w}({p:.3f})" for w, p in zip(vocab[idx], H[k][idx])))

# 出力:
# [トピック0] 文書内シェア 17.4%
#   表示(0.038) / 画面(0.033) / 設定(0.027) / エラー(0.027) / マニュアル(0.024) / ...
# [トピック1] 文書内シェア 28.2%
#   届く(0.045) / 説明(0.039) / 商品(0.034) / 連絡(0.029) / 在庫(0.028) / 配送(0.028) / ...
# [トピック2] 文書内シェア 20.0%
#   請求(0.061) / データ(0.049) / 説明(0.041) / 対応(0.038) / 確認(0.036) / 内訳(0.036) / ...
# [トピック3] 文書内シェア 16.8%
#   料金(0.104) / プラン(0.085) / 請求(0.057) / 変更(0.052) / 連絡(0.037) / 上がる(0.028) / ...
# [トピック4] 文書内シェア 17.7%
#   画面(0.061) / 時間(0.037) / 案内(0.036) / 内容(0.031) / 高い(0.029) / 早急(0.028) / ...
LDAが推定した5つのトピックの高確率語を、トピックごとに横棒グラフで並べた図

ここが本章でいちばん強調したいところです。トピックの名前は自動では出てきません。モデルが出すのは「トピック3の語彙分布」という数字の並びだけで、それが「料金プランの話題」だと判断するのは人間です。トピックモデルを紹介する資料でしばしば省略される工程ですが、実務ではここに最も時間がかかります。

今回の5トピックのうち、トピック0は「表示・画面・設定・エラー・マニュアル・初期」と並んでいるので「管理画面の設定でつまずく話題」と読めます。トピック3は「料金・プラン・請求・変更・値上げ」で「料金プランと請求の話題」でしょう。ここまでは素直です。一方でトピック4は「画面・時間・案内・内容・高い・早急・変わる・聞く」で、何の話題なのか一言で言えません。5つ作ったうち1つは名前が付かない、というのはよくあることです。

名前が付かないトピックが出たときの扱い方を、先に決めておくことをおすすめします。選択肢は3つあります。1つ目は、トピック数を変えて回し直すことです。2つ目は、前処理に戻ってストップワードを追加することです。3つ目は、「その他」として残したまま報告し、代表文書だけを添えることです。3つ目は逃げのように見えますが、無理に名前を付けて会議に出すより誠実です。名前を付けた瞬間、その名前が独り歩きして、中身と違う解釈で意思決定に使われることがあるためです。

命名の作業では、上位語だけを見ないほうがよい結果になります。そのトピックの比率が高い文書を10件から20件ほど実際に読み、上位語の印象と食い違わないかを確かめてください。

トピック数の決め方:パープレキシティは答えを出さない

「トピック数はいくつにすればいいですか」という質問には、残念ながら計算だけで出る答えがありません。この節では、よく使われる指標を実際に回して、なぜ答えが出ないのかを数字で示します。

まずパープレキシティです。これは「観測された各単語を、モデルがどれだけ高い確率で説明できるか」を表す指標で、小さいほど当てはまりがよいとされます。LDAは語の並び順を仮定しないBoWのモデルなので、「次に来る単語を当てる」という順序を含んだ読み方はできません。scikit-learnの実装も、1語あたりの対数尤度の符号を反転させて指数を取った値として定義されています。1200件を学習用960件と未使用240件に分け、Kを2から20まで変えて測りました。あわせて、上位語が同じ文書に一緒に出ているかを測るUMassコヒーレンス、そしてトピック同士の上位10語がどれだけ重複しているかも計算しています。

K学習パープレキシティ未使用パープレキシティUMassコヒーレンス上位語の重なり率1%未満のトピック数
2140.03219.48-2.7820.2000
3124.14208.28-2.4610.0670
4114.15199.34-2.5660.0830
5107.45187.64-2.6520.0800
696.35174.42-2.4870.0800
885.00163.54-2.2760.0390
1078.60158.15-2.2980.0470
1275.03141.55-2.2870.0330
1569.74141.48-2.3120.0380
2060.04121.88-2.2310.0411
トピック数Kを2から20まで変えたときのパープレキシティ、UMassコヒーレンス、トピック間の上位語重なり率を並べた3つの折れ線グラフ

パープレキシティは学習データでも未使用データでも、Kを増やすほど単調に下がり続けました。K=20でも下げ止まる気配がありません。これがパープレキシティの落とし穴です。過学習を避けるために未使用データで測っても、この規模では止まりませんでした。指標に従うなら「Kは大きいほどよい」という結論になりますが、20トピックの表を経営会議に出しても誰も読めません。

コヒーレンスは違う動き方をします。K=2で-2.782と低く、K=8で-2.276まで改善したあとはほぼ横ばいになりました。K=10で-2.298、K=12で-2.287、K=15で-2.312と、上下しながら平らです。つまり「8より少ないと解釈しにくいが、8を超えても解釈しやすさは増えない」と読めます。パープレキシティより実務的な情報を持っていますが、それでも「8か10か12か」までは決めてくれません。

K=20では、主トピックとして選ばれる文書が全体の1%に満たないトピックが1つ現れました。上位語の重なりを見ると、次のような対が出ます。

重なり率一方のトピックの上位語もう一方のトピックの上位語
0.3T15:プラン / 請求 / 料金 / 変更 / かかる / わかるT16:説明 / 請求 / 契約 / 解約 / 知る / 窓口
0.3T3:お願い / 改善 / 機会 / アプリ / 安定 / アップデートT6:設定 / 終える / 初期 / マニュアル / 読む / 迷う

表に載せたのは、それぞれのトピックの上位6語です。重なり率は上位10語で計算しているので、表に見えていない4語の中にも共通の語があります。

数字の上では別のトピックですが、業務の言葉にすると「請求まわり」がT15とT16に分かれているだけです。この粒度の違いを担当部署に説明して別の対策を立ててもらうのは、多くの場合は難しいと思います。

結論として、トピック数は「業務で使える粒度かどうか」で決めるほかにありません。判断の順番は次のとおりです。まず指標で下限を切ります。今回であればK=8より小さい範囲は解釈しにくいことがわかったので、そこから下は候補から外します。次に、候補となるKで実際にトピックを出し、全部に名前を付けてみます。名前が付かないトピックが半数を超えるなら多すぎます。最後に、そのトピック一覧を受け取る部署が、トピックごとに違う打ち手を持てるかを確認します。「請求まわり」に対して1つの改善策しか打てないなら、それを2つに分ける意味はありません。

Kを1つに絞り込めないときは、粗いKと細かいKの2種類を並行して持つ運用もあります。同じデータから2つの解像度の地図を作ると考えれば、無理に1つへ収束させる必要はありません。

NMF:確率をあきらめて、行列分解として解く

LDAと並んでよく使われるのが非負値行列因子分解、NMFです。考え方はもっと単純で、文書×単語の行列 \( X \) を、文書×トピックの行列 \( W \) とトピック×単語の行列 \( H \) の積で近似します。つまり \( X \approx WH \) となる \( W \) と \( H \) を探します。何を最小にするのかというと、既定では元の行列と近似との差の二乗和、すなわち \( \| X – WH \|_F^2 \) です。この量を、どちらの行列も負の値を持たないという制約のもとで最小にします。

ここで、目的関数と、あとで出てくる再構成誤差という出力値を分けて理解しておく必要があります。最小化の対象は上に書いた二乗フロベニウスノルムですが、scikit-learnreconstruction_err_として返すのは、その平方根にあたるフロベニウスノルム \( \| X – WH \|_F \) のほうです。二乗する前の値だという点が違います。この対応は実測で確かめました。後述する設定で学習させたあと、返ってきたreconstruction_err_は30.9853でした。同じ \( W \) と \( H \) を取り出して差の二乗和を自分で計算すると960.0865になり、その平方根は30.9853で、reconstruction_err_と一致します。数字を報告に載せるときは、二乗和のつもりで読むと桁の感覚がずれるので、どちらの量なのかを添えておくと安全です。

この非負制約が効きます。負の値を許すと「この語がマイナス0.3個出ている」といった打ち消し合いが起きて、成分が意味を持たなくなります。非負に限ると、各トピックは語の足し算だけで構成され、文書は複数トピックの足し算だけで構成されます。だから解釈しやすい成分が出てきます。

LDAとの違いは3点あります。第一に、NMFは確率モデルではありません。 \( W \) の各行の合計は1になりませんし、値も確率ではなくただの非負の重みです。実測では先頭5文書の行和が0.222、0.170、0.343、0.113、0.253とばらばらでした。「トピック1が31%」という言い方はできません。第二に、TF-IDFを入力にできます。というより、TF-IDFを入れたほうがよい結果になることが多い手法です。第三に、初期値の与え方を固定すれば決定的に振る舞います。

from sklearn.decomposition import NMF

nmf = NMF(n_components=5, init="nndsvd", random_state=20260901, max_iter=600)
Wn = nmf.fit_transform(Xt)   # 入力は TF-IDF
Hn = nmf.components_

print(round(nmf.reconstruction_err_, 4))  # 出力: 30.9853  フロベニウスノルム
print(nmf.n_iter_)                        # 出力: 129

# 目的関数(二乗フロベニウスノルム)との対応を自分で確かめる
d = Xt.toarray() - Wn @ Hn
print(round(float((d ** 2).sum()), 4))            # 出力: 960.0865  差の二乗和
print(round(float(np.sqrt((d ** 2).sum())), 4))   # 出力: 30.9853   その平方根

同じデータに当てた結果を並べると、違いがはっきりします。NMFの5トピックはこうなりました。

トピック高スコア語(上位8語)主トピックとなった文書数
NMF-T0対応 / 遅れる / 通知 / 届く / 連絡 / 早急 / 発送 / 納期135件
NMF-T1プラン / 請求 / 料金 / 変更 / 内訳 / 確認 / かかる / わかる189件
NMF-T2画面 / 設定 / データ / 表示 / 登録 / 内容 / 管理 / 特定448件
NMF-T3在庫 / 商品 / 配送 / 届く / 注文 / 状況 / 表示 / 利用269件
NMF-T4見積もり / 違う / 金額 / 支払い / 早い / 回答 / 請求 / 計画159件

今回のデータに限れば、NMFのほうが名前を付けやすい結果でした。5つ全部に業務の言葉が当てられます。データにあらかじめ付いている5つのカテゴリとの一致度を調整ランド指数(1で完全一致、偶然と同程度の一致なら0付近になる指標)で測ると、NMFが0.454、LDAが0.135でした。LDAとNMFの割り振り同士の一致は0.105で、両者はかなり違う分け方をしています。

ただし、これは「NMFのほうが優れている」という一般的な結論ではありません。1文書あたり8.58語という短いテキストでは、LDAが仮定する「何回もくじを引く」という過程が働きにくく、確率モデルの利点が出にくい条件でした。文書が長く、1件の中に複数の話題が本当に混ざっているデータでは、比率として結果を読めるLDAのほうが扱いやすくなります。

再現性の差は実務で無視できません。乱数の種だけを変えて、元の結果とどれくらい一致するかを測りました。

手法種=1種=7種=12345
LDA0.0950.1500.115
NMF(init=”nndsvd”)1.0001.0000.999
NMF(init=”random”)0.8770.6260.870

LDAは種を変えると、文書の割り振りがほとんど別物になりました。数値は調整ランド指数で、1.000なら完全一致、0なら偶然と同程度です。0.1前後というのは「ほぼ違う分け方をしている」という水準です。NMFはinit="nndsvd"を指定すれば初期値が特異値分解から決まるため、種を変えてもほぼ同じ結果になります。init="random"にすると当然ばらつきます。

四半期ごとに同じ分析を繰り返す運用では、この差が効いてきます。前回と今回でトピック番号の意味が入れ替わっていたら、推移の表が作れません。処理時間も、今回の規模ではLDAが4.31秒、NMFが0.02秒でした。まずNMFで当たりを付け、話題の混ざり方を細かく見たいところだけLDAに回す、という進め方は現実的だと考えています。

LDA・NMF・BERTopicの処理の流れと、出力の読み方の違いを比べた概念図

BERTopicの位置づけ:語の一致に頼らない選択肢

近年よく名前が挙がるのがBERTopicです。今回の実行環境には導入していないため、本章では実行結果を載せません。ここでは、何をする道具なのかという位置づけだけを説明します。

LDAもNMFも、入力は文書×単語の行列です。つまり「同じ語が使われているかどうか」だけを手がかりにトピックを作ります。この設計には弱点があって、「配送が遅い」と「発送が遅延している」は共有する語が「遅」の周辺しかなく、別の話題として扱われがちです。また、1文が20文字程度しかないアンケートの回答では、そもそも共有できる語が数個しかありません。

BERTopicは、この語の一致という前提を外します。構成は4段階です。第一に、文書を1本ずつ埋め込みモデルにかけてベクトルに変換します。語が違っても意味が近ければ近いベクトルになります。第二に、そのベクトルは数百次元あって扱いにくいので、次元圧縮をかけて低い次元に落とします。第三に、圧縮後の空間でクラスタリングを行い、文書のかたまりを見つけます。第四に、できたかたまりごとに、そのかたまりを特徴づける語をc-TF-IDFという方法で取り出します。これは「1つのクラスタに属する文書を全部つなげて1つの巨大な文書とみなし、クラスタ間でTF-IDFを計算する」という考え方です。

この構成から、得意な条件が読み取れます。短文に強く、語彙の揺れや表記の不統一にも耐えます。一方で、埋め込みモデルの計算が必要なので処理は重くなりますし、クラスタリングの段階でハード割り当てに戻るため、LDAのような混合比率は基本的に出ません。どのクラスタにも入らない文書が外れ値として残る点も、件数を数える用途では扱いを決めておく必要があります。埋め込みそのものの話は第8章で扱います。

選び方の目安としては、まずNMFかLDAで足りるかを確かめ、短文で語が共有されず結果が崩れる場合にBERTopicを検討する、という順番が無理がないと考えています。

経営報告に落とす:件数・クロス集計・代表文書の3点セット

トピックが出せても、そのままでは会議で使えません。ここからは報告の形に整えます。先ほどのNMFの5トピックに、高スコア語を読んで人が名前を付けました。

# トピック名は自動では出ない。高スコア語を読んで人が決めて、コードに書き下す
TOPIC_NAMES = {
    0: "連絡と対応の遅れ",
    1: "料金プランと請求内容",
    2: "管理画面の操作と表示",
    3: "在庫表示と配送状況",
    4: "見積もりと請求金額の食い違い",
}
df["topic"] = Wn.argmax(axis=1)
df["topic_name"] = df["topic"].map(TOPIC_NAMES)
df["topic_score"] = Wn.max(axis=1)

第一に出すのは件数です。1200件のうち「管理画面の操作と表示」が448件で37.3%、「在庫表示と配送状況」が269件で22.4%、「料金プランと請求内容」が189件で15.8%、「見積もりと請求金額の食い違い」が159件で13.2%、「連絡と対応の遅れ」が135件で11.2%でした。この5行があるだけで、どこに人を割くかの議論が始められます。

第二に出すのがクロス集計です。トピックだけを見ても、すでにわかっていることの確認で終わることがあります。手元にある別の軸と掛け合わせると、新しい情報が出ます。

トピックとカテゴリのクロス集計を、各トピックの中でカテゴリが占める割合として色の濃さで表したヒートマップ

この図から読めることが2つあります。1つは、「料金プランと請求内容」は100%が料金・請求カテゴリに収まり、「在庫表示と配送状況」も83.6%が配送・在庫に収まっていることです。既存のカテゴリ分類と一致しているので、この2トピックはわざわざトピックモデルを使わなくても見えていた話題だと言えます。もう1つは、そうでないトピックの存在です。「連絡と対応の遅れ」は不具合・エラーが50.4%、配送・在庫が26.7%、対応・接客が17.0%と、3つのカテゴリにまたがっていました。「連絡が遅い」という不満は、どのカテゴリの窓口でも起きています。カテゴリ別に集計している限り、この横断的な問題は5つに分散して小さく見えます。トピックモデルの価値は、こういう既存の分類軸をまたぐ話題を浮かび上がらせる点にあります。

感情ラベルとのクロス集計も有効です。件数の多寡と不満の濃さは別の話だからです。

トピック件数ネガティブ比率
連絡と対応の遅れ135件100.0%
料金プランと請求内容189件80.4%
管理画面の操作と表示448件71.9%
見積もりと請求金額の食い違い159件52.8%
在庫表示と配送状況269件52.4%

ここでいうネガティブ比率は、第5章で使ったものと同じで、サンプルVoCデータに付いている sentiment ラベルが negative である件数の割合です。件数が最も少ない「連絡と対応の遅れ」は、ネガティブ比率が100.0%でした。この話題では褒める声が1件もありません。逆に件数が2番目に多い「在庫表示と配送状況」のネガティブ比率は52.4%で、褒める声とほぼ半々です。件数だけを見て優先順位を付けると、この差を見落とします。

第三に、代表文書を必ず添えます。これは省略されがちですが、報告の説得力を決める要素です。トピックのスコアが高い順に3件ほど取り出して、原文をそのまま載せます。

for k in range(5):
    sub = df[df["topic"] == k].sort_values("topic_score", ascending=False).head(3)
    for _, r in sub.iterrows():
        print(f"[{r['doc_id']} / {r['channel']} / スコア{r['topic_score']:.3f}] {r['text']}")

# 出力(T0「連絡と対応の遅れ」の1件目):
# [D0435 / コールセンター / スコア0.234] 以前から気になっていたのですが、
# 通知が届かないことがあり、対応が遅れました。早急に対応していただきたいです。

代表文書には2つの役割があります。1つは、トピック名が中身と合っているかを読み手が自分で検証できるようにすることです。もう1つは、数字だけでは動かない意思決定を動かすことです。「連絡と対応の遅れが135件」という行より、実際の文面が1つ添えてあるほうが、対策の議論が具体的になります。ただし、代表文書はスコア上位を機械的に選んだものであって、そのトピックの平均的な姿ではありません。上位3件だけを見て全体を判断しないよう、報告の中で一言添えるのがよいと思います。

トピックの意味は動く:推移を出すときの注意

経営報告で最も求められるのは推移です。「先月から何が増えたのか」を知りたいわけです。ここに落とし穴があります。

今回のサンプルデータには日付の列がないので、doc_idの並びを受付順とみなして4等分し、期別の件数を出しました。

トピック第1期第2期第3期第4期第1期からの増減
連絡と対応の遅れ37332936-1
料金プランと請求内容41475447+6
管理画面の操作と表示109116118105-4
在庫表示と配送状況65686373+8
見積もりと請求金額の食い違い48363639-9

このサンプルは各件を独立に生成しているので、期の間に本当の差はありません。それでも「見積もりと請求金額の食い違いが9件減った」「在庫表示と配送状況が8件増えた」という数字が出ます。全部が偶然のばらつきです。実データでも同じことが起きます。件数の変動を報告するときは、その変動幅が偶然の範囲かどうかを一緒に示さないと、存在しない傾向に対策を打つことになります。

もう1つ、より根の深い問題があります。トピックの意味そのものが、モデルを作り直すたびに動くことです。前半600件だけで学習したモデルと、後半600件だけで学習したモデルを比べました。

前半600件で学習後半600件で学習上位10語の重なり
A-T0:遅れる / 対応 / 届く / 通知 / 連絡 / 発送B-T1:対応 / 遅れる / 届く / 通知 / 早急 / 検討0.6
A-T3:在庫 / 商品 / 利用 / 配送 / 注文 / 状況B-T3:在庫 / 商品 / 配送 / 注文 / 状況 / 届く0.7
A-T1:請求 / 見積もり / 違う / 金額 / 確認 / 内訳B-T0(0.5)とB-T4(0.4)に分裂0.5 / 0.4

なお前半・後半のどちらの学習も、反復回数の上限に達しないところまで回して収束させたうえで比べています。学習が途中で打ち切られたせいで結果が違って見えている、という可能性は排除しました。同じ生成規則で作った、性質が変わらないはずのデータでこれです。前半でひとまとまりだった「請求と見積もりの話題」が、後半では「請求内訳の話題」と「見積もりの金額差の話題」の2つに割れました。トピックの番号も、A-T0がB-T1に対応するというように入れ替わっています。

ここから導かれる運用上の決めごとは、次の3つです。1つ目は、トピック番号を報告書のキーにしないことです。番号は学習のたびに意味が変わるので、必ず人が付けた名前と紐づけ、その紐づけ表を成果物として残します。2つ目は、推移を出すときは学習し直さないことです。四半期ごとの比較をしたいなら、最初の期で学習したモデルを固定し、以降の期はそのモデルでtransformするだけにします。新しい話題が出てきても既存のトピックに割り振られてしまいますが、少なくとも軸はぶれません。3つ目は、それでも半年か1年に一度は学習し直し、そのときに「トピックの定義を変えた」と明示することです。定義が変わった前後で件数を直接つなげてはいけません。

この2つ目と3つ目は矛盾しているように見えますが、実務では両方必要です。固定したモデルは、新しく生まれた話題を捉えられません。半年前には存在しなかった不満が、既存のトピックに紛れ込んで見えなくなります。だから定期的に作り直します。ただし作り直したときは、それが新しい地図であることを報告の中ではっきり書きます。地図を差し替えたことを黙っていると、件数の断絶を「施策の効果」と読み違えられます。

トピックモデルは、文書を「複数の話題の混合」、話題を「語彙上の確率分布」として捉える2階建てのモデルです。LDAは確率モデルなので混合比率として結果を読めますが、入力はBoWに限られ、乱数の種によって結果が動きます。NMFは確率を捨てる代わりに、TF-IDFを使えて再現性が高く、短文のデータでは解釈しやすいトピックが出ることが多いという性質でした。トピック数は指標だけでは決まらず、最後は「その粒度で違う打ち手が打てるか」という業務側の基準で決めることになります。

報告の形にするときは、件数・クロス集計・代表文書の3点をそろえ、トピックの意味が時間とともに動くことを前提に、モデルの固定と作り直しの方針を先に決めておきます。この章で扱ったのはここまでです。トピックモデルはラベルのないデータから話題を見つける手法でしたが、あらかじめ決まった分類先に仕分けたい場合は教師あり学習を使います。学習データの作り方、不均衡なデータの扱い、精度指標の読み方は第7章で扱います。

この章を深めたい方への参考書籍

『トピックモデル』(岩田具治、講談社):本章で扱ったLDAの生成過程を、確率分布の基礎から順に積み上げて説明している一冊です。ユニグラムモデルから混合ユニグラムモデルを経てトピックモデルに至る流れが書かれているので、「1文書が複数トピックの混合である」という仮定がなぜ必要になったのかを理解したい方に向いています。トピック数の推定についても章が割かれています。

『トピックモデルによる統計的潜在意味解析』(佐藤一誠、コロナ社):自然言語処理シリーズの第8巻として、潜在意味解析の枠組みからLDAとその学習アルゴリズム、さらに潜在空間での回帰・分類までを扱っています。本章では触れられなかった推定アルゴリズムの中身や拡張モデルを、理論の側から確認したい読者に向いています。

『Pythonではじめるテキストアナリティクス入門』(榊剛史 編著、講談社):観光・金融経済・ソーシャルメディアといった題材で、Pythonによるテキスト分析を一通り実装する構成の入門書です。本章のコードを自分のデータに置き換えて動かす段階で、前処理から分析までの流れを別の題材で確認したい方に向いています。

第7章 文書分類で仕分けを自動化する

第6章では、ラベルの付いていない大量の文書をトピックモデルで俯瞰し、どんな話題がどれくらいの比重を占めているかを教師なしで把握しました。本章で扱うのは、その裏返しにあたる作業です。あらかじめ人が決めた区分にテキストを振り分ける、教師あり分類を扱います。トピックモデルが「データの側から話題を提案してもらう」手法だったのに対して、文書分類は「業務の側で決めた区分をコンピュータに覚えさせる」手法です。

この違いは技術的な好みの問題ではなく、業務要件から決まります。振り分け先が問い合わせ窓口の担当部署なのであれば、区分は組織図で先に決まっており、データから発見するものではありません。本章では、第2章で用意したサンプルVoCデータのカテゴリを予測するタスクを実際に学習させ、ベースラインの置き方、モデルの比べ方、学習データの作り方、不均衡データへの対処、そして精度の読み方までを、すべて実行結果の数字で追いかけます。

仕分けが自動化されると業務の何が変わるか

文書分類の価値は、分類そのものではなく、分類の後ろにある業務が短くなることにあります。もっとも典型的なのは問い合わせの自動振り分けです。多くの企業では、届いた問い合わせをまず一次対応の担当者が読み、内容を判断して該当部署へ転送します。この読んで判断する時間は1件あたり数十秒から数分ですが、件数が積み上がれば無視できない量になり、しかも一次対応の担当者は転送する以外の付加価値をほとんど生んでいません。分類器が届いた時点でカテゴリを付けられれば、この工程を丸ごと省いて担当部署へ直送できます。

二つ目は応答時間の管理です。問い合わせの初回応答までの時間に社内の目標を設けている場合、どの案件がどの部署のキューに入るかが早く確定するほど、目標を外す確率は下がります。振り分けに半日かかっていたものが即時に決まれば、それだけで応答時間の分布は前に寄ります。逆に、振り分けが遅れて滞留した案件がまとめて期限を割るという壊れ方をしている組織では、精度の高いモデルを作ることよりも、振り分けの遅れをなくすことのほうが効果が大きいこともあります。

三つ目はレビューやアンケート自由記述の論点別集計です。第3章から第6章までで扱った頻度や共起やトピックは、どちらかといえば探索のための手法でした。これに対して、区分を固定して毎月同じ物差しで数え直すことができれば、経営会議に出す資料として時系列で比較できるようになります。四つ目は社内文書のタグ付けです。議事録や報告書に事業部や案件種別のタグが自動で付けば、後から探すときの手がかりが増えます。いずれの場合も、分類器の出力は最終成果ではなく、次の集計や検索を成立させるための下ごしらえだと捉えるのが実務的です。

教師あり分類は6つの工程でできている

教師あり分類の流れは、ラベル設計、学習データ作成、ベクトル化、モデル学習、評価、運用の6つに分けられます。順番に依存関係があり、後ろの工程で困ったときの原因はたいてい前の工程にあります。

ラベル設計は、どんな区分を何個作るかを決める工程です。ここで決めた区分がそのまま業務のアウトプットになるので、技術ではなく業務の判断です。学習データ作成は、その区分に従って実際の文書に正解ラベルを付ける工程で、人手がもっともかかります。ベクトル化は第3章で扱ったTF-IDFなどでテキストを数値の並びに変える工程です。モデル学習は、ベクトルとラベルの対応関係を統計的に覚えさせる工程で、実は今回の規模なら数十ミリ秒で終わります。評価は、覚えたことが未知の文書にも通用するかを確かめる工程です。運用は、できたモデルを業務のどこに置き、どこまでを自動で流し、どこから人に回すかを決める工程です。

作業時間の配分は、この順番とは一致しません。実装のうえで手を動かす時間の大半は学習データ作成に消えます。モデル学習とハイパーパラメータの探索は、本章で扱う規模であれば合わせて数秒です。したがって「どのアルゴリズムを選ぶか」に時間を使うより、「ラベルの定義をどれだけはっきりさせられるか」に時間を使うほうが、最終的な精度への効き方は大きいことが多いように思います。

まずベースラインを置く

機械学習の価値は、単独のスコアではなく、機械学習を使わなかった場合との差で語るべきものです。そのため最初にやるのは、学習をしない方法でどこまで当たるかを測ることです。ベースラインを置かないままモデルの正解率だけを報告すると、その数字が高いのか低いのかを誰も判断できません。

ここで一つ、実験の前提を明示しておきます。第2章で用意したサンプルVoCデータは、カテゴリごとの文テンプレートから機械的に生成した架空のテキストです。そのため語彙がカテゴリ間でほとんど重ならず、そのまま分類器にかけると評価セットの正解率が 1.0000 になります。ロジスティック回帰で実際に測った結果がその値でした。現実の問い合わせがこうならないのは、1通のなかに複数の論点が混ざることと、同じ文書でも人によって付けるラベルが揺れることの2つが理由です。そこで本章では、同じ乱数シードで再現できる形で、この2つを共有データに加えた版を使います。全1200件のうち他カテゴリの文が混ざったものが538件(44.8%)、付与ラベルを近いカテゴリへ意図的に揺らしたものが121件(10.1%)、加工後の平均文字数は63.7文字です。

ここで1点、章をまたいで数字を追う読者のために補足しておきます。ラベルを近いカテゴリへ揺らした結果、カテゴリごとの件数も第2章で示した内訳から動いています。本章で使う加工版の内訳は、料金・請求294件、配送・在庫245件、不具合・エラー237件、使い方・操作214件、対応・接客210件です。全体の1200件という総数は変わりませんが、もっとも多いカテゴリともっとも少ないカテゴリの差は、第2章の状態よりも縮んでいます。以降に出てくるクラス別の件数は、評価セットの内訳も含めて、すべてこの加工版のものです。第2章の内訳と一致しないのは加工の結果であって、数え違いではありません。

加工した版に対して、2種類のベースラインを測りました。1つ目は常に最頻カテゴリを返すだけの予測、2つ目は業務担当者が書きそうなキーワードルールです。ルールは5カテゴリ合計で43語だけ用意し、含まれる語数がもっとも多いカテゴリを返します。以下のコードには、この43語を5カテゴリぶんすべて載せてあります。1語も当たらなかったときにどのカテゴリを返すかも決めておく必要があり、ここでは件数がもっとも多い「料金・請求」を返す設定にしました。この2点をそろえれば、後に出てくる実測値がそのまま再現できます。

import numpy as np
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, f1_score
from sklearn.model_selection import train_test_split

from ch07_common import load_task   # 第2章のデータに論点の混在とラベルの揺れを加える

SEED = 20260901

# データの読み込みと分割(以降のコードはすべてこの分割を使う)
df = load_task()                    # 1200件。category は加工後の付与ラベル
X = df["wakati"].values             # 分かち書き済みテキスト
y = df["category"].values
raw_text = df["text"].values        # キーワードルールは分かち書き前の原文に当てる

idx = np.arange(len(df))
i_tr, i_te = train_test_split(idx, test_size=0.25, random_state=SEED, stratify=y)
X_tr, X_te = X[i_tr], X[i_te]
y_tr, y_te = y[i_tr], y[i_te]       # 学習900件 / 評価300件

# ベースライン1: 常に多数派クラスを返す
dummy = DummyClassifier(strategy="most_frequent").fit(X_tr, y_tr)
p = dummy.predict(X_te)
print(f"accuracy={accuracy_score(y_te, p):.4f} "
      f"macro-F1={f1_score(y_te, p, average='macro'):.4f}")
# 出力: accuracy=0.2467 macro-F1=0.0791

# ベースライン2: キーワードルール(5カテゴリ・計43語)
RULES = [
    ("料金・請求", ["請求", "料金", "見積もり", "支払い", "解約金", "日割り", "プラン", "値上げ", "金額"]),
    ("配送・在庫", ["配送", "在庫", "梱包", "追跡", "再配達", "納期", "発送", "届く"]),
    ("不具合・エラー", ["エラー", "不具合", "ログイン", "同期", "通知", "崩れる", "消える", "アプリ", "アップデート"]),
    ("使い方・操作", ["管理画面", "マニュアル", "設定", "検索", "CSV", "操作", "画面遷移", "手順"]),
    ("対応・接客", ["問い合わせ", "サポート", "担当者", "窓口", "折り返し", "電話", "返信", "チャット", "案内"]),
]
FALLBACK = "料金・請求"   # 1語も当たらなかったときに返すクラス

def rule_predict(texts):
    out = []
    for t in texts:
        best, best_hit = FALLBACK, 0
        for cat, kws in RULES:
            hit = sum(1 for k in kws if k in t)
            if hit > best_hit:
                best, best_hit = cat, hit
        out.append(best)
    return np.array(out)

p = rule_predict(raw_text[i_te])
print("ルール語数の合計:", sum(len(k) for _, k in RULES))
print(f"accuracy={accuracy_score(y_te, p):.4f} "
      f"macro-F1={f1_score(y_te, p, average='macro'):.4f}")
# 出力: ルール語数の合計: 43
# 出力: accuracy=0.7433 macro-F1=0.7290

多数派クラス予測の正解率(accuracy)は 0.2467 でした。5クラスあり最大クラスが全体の約4分の1なので、当然の値です。一方、43語のキーワードルールは正解率 0.7433 に達しました。この差は重要です。もし機械学習モデルが 0.75 しか出せないのであれば、43語のルールを書くだけで同じ水準に届くので、モデルを運用に載せる理由はありません。ベースラインは、後で出てくる数字に意味を与えるための基準線です。

用語をここで固定しておきます。以降の表のヘッダに出てくる accuracy は、本文で「正解率」と書いているものと同じ指標です。全体の件数のうち、モデルの予測が正解と一致した件数の割合を指します。本文で「精度」と書いている箇所は、特定の指標名ではなく、当たり具合をまとめて指す総称として使っています。

3つの分類器を同じ条件で比べる

ベースラインを置いたうえで、多項ナイーブベイズ、ロジスティック回帰、線形SVMの3つを同じ条件で比べます。それぞれの考え方を一言でいえば、多項ナイーブベイズはカテゴリごとに語の出やすさを数え、その比で判定する手法、ロジスティック回帰は語ごとに重みを学習し、その合計から各カテゴリの確率を出す手法、線形SVMはカテゴリ間の余白がもっとも広くなる位置に境界を引く手法です。3つとも、語ごとの重みを足し合わせて境界を引く線形モデルという同じ系統に属します。同じ条件というのは、同じ学習セットと評価セット、同じベクトル化の設定を使うという意味です。scikit-learn の Pipeline を使うと、ベクトル化と分類器を1つの部品として扱えるため、条件をそろえたまま分類器だけを差し替えられます。ベクトル化を先に済ませてから分類器を替えると、交差検証のときに評価側のデータの情報がベクトル化に漏れ込むので、必ずパイプラインの内側に入れます。

from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC

SEED = 20260901

def make_pipe(clf):
    return Pipeline([
        # 事前に分かち書きしてあるので空白区切りで受ける
        ("tfidf", TfidfVectorizer(token_pattern=r"\S+", ngram_range=(1, 2), min_df=2)),
        ("clf", clf),
    ])

# LinearSVC は既定のソルバが乱数を使うので乱数種を固定する
for name, clf in [("多項ナイーブベイズ", MultinomialNB(alpha=0.1)),
                  ("ロジスティック回帰", LogisticRegression(max_iter=2000, C=10.0,
                                                  random_state=SEED)),
                  ("線形SVM", LinearSVC(C=1.0, random_state=SEED))]:
    pipe = make_pipe(clf).fit(X_tr, y_tr)
    p = pipe.predict(X_te)
    print(name, accuracy_score(y_te, p), f1_score(y_te, p, average="macro"))

学習900件、評価300件、語彙数887での実測値が次の表です。学習時間は同一マシンでの1回の実測です。

手法accuracymacro-F1学習時間(秒)
多数派クラス予測0.24670.07910.000
キーワードルール(43語)0.74330.72900.000
多項ナイーブベイズ0.83000.82110.014
ロジスティック回帰0.82670.81550.072
線形SVM0.85670.84750.020
多数派クラス予測・キーワードルール・多項ナイーブベイズ・ロジスティック回帰・線形SVMのaccuracyとmacro-F1を並べた棒グラフ

3つの分類器の差は 0.83 から 0.86 の範囲に収まっており、キーワードルールとの差である約0.11に比べれば小さなものです。学習時間はいずれも0.1秒未満で、この規模ではアルゴリズムの選択が計算資源の制約になることはありません。実務上の意味づけとしては、まず線形の3手法のどれかで水準を確認し、そこから先の改善はモデルの入れ替えではなく、ラベル定義の見直しや学習データの追加で取りにいくのが順当だと考えています。テキスト分類において、高次元で疎な特徴量に対する線形モデルの相性がよいことは広く知られており、複雑なモデルへ移る前にこの水準を押さえておく価値があります。

交差検証と層化分割、そして探索の回し方

1回の分割で出た数字は、その分割の運に左右されます。特に少数クラスを含むデータでは、たまたま評価側に少数クラスが多く入るか少なく入るかで、スコアが数ポイント動きます。これを均すのが交差検証で、データを複数に分けて学習と評価を繰り返し、平均と散らばりを見ます。

ここで層化分割が要る理由が出てきます。単純な KFold はデータを機械的に分けるだけなので、各分割に含まれるクラスの比率が保証されません。StratifiedKFold は各分割のクラス比率を全体と同じに保ちます。5分割の交差検証を実際に回すと、層化なしの各分割の評価側にある最小クラスの件数は 36、35、38、36、44 件とばらつき、macro-F1 の標準偏差は 0.0245 でした。層化ありでは最小クラスの件数が 42 件でそろい、標準偏差は 0.0172 に下がりました。

少数クラスが小さくなるほど、この差は決定的になります。5クラス中の1つを10件まで間引いて同じ5分割を回すと、層化なしでは評価側に含まれる少数クラスの件数が 2、0、3、1、4 件となり、2番目の分割には1件も入りません。少数クラスが0件の分割では、そのクラスの再現率が定義できません。scikit-learn は警告を出したうえでそのクラスのスコアを0として扱うため、平均スコアは分割ごとに意味の違う数字になります。層化ありなら 2 件ずつ均等に配られます。層化分割の価値はスコアを上げることではなく、分割ごとの数字を比較可能にすることにあります。

ハイパーパラメータの探索は GridSearchCV に任せます。ベクトル化の設定と分類器の設定をまとめて探索できるのが、パイプラインを組んでおく実利です。n-gramの範囲2通り、最小出現回数3通り、正則化の強さ4通りの計24通りを層化5分割で探索したところ、学習は合計120回、所要時間は 3.75 秒でした。もっともよかった組み合わせは1語と2語の連結、最小出現回数3、正則化パラメータC(学習データへの合わせ込みを抑える正則化の強さを決める設定値。この実装では値が大きいほど正則化は弱くなります)が 5.0 で、学習セット内の交差検証 macro-F1 は 0.8059、評価セットでは accuracy 0.8400、macro-F1 0.8307 でした。もっとも悪かった組み合わせでも 0.7826 で、探索によって得られた改善幅は約0.02にとどまります。探索は効きますが、劇的には効きません。

ラベル設計と学習データ作成の実務

ここからは、コードよりも先に決めておくべきことの話です。最初の分岐は、区分を相互排他にするか多ラベルにするかです。相互排他とは、1つの文書に必ず1つだけカテゴリが付く設計です。多ラベルは、1つの文書に複数のカテゴリが付くことを許す設計です。本章の実験で使ったデータでは、他カテゴリの文が混ざった文書が44.8%を占めていました。実際の問い合わせでも、料金の話と対応の話が1通に同居することは珍しくありません。それでも相互排他を選ぶ理由は、下流の業務が相互排他だからです。転送先の部署は1つしか選べません。多ラベルにすると、複数部署に同じ案件が流れて重複対応が起きます。逆に、集計だけが目的で誰にも転送しないのであれば、多ラベルのほうが実態に近い数字になります。決め手は技術ではなく、分類結果を受け取る側の業務です。

次が「その他」クラスの扱いです。どのカテゴリにも当てはまらない文書の受け皿として「その他」を置くのは自然ですが、この区分は放っておくと膨らみます。定義が「上のどれでもない」という消去法なので、共通の言語的な特徴を持たず、モデルが学習しにくいためです。実務的には、「その他」を学習対象のクラスとして持たせるのではなく、後述する確信度のしきい値で「どのクラスにも自信を持てなかったもの」として機械的に切り出し、人が見る運びにするほうが管理しやすいと考えています。そのうえで、「その他」に落ちた文書を定期的に読み返し、まとまった塊があれば新しいクラスとして昇格させます。

1クラス何件必要かは、よく聞かれる割に一般解のない問いです。目安の考え方としては、絶対的な件数を先に決めるのではなく、件数を増やしながら精度の伸びを測り、伸びが鈍ったところで止めるのが現実的です。実際に学習データの件数を変えて測った結果が次の表です。

学習データ件数(5クラス合計)1クラス平均accuracymacro-F1
50100.61000.5993
100200.73670.7263
200400.77670.7690
400800.78330.7726
6001200.79000.7808
9001800.82670.8155

1クラス10件では macro-F1 が 0.5993 とキーワードルールにも届きませんが、1クラス40件まで増やすと 0.7690 に達し、そこから80件、120件と増やしても 0.7726、0.7808 と伸びは緩やかです。この形は、最初の数十件がもっとも価値が高く、そこから先は投じた工数あたりの改善が減っていくことを示しています。したがって、いきなり1万件をアノテーションする計画を立てるのではなく、まず1クラス50件ほどで測り、伸びしろを確認してから追加分を決めるのが合理的です。

複数人でラベルを付ける場合は、一致率を測ります。同じ100件を2人が独立に分類し、どれだけ一致したかを見る指標として、偶然の一致を差し引いたコーエンのカッパ係数がよく使われます。一致率が低いときに真っ先に疑うべきはアノテーターの能力ではなく、ラベル定義の曖昧さです。人が迷う境界は、モデルにとっても迷う境界になります。本章で使った加工版でも、論点の混在と付与ラベルの揺れという2つを入れただけで、正解率は 1.0000 から 0.8567 まで落ちました。しかも後述するように、誤りのうち半数近くはラベルの揺れそのものに由来します。人の揺れは、そのまま精度の上限になります。

アノテーションの工数を下げる考え方が能動学習です。手順としては、まず少量のデータでモデルを作り、ラベルのない大量の文書に予測させ、モデルがもっとも自信を持てなかった文書から優先的に人がラベルを付けて学習に加えます。無作為に選ぶより、境界付近の文書に人手を集中させるほうが、同じ件数で得られる情報が多いという考え方です。能動学習を回す前提として、モデルが確信度を出せる形になっている必要があります。

1文書あたりの論点の数と下流業務の形から、ラベル設計の型を選ぶ2軸マトリクス

不均衡データで最初に壊れるのは正解率の読み方

業務データのクラス比率は、たいてい偏っています。重大なクレームは全体の数パーセント、解約につながる兆候はさらに少ないのが普通です。そして、見つけたいのはたいてい少数側です。ここで起きるのが、正解率が高いのに使いものにならないという状態です。

仕組みは単純です。分類器は全体の誤りを減らすように学習するので、少数クラスを全部見逃しても、多数クラスさえ当てていれば損失はほとんど増えません。少数クラスが全体の5%なら、少数クラスを1件も見つけずに「全部多数派」と答えるだけで正解率95%です。この構造を実測するため、5クラスのうち1つを60件まで間引き、「そのカテゴリか否か」の二値分類にしました。全1050件のうち少数クラスは60件、比率にして 5.71%、多数派との比は 16.5 対 1 です。

対処として、何もしない場合、クラス重みを付ける場合、多数派を減らすアンダーサンプリング、少数派を増やすオーバーサンプリング、そして予測確率のしきい値を下げる場合の5通りを、層化5分割の out-of-fold 予測でそろえて比べました。out-of-fold 予測とは、各データについて、そのデータを学習に使わなかった分割のモデルで出した予測のことです。こうすると全1050件それぞれに「未知のデータとして扱われたときの予測」が1つずつ付くので、5通りの対処を同じ土俵で比べられます。サンプリングの処理は必ず学習側の分割にだけ適用します。評価側にまで適用すると、実際には存在しない比率のデータで採点することになり、数字が壊れます。

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
from sklearn.pipeline import Pipeline

SEED = 20260901
TARGET = "対応・接客"

# 少数クラスを60件まで間引き、「そのカテゴリか否か」の二値にする
# df は前のコードブロックで load_task() から作ったもの
rng0 = np.random.RandomState(SEED)
pos_all = np.where(df["category"].values == TARGET)[0]
neg_all = np.where(df["category"].values != TARGET)[0]
keep = np.array(sorted(np.concatenate(
    [rng0.choice(pos_all, size=60, replace=False), neg_all]).tolist()))
sub2 = df.iloc[keep].reset_index(drop=True)
# 元の X / y は上書きせず、この実験専用の配列として持つ
X2 = sub2["wakati"].values                             # 文字列の配列(1050件)
y2 = (sub2["category"].values == TARGET).astype(int)   # 0 か 1 の配列

# 分類器へのキーワード引数を受け取るパイプライン
# 前に出てきた make_pipe(clf) とは引数の形が違うので、別の名前にしておく
def make_lr_pipe(**kw):
    return Pipeline([
        ("tfidf", TfidfVectorizer(token_pattern=r"\S+", ngram_range=(1, 2), min_df=2)),
        ("clf", LogisticRegression(max_iter=2000, C=5.0, random_state=SEED, **kw)),
    ])

strategy = "under"               # "none" / "weight" / "under" / "over" を順に試す
rng = np.random.RandomState(SEED)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
prob = np.zeros(len(y2))
for tr, te in cv.split(X2, y2):
    tr_idx = tr
    if strategy == "under":          # 多数派を少数派と同数まで減らす
        pos = tr[y2[tr] == 1]
        neg = rng.choice(tr[y2[tr] == 0], size=len(pos), replace=False)
        tr_idx = np.concatenate([pos, neg])
    elif strategy == "over":         # 少数派を多数派と同数まで復元抽出で増やす
        neg = tr[y2[tr] == 0]
        pos = rng.choice(tr[y2[tr] == 1], size=len(neg), replace=True)
        tr_idx = np.concatenate([pos, neg])
    kw = {"class_weight": "balanced"} if strategy == "weight" else {}
    pipe = make_lr_pipe(**kw)
    pipe.fit(X2[tr_idx], y2[tr_idx])  # 学習側だけを加工する
    prob[te] = pipe.predict_proba(X2[te])[:, 1]

# 出力: 少数クラス 60 / 1050(5.71%)
# 出力: under: accuracy=0.7914 precision=0.183 recall=0.767 F1=0.296 FN=14 FP=205
対処accuracy(全体)少数クラス precision少数クラス recall少数クラス F1取りこぼし誤検知
何もしない0.94950.7330.1830.293494
class_weight="balanced"0.90000.3140.6330.4202283
アンダーサンプリング0.79140.1830.7670.29614205
オーバーサンプリング0.91620.3600.6000.4502464
しきい値を0.20に下げる0.93710.4530.4830.4683135
少数クラスへの5つの対処ごとに、全体のaccuracyと少数クラスのprecision・recall・F1を並べた棒グラフ

何もしない場合の正解率は 0.9495 で、一見すると優秀です。しかし少数クラスの再現率は 0.183 しかなく、60件のうち49件を取りこぼしています。この状態を「正解率95%のモデルができました」と報告してしまうと、受け取った側は運用に載せてよいと判断します。正解率だけを見ると壊れる、というのはこの現象を指しています。

class_weight="balanced" はクラスの出現頻度の逆数を損失の重みにする設定で、少数クラスを見逃すコストを引き上げます。再現率は 0.183 から 0.633 まで上がり、そのかわり誤検知が4件から83件に増え、全体の正解率は 0.9000 に下がりました。アンダーサンプリングは再現率を 0.767 まで押し上げますが、学習に使える多数派の情報を大量に捨てるため誤検知が205件に膨らみ、適合率は 0.183 まで落ちます。オーバーサンプリングは同じ少数クラスの文書を複製するだけなので情報は増えませんが、多数派を捨てない分だけバランスがよく、F1 は 0.450 でした。

興味深いのは、何もしないモデルの予測確率をそのまま使い、判定のしきい値を 0.50 から 0.20 に下げただけの場合です。F1 は 0.468 と、他の4通りのどれよりも高くなりました。クラス重みを付ける場合とサンプリングを行う2通りを合わせた3通りは、学習の側に手を入れているにもかかわらず、いずれもこの値に届いていません。学習の側をいじらなくても、出力の読み方を変えるだけで動く範囲がかなりあるということです。実務では、まずしきい値の調整で足りるかを確かめ、それでも足りないときにクラス重みやサンプリングへ進むのが順序として無駄がありません。

同じことは5クラスのままでも起きます。1クラスだけを60件に間引いて多クラス分類を学習させると、全体の正解率は 0.8631 と高いのに、間引いたクラスの再現率だけが 0.267 まで落ちました。class_weight="balanced" を付けると、全体の正解率は 0.8593 とほとんど変わらないまま、そのクラスの再現率は 0.467 に上がります。全体の正解率という1つの数字は、少数クラスの状態をほぼ何も伝えないということです。

しきい値は、モデル側の都合ではなく業務要件から逆算します。たとえば「少数クラスの8割は拾いたい」という要件があるなら、それを満たす最小のしきい値を求めます。実測では 0.073 で再現率 0.800、そのときの適合率は 0.262、取りこぼし12件に対して誤検知135件、人が確認する件数は183件でした。60件を探すために183件を読む運用が許容できるかどうかは、1件見逃したときの損失と1件余分に読むコストの比較で決まります。この会話は精度指標ではなく業務の言葉で行うべきものです。

精度の読み方:混同行列とクラスごとの数字

ここまで何度か出てきた適合率と再現率を整理します。あるクラスについて、モデルがそのクラスだと判定したもののうち実際に正しかった割合が適合率、実際にそのクラスであるもののうちモデルが拾えた割合が再現率です。F1はこの2つの調和平均で、片方だけが高い状態を高く評価しないようにした指標です。同じモデルの判定のしきい値を動かす限りは、適合率を上げれば再現率が下がり、再現率を上げれば適合率が下がるという関係になるため、どちらを優先するかは業務が決めます。なお、この関係は「常にどちらかを諦めなければならない」という意味ではありません。モデルや学習データそのものを良くすれば、適合率と再現率は同時に上がります。トレードオフになるのは、モデルを固定したうえで出力の切り方だけを変えるときの話です。

全体像を見るには混同行列を使います。行に正解のカテゴリ、列にモデルの予測を取り、件数を並べた表です。5クラスの分類を線形SVMで学習し、評価セット300件に対して出した混同行列が次の図です。

5カテゴリの混同行列。行が正解カテゴリ、列が予測カテゴリで、対角成分と主な取り違えの件数が示されている

誤りは300件中43件で、その内訳には明確な偏りがあります。「使い方・操作」を「不具合・エラー」と取り違えたものが12件、その逆が9件で、この2クラス間の往復だけで誤り全体の約半分を占めます。次に多いのが「対応・接客」を「配送・在庫」と誤ったもの9件、「料金・請求」と誤ったもの7件です。一方で「料金・請求」と「配送・在庫」の間の取り違えは合計1件しかありません。これは、操作がわからないという相談とエラーが出るという相談が、書き手の言葉のうえで近いことを示しています。全体のスコアを1つ眺めているだけでは、この構造は見えません。

クラスごとに分けた数字が次の表です。

カテゴリ件数precisionrecallF1
料金・請求740.9100.9590.934
配送・在庫610.8530.9510.899
不具合・エラー590.7810.8470.813
使い方・操作540.8240.7780.800
対応・接客520.9230.6920.791
macro平均0.8580.8460.848
weighted平均0.8600.8570.854

全体の正解率は 0.8567 ですが、クラスごとに見ると 0.934 から 0.791 まで幅があります。特に「対応・接客」は適合率 0.923 に対して再現率 0.692 と非対称で、このカテゴリだと判定したものはほぼ当たっている一方、本来このカテゴリであるものの3割を他へ流している状態です。振り分け先の部署から見れば、届いた案件は正しいが、来るべき案件の一部が来ていないという状況です。

macro平均とweighted平均の違いも押さえておく必要があります。macro平均は各クラスのスコアを単純平均するので、件数の少ないクラスも大きいクラスも同じ重みで効きます。weighted平均は件数で重み付けするので、大きいクラスの成績に引っぱられます。本章の表では両者の差は 0.848 と 0.854 でわずかですが、これはクラスの件数が 52 件から 74 件の範囲に収まっているからです。1つのクラスが全体の9割を占めるようなデータでは、weighted平均は多数クラスの成績とほぼ同じ値になり、少数クラスがどれだけ悪くても数字に現れません。少数クラスを見つけることが目的なら macro平均で見るのが妥当です。なお多クラス分類のmicro平均は全体の正解率と一致し、実測でも 0.8567 で一致しました。

ROC曲線とPR曲線の使い分けも、不均衡データでは実務的な差が出ます。先ほどの少数クラス比率 5.71% の二値分類で測ると、ROC曲線の下の面積は 0.879 でした。PR曲線の側は、average_precision_scoreで計算した Average Precision が 0.475 です。

ここで呼び名を1つに決めておきます。PR曲線の下の面積は、慣用的にPR-AUCと呼ばれますが、計算方法が1つに定まっていません。average_precision_scoreは、しきい値を下げるたびに増えた再現率の分に、そのときの適合率を掛けて足し上げる階段状の総和で、曲線を台形で近似する積分ではありません。同じデータでaucに再現率と適合率を渡して台形積分すると 0.470 になり、Average Precision の 0.475 とは一致しませんでした。差は 0.005 弱で結論は変わりませんが、別々の計算をした値に同じ名前を付けると、他の資料の数字と突き合わせたときに理由の分からないずれが残ります。本コラムでは、実際に計算しているのがaverage_precision_scoreのほうなので、値の名前は Average Precision とします。

ROCは偽陽性率を横軸に取るため、多数派が大量にあるほど分母が大きくなり、少数クラスをほとんど拾えていなくても高い値が出ます。PRのほうは、でたらめに予測したときの水準が少数クラスの比率そのもの、この場合 0.057 になるので、実際の使いにくさが数字に反映されます。少数クラスを見つけたい問題ではPR曲線を見るほうが安全です。

同一モデルのPR曲線とROC曲線を並べた図。PR曲線側は Average Precision の0.475、ROC曲線側はROC-AUCの0.879で、ランダム予測の水準も破線で示されている

ここまでを踏まえると、「精度95%です」という報告がなぜ意味を持たないことがあるかが整理できます。クラス比率が偏っていれば95%は何もしなくても出る値であり、どのクラスの何を見落としているかが示されていなければ、その数字から業務上の判断は導けません。報告に含めるべきなのは、単一の平均値ではなく、ベースラインとの差、クラスごとの適合率と再現率、そして主な取り違えの組です。

誤分類を読み、原因を切り分ける

数字を眺めるだけでは次の一手が決まりません。最後に必ず行うのが、誤分類した文書を実際に取り出して読む作業です。目的は、その誤りがラベル定義の曖昧さから来ているのか、モデルの力不足から来ているのかを切り分けることです。前者なら定義を直し、後者なら学習データや特徴量を増やす、という具合に打ち手が変わります。

# df / X / y / i_tr / i_te は、ベースラインのコードブロックで作ったもの
pipe = make_pipe(LinearSVC(C=1.0, random_state=SEED))
pipe.fit(X[i_tr], y[i_tr])
pred = pipe.predict(X[i_te])       # 評価セット300件に対する予測

sub = df.iloc[i_te].copy()
sub["pred"] = pred
wrong = sub[sub["category"] != sub["pred"]]

for _, row in wrong.head(3).iterrows():
    print(f"[{row['doc_id']}] 正解={row['category']} 予測={row['pred']}")
    print("  ", row["text"])

# 出力(抜粋):
# [D0427] 正解=配送・在庫 予測=対応・接客
#    率直な感想です。梱包が丁寧で、商品に傷ひとつありませんでした。満足しています。窓口をたらい回しにされ、同じ説明を三回しました。
# [D0148] 正解=使い方・操作 予測=不具合・エラー
#    以前から気になっていたのですが、管理画面のどこを押せば設定できるのかわかりませんでした。改善をお願いします。他システムとの同期が途中で止まり、最新の数字になりません。

取り出した43件を分類すると、他カテゴリの文が混ざっている文書が33件(76.7%)、付与ラベルを揺らした文書が21件(48.8%)で、この2つに該当しないものは0件でした。33件と21件を足すと43件を超えますが、これは2つの原因が重複しているためで、両方に当てはまる文書が11件あります。人工的に作ったデータなので誤りの原因が設計どおりに出るのは当然ではありますが、実務でも、誤分類の大半がデータ側に理由を持つという構図は珍しくありません。さらに、ラベルを揺らした21件については、21件すべてでモデルの予測が加工前の元カテゴリと一致していました。つまりモデルが外したのではなく、正解として与えたラベルの側が揺れていたということです。この分をモデルの改善課題として扱うと、直せないものを直そうとして工数を費やすことになります。

混ざっている文書のほうは、ラベル定義の問題です。上に挙げた D0427 は、梱包への好意的な感想と窓口対応への不満が同じ文書に入っています。正解ラベルは「配送・在庫」ですが、モデルは「対応・接客」と答えました。人が読んでも判断が割れる文書であり、モデルの誤りとして数えることに意味は薄いと考えます。こうした文書がまとまった量あるなら、選ぶべきは「複数の論点を含む場合は先に書かれたほうを採る」といった判断規則をアノテーション指針に明記することか、あるいは多ラベルに設計を変えることであって、モデルを取り替えることではありません。

運用面での現実的な着地も、この分析から導けます。判定の確信度が低いものを人手に回す仕組みを入れると、自動処理した分の正解率を引き上げられます。実測では、確信度0.6以上だけを自動処理する設定で全体の83.0%を自動化し、その部分の正解率は 0.8795、0.7以上に上げると自動化率67.0%で正解率 0.9005、0.8以上では自動化率43.7%で 0.9084 でした。人手に回る件数は順に51件、99件、169件です。すべてを自動で流すことにこだわらず、確信度の低い部分を人に残す設計にすれば、自動化率と正解率のどちらを優先するかを運用中に調整できます。

確信度のしきい値で自動転送と人手確認に振り分け、確認結果を学習データへ戻す運用フローの概念図

本章では、教師あり分類をベースラインから組み立て、5クラスのVoCデータに対してキーワードルールの macro-F1 0.7290 に対し線形SVMで 0.8475 という水準を実測しました。そのうえで、不均衡なデータでは正解率 0.9495 の裏で少数クラスの再現率が 0.183 まで落ちることを確かめ、混同行列とクラスごとの指標で読み解く手順を示しました。ここまでの分類はすべて、単語の一致を手がかりにした特徴量に立っています。言い回しが違うだけで同じことを言っている文書をどう扱うかは、単語の一致では届かない領域です。第8章では、文書を意味の空間の1点として表す埋め込み表現を扱い、類似検索やFAQ照合へつなげます。

この章を深めたい方への参考書籍

『IT Text 自然言語処理の基礎』(岡崎直観・荒瀬由紀・鈴木潤・鶴岡慶雅・宮尾祐介、オーム社):情報系の教科書として、テキストの表現から分類、系列ラベリング、ニューラル言語モデルまでを一貫した記法で整理した一冊です。本章で扱った教師あり分類を、言語処理全体の体系のどこに位置する技術なのかという視点から確認したい読者に向いています。

『評価指標入門〜データサイエンスとビジネスをつなぐ架け橋』(高柳慎一・長田怜士、技術評論社):本章の後半で扱った適合率・再現率・macro平均・PR曲線といった指標を、二値分類と多クラス分類に章を分けて整理した本です。どの指標を選ぶかがビジネス側のKPIとどうつながるかという観点が通っており、精度の数字を経営に説明する立場の読者に向いています。

『Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎』(Andreas C. Müller・Sarah Guido著、中田秀基訳、オライリー・ジャパン):本章で使った Pipeline、交差検証、GridSearchCV の使い方を、scikit-learn の設計思想に沿って確認したい読者に向いています。テキストデータの扱いにも章が割かれており、本章のコードを自分の手元で組み替えるときの土台になります。

第8章 文書ベクトルと類似検索

第7章では、あらかじめ決めたラベルを人手で付け、そのラベルを当てる分類器を学習させました。仕分けの基準が固定されていて、過去の判断を再現したい場面では、この教師あり分類がもっとも素直な選択肢になります。ただし業務には、ラベルを決めきれない場面も多くあります。「この問い合わせに似た過去案件を出してほしい」「この不満に近い声が他に何件あるか知りたい」といった要求は、分類ではなく検索の形をしています。この章では、文書を数値のベクトルに変換し、意味の近さで並べ替える方法を扱います。

扱う内容は、単語をベクトルにする考え方から始めて、文書をベクトルにする方法の変遷、そして現在の実務で使われている文埋め込みモデルまでです。第2章で用意したサンプルVoCデータを使い、従来のTF-IDF検索と文埋め込みによる検索を同じクエリで走らせて、どちらがどう強いのかを実際の出力で確かめます。あわせて、埋め込みモデルを業務に入れるときに何を見て選び、運用でどこにつまずくのかまで書きます。生成AIを使った検索の設計は第9章で扱いますので、ここでは埋め込みそのものに絞ります。

単語が一致しなければ、意味が同じでも見つからない

第3章で作ったTF-IDFの表は、文書を「どの単語が何回出たか」で表したものでした。検索に使うときは、クエリも同じように単語のベクトルにして、文書ベクトルとの角度が近い順に並べます。この仕組みは、クエリの単語が文書にも出てくることを前提にしています。逆に言えば、同じことを別の言葉で書かれた瞬間に、その文書は候補から外れます。これを語彙のミスマッチと呼びます。

問い合わせの現場で起きる典型は3つあります。1つ目は表記ゆれで、「ログイン」と「ログオン」、「問合せ」と「問い合わせ」のように、同じ対象を違う文字列で書くものです。2つ目は言い換えで、「解約」と「契約の解除」、「請求」と「支払い」、「配送」と「発送」のように、語そのものが違うが指しているものは同じというケースです。3つ目は抽象度の違いで、顧客が「お金が引かれすぎている」と書いた事象を、社内では「二重請求」と呼んでいるようなものです。どれも人間には同じ意味に読めますが、単語の一致だけを見る仕組みには別物として映ります。

サンプルVoCデータで実際に測りました。第2章と第3章の前処理を通したあとの内容語の異なり語数は178語です。ここに、コーパスの本文には出てこない言い回しで4つのクエリを投げます。

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from ch03_common import wakati
from voc_corpus import load_voc

df = load_voc()
texts = df["text"].tolist()

vec = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b", min_df=2)
X = vec.fit_transform([wakati(t) for t in texts])   # (1200, 178)

for q in ["解約したい", "お金が引き落とされすぎている",
          "サインインできません", "荷物がなかなか来ない"]:
    sim = cosine_similarity(vec.transform([wakati(q)]), X).ravel()
    print(q, "スコア>0の文書数:", int((sim > 0).sum()))

# 出力:
# 解約したい スコア>0の文書数: 37
# お金が引き落とされすぎている スコア>0の文書数: 0
# サインインできません スコア>0の文書数: 0
# 荷物がなかなか来ない スコア>0の文書数: 60

4つのうち2つは、1200件すべてに対してスコアが0でした。「お金」「引き落とす」「サインイン」という語がコーパスに1件も出てこないためです。検索としては何も返せていない状態で、それでも上位3件は表示されます。スコアがすべて0のときは、順位が配列の並び順で決まってしまうだけなので、画面には「関係のない文書が3件並んでいる」ように見えます。検索窓に何か出てくることと、検索できていることは別だという実例です。

残りの2つも安心はできません。「荷物がなかなか来ない」は60件がスコア0より上になりましたが、上位3件はすべて「在庫ありの表示で注文したのに、後から在庫切れの連絡が来ました」でした。一致した語は「来る」です。顧客が言いたい「荷物が届かない」と、文書が言っている「連絡が来た」は、動詞の原形が同じというだけで意味は無関係です。単語の一致という指標は、意味の一致を保証しません。

クエリ前処理後の内容語コーパスでの出現文書数TF-IDFでスコア0より大きい文書
解約したい解約解約:37件37件
お金が引き落とされすぎているお金、引き落とすお金:0件、引き落とす:0件0件
サインインできませんサインインサインイン:0件0件
荷物がなかなか来ない荷物、来る荷物:0件、来る:60件60件

対処として真っ先に思いつくのは同義語辞書です。「サインイン」を「ログイン」に寄せる規則を書けば、この1件は解決します。実際、社内用語や製品名のように言い換えの候補が限られている領域では、辞書が最も確実で説明もしやすい方法です。ただし顧客が書く自由文の言い回しは有限ではありません。新しい表現が出てくるたびに辞書を足す運用は、いずれ追いつかなくなります。単語そのものに意味の近さを持たせられないか、という発想が次に来ます。

分布仮説と、単語の分散表現という考え方

言語学には、語の意味はその語が現れる文脈によって決まるという分布仮説の考え方が古くからあります。「解約」と「退会」は、どちらも「手続き」「したい」「金」「フォーム」といった語の近くに現れます。周りに出てくる語の傾向が似ているなら、その2語の意味も似ているだろう、と考えるわけです。この仮説を計算に落としたものが単語の分散表現です。1語を1本の数百次元の実数ベクトルで表し、意味の近い語どうしがベクトル空間で近くなるように数値を決めます。

その代表がword2vecです。学習のやり方は2通りあり、周辺の語から中心の語を当てにいく方法と、中心の語から周辺の語を当てにいく方法があります。どちらも「文中のある位置に、その語が出るかどうか」を当てる問題を大量に解かせるだけで、意味の定義を人が与えることはありません。学習が終わると、副産物として各語のベクトルが手に入ります。ラベルを一切用意せず、生のテキストだけから語の関係を取り出せる点が、この手法が広く使われた理由です。

単語の分散表現でよく紹介されるのが、ベクトルの引き算と足し算です。「王」から「男」を引いて「女」を足すと「女王」に近いベクトルになる、という例が知られています。実務でこの加減算そのものを使う場面は多くありませんが、意味の関係が座標の差として表現されうるという性質は、この後に出てくる文書ベクトルの土台になっています。なお、word2vecの発展として、単語を文字の並びに分解して学習する方式もあります。こちらは未知の単語や表記のゆれに強く、日本語のように複合語が多い言語では扱いやすい面があります。

ここで断りを入れます。word2vecやこの後に出てくるdoc2vecを学習するライブラリは、本記事の実行環境のPythonバージョンに対応する配布物がなく、導入していません。したがってこの節と次の節の一部については、実行結果を載せていません。考え方の説明にとどめ、数値を伴う検証は、実際に動かせた文埋め込みモデルの側で行います。

単語ベクトルには、業務で使うときに知っておくべき限界もあります。1つは、1語に1本のベクトルしか持てないことです。「対応」という語は「サポート対応」と「システムの対応状況」で意味が違いますが、同じベクトルが割り当てられます。もう1つは、反対の意味の語も近くなりやすいことです。「高い」と「安い」は同じような文脈に現れるため、分布仮説の観点では似た語と判定されます。価格への不満を探しているときに、価格を褒めている文が混ざる原因になります。

文書ベクトルの作り方の系譜と、それぞれが改善したもの

検索したいのは単語ではなく文書です。単語のベクトルを文書のベクトルに変える方法は、いくつかの段階を踏んで発展してきました。何が問題で、何が改善されたのかを順に整理します。

方式文書の表し方前の方式から改善された点残る弱点
BoW、TF-IDF語彙の数だけ次元を持つ疎ベクトル。ほとんどの成分が0数えるだけで作れる。どの語が効いたかを人に説明できる語が一致しないとスコアが0になる。語順を持たない。次元が語彙数まで増える
単語ベクトルの平均文書に出てくる単語のベクトルを平均した密ベクトル。数百次元語彙が違っても意味が近ければ近い値になる。次元が語彙数に依存しない語順が消える。「安い」と「安くない」がほぼ同じ位置になる。長い文書ほど平均で情報が薄まる
doc2vec文書そのものに識別子を与え、単語と一緒に学習させて文書固有のベクトルを得る文書ごとの特徴を、単語の平均に還元せずに持てる手元のコーパスで学習が必要。量が少ないと安定しない。新しい文書のベクトルを出す手順が追加で要る
SBERT系の文埋め込み事前学習済みのTransformerに文を通し、文全体を1本の密ベクトルにするBoWや単語の平均より、文脈と語順を反映しやすい。学習済みモデルをそのまま使えるモデルへの依存が強い。計算コストが上がる。中身の説明がしにくい

単語ベクトルの平均は、実装が数行で済むうえに効果もある方法で、今でも小規模な用途では選択肢になります。ただし否定に弱いという性質は業務上かなり痛みます。VoC分析では「使いやすいとは言えません」のような文が重要な不満のシグナルですが、単語を平均する方式では「使いやすい」の成分が強く残り、好意的な文の近くに配置されがちです。第5章で扱った否定の難しさが、ここでも同じ形で現れます。

doc2vecは文書ごとの表現を直接学習する点で理屈が通っていますが、手元のコーパスで学習する以上、データ量とドメインの偏りの影響を受けます。数千件のVoCで学習させたベクトルは、そのVoCの世界でしか通用しません。一方で、次に述べる文埋め込みモデルは、大量の一般テキストで先に学習を済ませたものを持ってきて使えます。自社データが少ない段階でも成果が出しやすいのは、この違いによるところが大きいと考えています。

Sentence-BERTが解いた問題と、双塔構造

BERTのような事前学習済みモデルは、2つの文を並べて入力し、その2文がどれだけ似ているかを直接出力させることができます。精度は高いのですが、検索には向きません。理由は計算量です。手元の1200件から似た文の組を全部見つけようとすると、組み合わせは1200かける1199を2で割って719,400通りになります。1件の推論に10ミリ秒かかるとしても2時間近くかかる計算で、検索のたびにこれを走らせることはできません。クエリを1件投げるだけでも、1200件すべてとペアを組んで1200回の推論が必要になります。

Sentence-BERTは、この構造を組み替えることでその問題を解きました。2文を一緒に入力するのをやめ、同じモデルに1文ずつ通して、それぞれを独立した1本のベクトルにします。似ているかどうかは、出てきた2本のベクトルの角度で測ります。2つの入力を別々の塔に通すように見えることから、双塔構造と呼ばれます。学習の段階で「意味が近い文のペアは近い位置に、遠いペアは遠い位置に」なるようにモデルを調整しておくのがポイントで、これによりベクトルどうしの角度が意味の近さとして使えるようになります。

この形にすると、文書側のベクトルは検索の前に1回だけ計算して保存しておけます。検索時にモデルを動かすのはクエリの1文だけで、あとは保存済みのベクトルとの掛け算です。1200回の推論が1回の推論と1回の行列計算に変わるわけで、これが実用に耐える速度を生んでいます。

従来型の入力結合方式と双塔構造による類似度計算の違いを比べた概念図

実際に日本語の文埋め込みモデルを動かします。使うのはcl-nagoya/ruri-v3-30mです。配布元のモデルカードには、出力は256次元、入力の上限は8192トークン、ライセンスはApache License 2.0と記載されています。このうち出力の次元数と入力の上限は、手元でモデルを読み込んで実際に値を出力させ、記載どおりであることを確認しました。ライセンスは配布元の記載を参照したもので、業務で使う前には必ず自分で最新の記載を確認してください。このモデルには特徴があり、用途に応じて入力の先頭に接頭辞を付ける設計になっています。意味そのものを表したいときは接頭辞なし、分類やクラスタリングにはトピック: 、検索のクエリ側には検索クエリ: 、検索される文書側には検索文書: を付けます。検索用途ではクエリと文書で別の接頭辞を使う点が重要です。

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("cl-nagoya/ruri-v3-30m", device="cpu")

# texts は前のコードブロックで作った1200件の原文リスト
# queries は前の節でTF-IDF検索に投げたものと同じ4件
queries = ["解約したい", "お金が引き落とされすぎている",
           "サインインできません", "荷物がなかなか来ない"]

# 文書側は「検索文書: 」、クエリ側は「検索クエリ: 」を付ける
D = model.encode(["検索文書: " + t for t in texts],
                 batch_size=32, convert_to_numpy=True,
                 normalize_embeddings=True)
Q = model.encode(["検索クエリ: " + q for q in queries],
                 convert_to_numpy=True, normalize_embeddings=True)

sim = D @ Q[0]                 # 正規化済みなので内積がそのままコサイン類似度
top3 = sim.argsort()[::-1][:3]

# 出力:
# 最大入力トークン長: 8192
# 文書埋め込み: (1200, 256)  計算秒数: 6.0
# L2ノルム(正規化後): 平均 1.0
# 埋め込み配列のメモリ: 1.17 MB(float32)

実行環境はCPUのみです。モデルの読み込みに20.1秒、1200件の埋め込みに6.0秒でした。1件あたり5ミリ秒ほどで、数千件から数万件の社内文書であれば、夜間のバッチどころか業務時間中に流しても支障のない水準です。出来上がった配列は1200行かける256列で、float32で保存して1.17メガバイトでした。以下、本章でキロバイト、メガバイト、ギガバイトと書く値は、いずれも1段上がるごとに1024倍する換算でそろえています。元のテキストが1200件で平均52.2文字ですから、テキストそのものより大きいことになります。ここは件数が増えたときに効いてくるので、後の節で改めて扱います。

同じクエリで、TF-IDF検索とSBERT検索を並べる

前の節で0件しか返せなかったクエリを、そのまま文埋め込みの検索に投げます。結果を並べたものが次の図です。左がTF-IDF、右がSBERTで、それぞれ上位3件を表示しています。横軸はどちらもコサイン類似度ですが、値の作られ方が違うので、方式をまたいで絶対値を比べることはできません。見るべきは、どの文書が上に来たかです。

4つのクエリについて、TF-IDF検索とSBERT検索の上位3件を横棒グラフで並べた比較図

「お金が引き落とされすぎている」では、TF-IDF側は3件ともスコア0で、内容も納期の連絡や見積もりへの好意的な声でした。SBERT側の上位3件はすべて「日割り計算の考え方が案内と違っていて、支払い額が想定より高いです」で、類似度は0.847から0.843です。「お金」も「引き落とす」も文書側には一度も出てきませんが、支払い額が想定より高いという意味を拾えています。

「サインインできません」も同じです。TF-IDF側はスコア0のまま無関係な文が並び、SBERT側は上位3件すべてが「ログインできない状態が数日続いています」でした。類似度は0.890から0.888です。サインインとログインを結びつける辞書は一切与えていません。

「荷物がなかなか来ない」は、両方式の差が最もはっきり出た例です。TF-IDFの上位3件は「在庫ありの表示で注文したのに、後から在庫切れの連絡が来ました」で、動詞「来る」の一致に引きずられていました。SBERTの上位3件は「配送の予定日を過ぎても商品が届きませんでした」「追跡番号が発行されたのに、配送状況が反映されません」で、類似度は0.893から0.891です。荷物が届かないという意味に沿って並んでいます。

一方で「解約したい」は、両方式ともほぼ同じ文書群を返しました。「解約」という語がコーパスに37件あるためで、TF-IDFでも十分に届く範囲です。ここから読み取れるのは、文埋め込みが常にTF-IDFを上回るのではなく、語が一致する範囲では差が出にくい、ということです。差が出るのは、語が一致しないときだけです。

上位10件のうち、そのクエリが想定するカテゴリの文書が何件入っていたかも数えました。

クエリ想定カテゴリTF-IDFでスコア0より大きい文書TF-IDF上位10件の的中SBERT上位10件の的中
解約したい料金・請求37件10件10件
お金が引き落とされすぎている料金・請求0件6件10件
サインインできません不具合・エラー0件1件10件
荷物がなかなか来ない配送・在庫60件10件10件

この表の読み方には注意が必要です。スコアがすべて0のクエリでのTF-IDFの6件や1件は、検索結果として意味がありません。並び順が決まらないまま配列の先頭から10件を取っただけで、たまたまその割合だったにすぎません。逆に「荷物がなかなか来ない」はTF-IDFも10件的中していますが、中身は在庫切れの声であり、カテゴリが合っていても顧客の要求は取り違えています。カテゴリ的中率のような集計値だけを見て良し悪しを決めず、実際に返ってきた文書を読む工程を必ず挟むべきだと考えています。

誤解のないように書いておくと、TF-IDFが不要になるわけではありません。型番、エラーコード、契約番号、製品の正式名称のように、文字列が完全に一致することそのものに意味がある検索では、単語一致の方式が確実です。文埋め込みはこうした固有の文字列を「似た雰囲気の別の文字列」と混同することがあります。実務では両方のスコアを出して統合する構成がよく使われます。片方で拾えなかったものをもう片方が拾う関係にあるためで、どちらかを選ぶ問題ではありません。

コサイン類似度をどう読むか

ここまで類似度と呼んできたものを整理します。2本のベクトルのコサイン類似度は、内積を互いの長さで割ったもので、次の式で書けます。

\( \cos(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \, \|\mathbf{b}\|} \)

長さで割っているので、この値はベクトルの向きだけを見ています。文書が長くて成分の値が全体に大きくても、向きが同じなら1に近づきます。文書の長短に左右されにくいという性質は、長さがまちまちなVoCや問い合わせを扱ううえで都合が良いところです。実装上は、ベクトルをあらかじめ長さ1にそろえておけば、割り算が不要になり内積だけで済みます。先ほどのコードでnormalize_embeddings=Trueを指定したのはこのためで、実測でもノルムの平均は1.0でした。1200行と256次元の行列にクエリ1本を掛けるだけになるので、検索処理は数行で書けます。

問題は値の読み方です。カテゴリごとに文書ベクトルの平均を取り、平均どうしのコサイン類似度を測ると、次のようになりました。

料金・請求配送・在庫不具合・エラー使い方・操作対応・接客
料金・請求1.0000.9350.9380.9440.957
配送・在庫0.9351.0000.9500.9360.958
不具合・エラー0.9380.9501.0000.9620.959
使い方・操作0.9440.9360.9621.0000.959
対応・接客0.9570.9580.9590.9591.000

まったく別のカテゴリどうしでも0.935から0.962の範囲に収まっています。人の感覚では料金の話と配送の話は無関係ですが、この空間では0.935です。つまり「類似度0.9以上なら似ている」というような絶対値の閾値は、そのままでは使えません。埋め込みモデルの出力には、ベクトルが空間の一部の方向に偏って分布する性質があり、無関係な文の組でも高い値が出ます。使うべきは絶対値ではなく相対順位です。同じクエリに対する候補の中で何番目に来たか、あるいは全体の分布と比べてどれだけ突出しているかを見ます。業務で閾値を決めるときも、「0.9で切る」ではなく「上位20件を人が確認する」「無関係な文の類似度の分布を測って、その上位1%を超えたものだけ通す」といった決め方をします。閾値の設計と評価の考え方は第10章でまとめて扱います。

空間の様子を目で見るために、1200件を2次元に圧縮して描きました。左は第3章のTF-IDF行列を2次元に落としたもの、右は256次元の埋め込みを2次元に落としたものです。点1つが1件のVoCで、色と形はカテゴリを表しています。

TF-IDF空間と256次元のSBERT埋め込み空間を、それぞれ2次元に圧縮してカテゴリ別に色分けした散布図

2次元への圧縮には主成分分析を使いました。データの散らばりがもっとも大きい方向から順に軸を選び直し、その先頭の2本だけを残して残りを捨てる方法です。圧縮の際に元の情報がどれだけ残っているかも測っています。TF-IDF側は2次元で元の分散の6.1%しか説明できていないのに対し、埋め込み側は19.0%でした。低い次元で見たときの見通しが違うということです。カテゴリの分かれ具合をシルエット係数で測ると、TF-IDF空間が0.132、埋め込み空間が0.185で、埋め込み側のほうがカテゴリごとにまとまっています。シルエット係数は、同じカテゴリの点どうしがどれだけ近く、別のカテゴリの点からどれだけ離れているかを表す指標で、1に近いほど分離が良いことを意味します。ただしどちらも1からは遠く、きれいに5つの島に分かれるわけではありません。散布図は全体の構造をつかむための道具であって、2次元の絵で近いから似ている、離れているから違う、と判断してはいけません。判断は元の次元の数値で行い、絵は説明のために使うという役割分担にすべきです。

次元と、精度・速度・保存コストのつり合い

256次元というのは、文埋め込みモデルとしては小さい部類です。次元が大きいほど表現力は上がりますが、保存する量も計算量も増えます。どこまで削れるのかを実測しました。256次元の埋め込みを主成分分析で圧縮し、圧縮前の検索結果の上位10件が、圧縮後にどれだけ残るかを測りました。クエリには、コーパスから無作為に選んだ200件の文書をそのまま検索語として使っています。

左は次元数を減らしたときの上位10件の一致率、右は登録件数を増やしたときの全件計算時間を示した2つの折れ線グラフ
次元数元の上位10件との一致率1件あたりの保存サイズ1200件の合計サイズ
2561.0001024バイト1200KB
1280.928512バイト600KB
640.921256バイト300KB
320.866128バイト150KB
160.77864バイト75KB
80.64032バイト38KB

64次元まで落としても一致率は0.921で、保存サイズは4分の1になりました。32次元では0.866、8次元では0.640まで落ちます。10件のうち3件から4件が入れ替わる状態で、これは検索結果として別物です。この曲線の形は、削るほど一様に悪くなるという単純なものではありません。最初の半減にあたる256次元から128次元で 1.000 が 0.928 まで落ち、ここで1割弱を失います。ところが128次元から64次元では 0.921 とほぼ横ばいで、半分に削ってもほとんど変わりません。そこから先は 0.866、0.778、0.640 と、半減させるたびの落ち幅が大きくなっていきます。つまり、はじめの一段でいくらか失い、しばらく粘る区間があり、そこを過ぎると崩れ方が速くなる、という3つの局面に分かれています。どこが折れ目になるかはデータとモデルによって変わるので、削るなら必ず自分のデータで測るべきです。

保存形式でも量は変わります。256次元をfloat32で持つと1件あたり1024バイトで、100万件なら0.95ギガバイトです。float16にすれば512バイトで0.48ギガバイト、さらに整数8ビットに量子化すれば256バイトで0.24ギガバイトになります。精度は落ちますが、件数が多く、かつ厳密な順位より候補の絞り込みが目的であれば、実用的な選択肢になります。

検索そのものの速度も測りました。256次元のベクトルに対して、1クエリで全件との内積を計算し、上位10件を取り出すまでの時間です。1,200件で0.02ミリ秒、12,000件で0.23ミリ秒、60,000件で1.08ミリ秒、300,000件で6.32ミリ秒でした。件数におおむね比例して伸びています。逆に言えば、数十万件の規模までなら特別な仕組みを入れずに全件計算で足ります。凝った検索基盤を最初から用意する必要はなく、まずは配列とファイルで始めるのが現実的だと考えています。

埋め込みモデルの実務選定と、運用で効いてくる落とし穴

モデルを選ぶときに見るべき点を整理します。精度の指標だけで決められる話ではなく、業務側の制約のほうが選択を強く縛ることが多いように思います。

  • 日本語に対応しているか。多言語対応をうたうモデルでも、日本語の性能が十分とは限りません。自社のデータで数十件の検索を試し、返ってきた文書を人が読んで判断します
  • 入力できる文の長さの上限。今回のモデルは8192トークンですが、512トークン程度が上限のモデルも多くあります。上限を超えた部分は黙って切り捨てられ、長い文書ほど後半が無視されます。契約書や議事録を丸ごと入れる用途では致命的なので、あらかじめ段落単位に分割して埋め込む設計にします
  • 次元数と保存コスト。前の節のとおり、件数かける次元数かけるバイト数が必要な容量です。100万件を超える見込みがあるなら、この段階で計算しておきます
  • ライセンスと商用利用の可否。今回使ったモデルはApache License 2.0で公開されています。研究用途に限定されたモデルもあるため、業務で使う前に必ず配布元の記載を確認します
  • 手元で動かすか、外部のAPIを使うか。顧客の声や社内文書を外部のサービスに送れるかどうかは、技術ではなく契約と社内規程の問題です。送れないのであれば、自社の環境で動くモデルに選択肢が限られます
  • 推論に必要な計算資源。今回の規模はCPUのみで動きました。大きなモデルではGPUが前提になることもあり、件数が多い場合は初回の全件計算にかかる時間も見積もっておきます

運用に入ってから効いてくる落とし穴が2つあります。1つ目は、埋め込みは作り方まで含めて1セットだということです。同じモデルでも、入力の作り方を変えると結果が変わります。今回のモデルはクエリに検索クエリ: を付ける設計ですが、これを付け忘れた場合、正しい使い方に対する上位10件の一致率は0.73でした。文書側の接頭辞である検索文書: を誤ってクエリにも付けた場合は0.62、分類用のトピック: を付けた場合は0.80です。3割前後の結果が入れ替わります。接頭辞の有無というだけの違いで、エラーも警告も出ません。検索が何となく当たらないという不調として現れるので、原因にたどり着きにくい種類の不具合です。

# クエリ側の接頭辞を変えて、上位10件がどれだけ入れ替わるかを測る
# 出力:
# 正しい使い方(検索クエリ: ): 基準
# 接頭辞なし: 一致率=0.73
# 文書側の接頭辞を誤って付けた(検索文書: ): 一致率=0.62
# 分類用の接頭辞を付けた(トピック: ): 一致率=0.80

2つ目は、モデルを変えたら過去のベクトルは使えないということです。埋め込みの各次元には固有の意味があるわけではなく、モデルの学習の結果としてたまたまその配置になっているだけです。別のモデルが出力した256次元と、今のモデルの256次元は、次元の数が同じでも別の空間の座標です。両者を同じ配列に入れて内積を取っても、計算は通りますが結果は無意味です。次元数が違えば計算がエラーになるぶんまだ気づけますが、たまたま同じ次元数のモデルに乗り換えたときは、壊れた検索がそのまま動き続けます。

したがってモデルを更新するときは、全件を再計算するのが原則です。100万件を持っているなら100万件を作り直すことになり、これは無視できない作業になります。運用としては、ベクトルを保存するテーブルにモデル名と版、それに接頭辞などの入力の作り方を列として持たせ、検索時に使っているものと一致するかを確認する仕組みを入れておくのが確実です。再計算の途中で古いベクトルと新しいベクトルが混ざる期間が生じるため、切り替えは別のテーブルに新しい版を作りきってから参照先を切り替える手順にします。モデルの更新頻度は高くありませんが、一度やると決めたときに手順がないと止まります。

件数が増えたときの選択肢:近似最近傍探索とベクトルデータベース

ここまでの検索は、クエリのベクトルと全件のベクトルの内積を取る全件計算です。実測では300,000件で6.32ミリ秒でしたから、この規模までは全件計算で困りません。ただし件数に比例して伸びるので、1000万件になれば1クエリあたり0.2秒程度、同時に多数の検索が走ればそのぶん重なります。加えて、全件のベクトルをメモリに載せる必要があり、256次元のfloat32で1000万件なら約9.5ギガバイトです。ここが全件計算の限界になります。

この限界を越えるための考え方が近似最近傍探索です。すべてと比べるのをやめ、確実に上位に来ないものを計算せずに飛ばします。厳密な最上位を保証しない代わりに、桁違いに速くなります。実現の方針は大きく3つに分かれます。1つはベクトルどうしを近いものどうしでつないだグラフを作り、そこをたどって近傍へ降りていく方法です。1つは全体をあらかじめグループに分けておき、クエリに近いグループだけを調べる方法です。1つはベクトルそのものを圧縮して、粗い計算で候補を絞ってから精密に測り直す方法です。実際の製品や実装では、これらを組み合わせています。

本章ではこれらの実装は行っていません。理由は、近似の設計は件数と要求される再現率と更新頻度によって答えが変わり、一般論として「これを使えばよい」と言える性質のものではないからです。ここでは選択肢の位置づけを整理するにとどめます。

方式向いている規模導入の重さ注意点
配列を持って全件計算する数十万件程度までライブラリ追加なしで書ける件数に比例して遅くなる。全件をメモリに載せる必要がある
近似最近傍探索のライブラリを組み込む数十万件を超えるあたりから数千万件索引の構築と保存の管理が要る厳密な上位を保証しない。追加や削除の反映方法を決めておく
既存のデータベースや検索基盤の拡張機能を使う既存の運用に乗せたい場合既存の権限管理やバックアップに乗る対応している索引の種類と件数の上限を確認する
専用のベクトルデータベースを使う大規模かつ更新が頻繁な場合新しい構成要素が1つ増える運用と監視の対象が増える。データを外部に置けるかを先に確認する

選定の順番としては、まず全件計算で始めて、業務として成立するかを確かめるのが良いと考えています。検索の質が用を成さないのに基盤だけ立派に作っても意味がありません。件数が数十万件を超え、応答時間が業務の要求に届かなくなった時点で、初めて近似の導入を検討すれば十分です。なお近似を入れると、同じクエリでも索引の作り方によって結果が変わりうるため、導入の前後で上位10件がどれだけ一致するかを測り、落ちてよい範囲を決めてから切り替えます。

件数の規模から類似検索の構成を選ぶ判断フローと、導入の重さ・厳密性の比較表

この章では、単語の一致だけでは意味の同じ文書を取りこぼすことを実測で確かめ、文を1本のベクトルに変える文埋め込みによってその取りこぼしが減ることを、同じクエリの検索結果で比べました。コサイン類似度の値は絶対値で閾値を切れないこと、次元を削ると検索結果がどこから崩れるか、モデルや入力の作り方を変えると過去のベクトルが使えなくなることまで、数値で押さえています。次の第9章では、大規模言語モデルを使ったゼロショット分類や要約、構造化抽出を扱い、ここで作った検索の仕組みが生成の側とどう組み合わさるのか、そして従来手法と使い分ける基準はどこにあるのかを整理します。

この章を深めたい方への参考書籍

『ゼロから作るDeep Learning ❷ 自然言語処理編』(斎藤康毅、オライリー・ジャパン):本章で概念だけを説明したword2vecを、実際に手を動かして実装しながら理解したい読者に向いています。分布仮説から始めて、カウントベースの手法と推論ベースの手法の関係、学習を高速化する工夫までを順に追える構成になっており、分散表現がなぜあの形になるのかが腑に落ちます。

『BERTによる自然言語処理入門:Transformersを使った実践プログラミング』(ストックマーク株式会社 編、近江崇宏・金田健太郎・森長誠・江間見亜利 著、オーム社):本章で使った文埋め込みモデルの土台にあるBERTを、日本語のデータで動かしながら学べます。文章の類似度計算や文書検索を扱う章があり、双塔構造の前提となるモデルの側から理解を補強したい読者に適しています。

『情報検索:検索エンジンの実装と評価』(Stefan Büttcher・Charles L. A. Clarke・Gordon V. Cormack 著、梅澤克之・Neil Rubens・松田健・三川健太・水野信也・山本健司 訳、森北出版):本章では検索の評価をカテゴリ的中率という簡便な形でしか扱いませんでした。適合率や再現率をどう定義し、検索結果の順位をどう評価するかという情報検索の体系を、索引の実装から評価指標まで通して確認したい読者に向いています。

第9章 LLM時代のテキストマイニング

第8章では、文章を数百次元のベクトルに置き換えることで、言葉づかいが違っていても意味の近い文書を引き当てられることを確認しました。埋め込みは「似ているかどうか」までは答えてくれますが、「この問い合わせは料金の話なのか配送の話なのか」を判定するには、そこから先に分類器を載せる必要がありました。本章で扱う大規模言語モデルは、その最後のひと押しを、学習データを用意しないまま行えるようにした技術です。

ただし、できるようになったことと、できるようになっていないことの線引きは、思っているよりはっきりしています。本章では前半でLLMが実務のどこを置き換えたのかを整理し、後半で従来手法との使い分けの基準と、運用に乗せるときの歯止めをまとめます。なお外部のLLM APIを呼ぶコードは形として示しますが、本章では実行していません。実行しているのは、LLMの出力を受け取る側の検証処理と、検索拡張生成の前半にあたるチャンク分割と検索の部分です。どこが実測でどこが設計例なのかは、その都度本文に書き分けます。

LLMが変えたことと、変えていないこと

第7章で見たとおり、従来の文書分類は学習データを作るところから始まります。問い合わせを五つのカテゴリに振り分けたければ、まず数百件から数千件の文章に人手でラベルを付け、それを分類器に学習させ、精度を測り、足りなければラベルを足す、という手順を踏みます。この工程は数週間から数か月かかることが珍しくなく、テキスト活用の企画が止まる最大の理由になっていました。カテゴリを一つ増やすたびに、その分のラベル付けをやり直す必要があることも重い制約でした。

LLMが変えたのは、この入口です。ラベルの名前と定義を文章で書いて渡すだけで、学習データなしに分類ができます。抽出したい項目を並べるだけで、自由文から構造化されたデータが返ってきます。カテゴリを一つ増やしたいときは、プロンプトに一行足すだけで済みます。データを集めてから作り始めるのではなく、作りながら仕様を決められるようになった、と言い換えてもよいと思います。試作にかかる時間が数週間から数時間に縮んだことの意味は大きく、これまで費用対効果が合わずに見送られてきた小さな用途が、検討の対象に入ってきました。

一方で、変わっていないことのほうが実は多くあります。第一に、目的設計は変わりません。第1章で述べたとおり、何を知りたくて、その結果を誰がどう使うのかが決まっていなければ、出てくるのは眺めて終わる結果です。LLMは「何を分類すべきか」を決めてはくれません。第二に、評価は変わりません。学習データが不要になっても、その分類が当たっているかを測るための正解データは依然として必要です。むしろ学習データを作らなくてよくなった分、評価データの作成だけが人手の工程として残り、そこを省いてしまう例が出やすくなったように思います。第三に、データの品質は変わりません。文字化けした文章、重複した行、テンプレート文の混入といった問題は、第2章で扱った前処理をしなければ残り続けます。LLMはそうした汚れをある程度は読み飛ばしますが、読み飛ばした結果が何だったかは外から見えません。

整理すると、LLMが縮めたのは「作り始めるまでの距離」であって、「正しいかどうかを確かめる手間」ではありません。この二つを混同すると、動くものはすぐできたのに、業務で使ってよいかどうかを誰も判断できない、という状態に落ち着きます。

ゼロショット分類とフューショット分類

ゼロショット分類は、例を一つも見せずに、ラベルの一覧と定義だけを渡して分類させるやり方です。第2章で用意したサンプルVoCデータでいえば、料金・請求、配送・在庫、使い方・操作、不具合・エラー、対応・接客という五つのラベルの意味を文章で説明し、対象の一文を添えて、どれに当たるかを答えさせます。フューショット分類は、そこにラベル付きの例を数件から十数件だけ添えるやり方です。例を見せることで、境界の引き方をこちらの意図に寄せられます。

どちらを選ぶかの目安は、ラベルの境界が言葉で説明しきれるかどうかです。「配送・在庫」と「不具合・エラー」のように辞書的な意味で分かれるものは、定義文だけで足ります。一方、「対応・接客への不満」と「使い方が分かりにくいことへの不満」のように、同じ文章がどちらにも読める場合は、定義をいくら丁寧に書いても揺れが残ります。そういうラベルには、判断に迷う実例を数件添えるのが効きます。例は正解の代表例ではなく、境界線上の例を選ぶほうが効果が出ます。

プロンプトの設計で効くことは、突き詰めると三つです。一つ目は、ラベルの名前だけでなく定義を書くことです。「料金・請求」とだけ書くと、モデルは名前から意味を推測します。その推測がこちらの業務上の定義と一致している保証はありません。「請求金額、請求書の内訳、支払い方法、プラン変更に伴う料金に関する内容」のように、含むものを列挙して書きます。含まないものを一行添えるとさらに安定します。

二つ目は、出力形式を固定することです。自然な文章で答えさせると、後段のプログラムがそれを解釈する処理を書くことになり、そこが壊れます。ラベル名だけを返す、あるいはJSONで返す、と明示し、余計な説明を付けないよう指示します。三つ目は、「該当なし」の選択肢を必ず用意することです。これがないと、モデルは与えられたラベルのどれかに必ず押し込みます。分類の対象に、そもそもどのカテゴリにも属さない文章が混ざるのは普通のことで、その受け皿を作らないと、無関係な文章が最も近そうなラベルに紛れ込み、誰も気づかないまま集計が歪みます。

以下は分類プロンプトとAPI呼び出しの形です。本章ではこのコードを実行していません。APIキーを用意していないためで、掲載しているのは設計の形であって、実行結果ではありません。

# 注意: このコードは本章では実行していない。形式を示すための例。
SYSTEM = """あなたは問い合わせ内容を分類する担当者です。
次のラベルのいずれか1つを選び、ラベル名だけを出力してください。
説明や理由は書かないでください。

- 料金・請求: 請求金額、請求書の内訳、支払い方法、プラン変更に伴う料金に関する内容
- 配送・在庫: 納期、配送状況、梱包、在庫の有無に関する内容
- 使い方・操作: 画面の操作方法、設定手順、マニュアルの分かりにくさに関する内容
- 不具合・エラー: エラー表示、動作の停止、データの不整合に関する内容
- 対応・接客: 問い合わせへの回答、担当者の応対、連絡の有無に関する内容
- 該当なし: 上のどれにも当てはまらない内容
"""

def classify(text: str) -> str:
    resp = client.messages.create(          # 実行していない
        model="...",                        # モデル名はバージョンまで固定する
        temperature=0,                      # 再現性のため0にする
        max_tokens=16,
        system=SYSTEM,
        messages=[{"role": "user", "content": text}],
    )
    return resp.content[0].text.strip()

この形で分類させたときに注意したいのは、出力が必ずしもラベル一覧の中に収まらないことです。指示に反して説明文が付いたり、ラベル名が微妙に言い換えられたりします。そのため、返ってきた文字列がラベル一覧に含まれるかどうかを必ず確認し、含まれなければ再試行するか、未判定として記録する必要があります。この「返ってきたものを疑う」という設計は、次に述べる構造化抽出でさらに重要になります。

自由文から構造化データを取り出す

構造化抽出は、分類よりも踏み込んだ使い方です。分類は一つの文章に一つのラベルを付けるだけですが、構造化抽出は文章の中から複数の項目を取り出して、決まった形に整えます。VoCの文章であれば、何について言っているのかという対象、何が起きたのかという事象、どうしてほしいのかという希望する対応、どれだけ急ぐのかという緊急度を、一度に取り出すことができます。取り出した結果は表になるので、そのまま集計にかけられます。自由文をそのまま集計することはできませんが、この四項目に変換できれば、対象別の件数、対応別の割合、緊急度の分布といった数字が出てきます。

ここで決定的に重要なのが、出力形式をJSON Schemaのような機械可読な仕様で指定することです。プロンプトに日本語で「JSONで返してください」と書くだけでも大半は返ってきますが、それは「大半は」であって「必ず」ではありません。キーの名前が揺れる、数値のはずが文字列で返る、指定していないキーが増える、といったことが一定の割合で起きます。スキーマを渡せる仕組みが使える場合はそれを使い、使えない場合でも、受け取る側で同じ内容を検証します。ここで想定している出力の仕様は次のとおりです。

ACTION_ENUM = ["返金", "交換", "再送", "再説明", "設定変更", "改善要望", "不明"]

SCHEMA = {
    "type": "object",
    "required": ["target", "event", "desired_action", "urgency", "evidence"],
    "additionalProperties": False,
    "properties": {
        "target": {"type": "string", "minLength": 1, "maxLength": 40},
        "event": {"type": "string", "minLength": 1, "maxLength": 200},
        "desired_action": {"type": "string", "enum": ACTION_ENUM},
        "urgency": {"type": "integer", "minimum": 1, "maximum": 5},
        "evidence": {"type": "string", "minLength": 1, "maxLength": 200},
    },
}

この仕様には、後の検証を効かせるための工夫を三つ入れました。第一に、希望する対応を自由文ではなく列挙型にしました。自由文で受けると「至急対応」「早急に返金」といった表記が無限に増え、集計ができなくなります。第二に、緊急度を1から5の整数に限定しました。範囲を決めておけば、範囲外の値が返ったときに機械的に弾けます。第三に、根拠となる引用を必須項目に加えました。これが後で述べる幻覚の検出に効きます。

LLMの出力を検証する仕組みを自分で持つ

ここからは実際に動かした部分です。APIは呼んでいませんが、LLMが返しがちな出力を模したJSON文字列を10種類用意し、それを検証ロジックに通しました。検証ロジックそのものは実運用に載せるものと同じ内容で、パース、スキーマ検証、業務ルール検証、リトライ制御の四段で構成しています。

第一段のパースは、返ってきたテキストからJSONの部分だけを取り出す処理です。コードブロックの記号で囲まれて返ってくること、前後に「承知しました」「ご確認ください」といった地の文が付いてくることは日常的に起こるので、囲みを外し、最初の波括弧から最後の波括弧までを切り出してから解釈します。第二段のスキーマ検証は、必須キーの有無、型、列挙値、数値の範囲、文字列の長さ、余分なキーの混入を順に確認します。第三段の業務ルール検証では、根拠として返された引用が、元の文章の中に実際に存在するかどうかを確かめます。空白を除いて部分文字列として含まれていなければ、モデルが原文にない文言を作ったということなので、その時点で弾きます。

以下に載せるのは、この検証処理の主要部の抜粋です。rejsonの読み込み、表記のゆれを吸収するnormalize、スキーマ検証と業務ルール検証をまとめたvalidate_recordは、紙面の都合で省いています。そのまま貼り付けて動くものではなく、四段のうちどこで何を見ているかを示すためのものだと考えてください。

FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.S)

def parse_json_block(raw: str):
    """返答テキストからJSONオブジェクトを取り出す。失敗したら (None, 理由)。"""
    if raw is None or not raw.strip():
        return None, "empty_response"
    text = raw.strip()
    m = FENCE.search(text)
    if m:
        text = m.group(1).strip()
    start, end = text.find("{"), text.rfind("}")
    if start == -1 or end == -1 or end < start:
        return None, "no_json_object"
    try:
        return json.loads(text[start : end + 1]), None
    except json.JSONDecodeError:
        return None, "json_decode_error"

def validate_grounding(obj, source_text: str):
    """evidence が原文の部分文字列であることを確認する。"""
    ev = obj.get("evidence")
    if isinstance(ev, str) and ev:
        if normalize(ev) not in normalize(source_text):
            return ["evidence_not_in_source"]
    return []

10種類の出力を通した結果、通ったのは3種類、弾かれたのは7種類でした。内訳は、必須キーの欠落、緊急度が文字列で返った型違い、列挙にない「至急対応」というラベル、範囲外の緊急度9、原文にない文言を根拠として返した幻覚、JSONとして壊れている出力、指定していない確信度というキーの追加が、それぞれ1件ずつです。逆に、コードブロックの記号で囲まれた出力と、前後に地の文が付いた出力は、パース処理が正しく中身だけを取り出して通過しました。

# 出力: python ch09_01_validate.py の実行結果より抜粋
# ケース                   判定      検出した違反
# C1 正常                 OK      -
# C2 コードフェンス付き          OK      -
# C3 前後に地の文             OK      -
# C4 必須キー欠落             NG      missing_key:desired_action
# C5 型違い(緊急度が文字列)       NG      type_error:urgency:str
# C6 列挙外のラベル            NG      enum_violation:desired_action:至急対応
# C7 範囲外の緊急度            NG      out_of_range:urgency:9
# C8 原文にない根拠(幻覚)        NG      evidence_not_in_source
# C9 JSONとして壊れている       NG      json_decode_error
# C10 余分なキーを勝手に追加       NG      unexpected_key:confidence
# 通過 3 / 10 件(弾いた 7 件)

ここで見てほしいのは、弾かれた7件の性質がそれぞれ違うことです。JSONとして壊れている出力は、後段のプログラムが必ず例外で止まるので、放置しても事故として表面化します。危ないのは、そうならない種類の違反です。緊急度9は、集計するとその案件だけが最優先に浮かび上がります。列挙にない「至急対応」というラベルは、集計表に新しい行を作り、返金や再説明と並んで表示されます。原文にない文言を根拠として返した幻覚に至っては、JSONとしても型としても完全に正常で、人が一件ずつ原文と突き合わせなければ気づけません。検証を「JSONとして読めるか」で止めてしまうと、これらは全部すり抜けます。

第四段のリトライ制御は、検証に落ちたときに、落ちた理由を添えてもう一度依頼する仕組みです。単に同じプロンプトで再試行するのではなく、「desired_actionは次の一覧から選んでください」のように、検出した違反をそのまま次の指示に混ぜます。上限回数を決めておき、そこまでに通らなければ打ち切って、人が見るキューに回します。打ち切った件数を記録することも忘れないようにします。この件数が増えているときは、対象データの性質が変わったか、モデルが更新されたかのどちらかで、どちらも気づくべき変化です。

次のコードは、この制御の流れだけを取り出したものです。本章ではAPIを呼んでいないため、responsesという引数に「1回目にこう返ってきた、2回目にはこう返ってきた」という文字列をあらかじめ並べて渡し、それを順に検証しています。ここで再依頼を模擬している、と読んでください。実運用では、このresponsesから1件取り出している行が、検出した違反の一覧をプロンプトに足してAPIを呼び直す処理に置き換わります。試行ごとにどの段で何に落ちたかをtraceへ積んでいく形は、そのまま使えます。

def extract_with_retry(source_text, responses, max_attempts=3):
    """responses は「LLMがn回目に返した文字列」を並べたリスト。ここでは再依頼を模擬する。

    実運用では、下の1行を
        raw = call_llm(base_prompt + 前回検出した違反の説明)
    に差し替える。制御の流れ自体は変えずに済む。
    """
    trace = []
    for attempt in range(1, max_attempts + 1):
        raw = responses[attempt - 1] if attempt - 1 < len(responses) else None
        obj, perr = parse_json_block(raw)
        if obj is None:
            trace.append((attempt, "parse", [perr]))
            continue
        errs = validate_record(obj, source_text)
        if errs:
            trace.append((attempt, "validate", errs))
            continue
        trace.append((attempt, "ok", []))
        return obj, trace
    return None, trace

# 出力: 1回目が壊れた出力、2回目が列挙外、3回目で通ったケース
#   試行1: parse    json_decode_error
#   試行2: validate enum_violation:desired_action:至急返金
#   試行3: ok       OK
# 出力: 上限まで直らなかったケース
#   試行1: validate missing_key:event, missing_key:desired_action, ...
#   試行2: parse    no_json_object
#   試行3: validate enum_violation:desired_action:至急, out_of_range:urgency:0
#   最終結果: 打ち切り。件数を記録して人手のキューへ回す

この検証層があるかないかで、LLMを使った処理の位置づけが変わります。検証がなければ、出力が正しいことをどこでも保証していないまま、下流の集計にそのまま流れ込みます。検証があれば、少なくとも「仕様に合わない出力は下流に届かない」ことが保証され、通らなかった件数が運用の指標として手元に残ります。第10章で扱う運用設計の観点からいえば、この打ち切り件数の推移こそが、日々見るべき数字の一つになります。

要約で難しいのは、短くすることではなく何を捨てるかの判断

要約には、抽出型と生成型の二つがあります。抽出型は、元の文章から重要な文をそのまま選び出して並べる方式です。文章そのものは原文のままなので、元にない内容が混ざることは原理的にありません。生成型は、内容を読み取ったうえで新しい文を書き起こす方式で、LLMが得意とするのはこちらです。読みやすさは生成型が明らかに勝りますが、代償として、元の文章に書かれていないことが混ざる可能性が常にあります。

この混入は、間違った内容が入るという形だけでは起こりません。もっと多いのは、書かれていた条件が落ちる形です。「一部の環境で発生する」が「発生する」になる、「検討中」が「予定」になる、「三件の報告があった」が「複数の報告があった」になる、といった形で起こります。一つひとつは些細に見えますが、要約だけを読んで判断する立場の人には、元の文章に条件が付いていたことが見えません。要約を経営報告に載せるなら、要約と元の文章への導線を必ずセットにする、という運用が要ります。

大量の文書をまとめるときは、階層要約という段取りを使います。全部を一度に渡すことはできないので、文書を分割してそれぞれを要約し、できた要約をさらにまとめて要約する、という二段構え、場合によっては三段構えにします。段を重ねるほど圧縮率は上がりますが、条件が落ちる作用も段ごとに積み重なります。二段目の要約は、元の文章ではなく一段目の要約を読んで書かれているので、一段目で落ちた条件は二段目では復元されません。段数はできるだけ浅くし、各段で何件をまとめたのかを記録しておくのが実務的な対処です。

要約の評価が難しいのも、正面から認めておいたほうがよい点です。分類であれば正解ラベルと突き合わせて精度が出せますが、要約には唯一の正解がありません。参照要約との単語の重なりを測る指標は昔からありますが、言い換えを使った良い要約が低く出るという弱点があり、それだけで判断できるものではありません。実務では、元の文章に書かれていない内容が入っていないかという一点に絞って人が確認する方式が、費用と効果の釣り合いが取りやすいように思います。書かれていないことが書かれている要約は、読んだ人の判断そのものを狂わせるからです。網羅性や読みやすさは、この一点に比べれば優先度が下がります。ただし網羅性を確認項目から外してよいという意味ではありません。この節の前半で見たとおり、要約でもっとも起きやすいのは条件が落ちる形の欠落で、重要な条件が1つ抜けた要約は、内容が正しくても業務判断を誤らせます。用途によっては、原文に無い記述の検出と並べて、あらかじめ決めた必須項目が要約に残っているかどうかも確認項目に入れる必要があります。

RAGの四つの工程と、チャンク分割の実測

RAGは検索拡張生成と訳される仕組みで、質問に関係のありそうな文書を先に検索し、それを根拠としてLLMに渡してから答えさせます。モデルが学習していない社内の情報にも答えられること、答えの根拠になった文書を提示できることが利点です。工程は四つに分かれます。長い文書を扱いやすい大きさに切るチャンク分割、切った断片をベクトルに変換する埋め込み、質問に近い断片を探す検索、そして見つけた断片を材料に答えを書く生成です。

検索拡張生成の工程と、従来手法と同じ技術で済む範囲・LLMを使う範囲を示した概念図

四工程のうち、前半の三つは第8章までに扱った技術の組み合わせで、LLMは登場しません。実際、うまくいかないRAGの原因の大半は、生成ではなく検索の側にあります。関係のない断片が渡ればLLMは関係のない答えを書き、答えが書かれた断片が渡らなければ、どれだけ良いモデルを使っても正しい答えは出ません。ここでは前半の工程を実際に動かし、チャンクの切り方が検索の当たり方をどう変えるかを測りました。生成の工程はAPIを必要とするため実行していません。

対象は、社内文書を模した架空のカスタマーサポート運用ハンドブックで、長さは2,667文字です。実在の規程ではなく、本章のために書き起こしたものです。この文書に対して、答えが特定の一節に書かれている質問を15問用意しました。たとえば「パスワード再設定のリンクはいつまで有効ですか」という質問には「パスワードの再設定リンクの有効期限は発行から60分です」という一節が対応します。埋め込みには第8章で扱った日本語の文埋め込みモデルを用い、文書側とクエリ側にそれぞれ接頭辞を付けてベクトル化し、コサイン類似度で検索します。チャンク分割は、文字数を固定した窓を少しずつずらしていく最も単純な方式にしました。

def chunk_text(text: str, size: int, overlap: int):
    """文字数固定のスライディングウィンドウで分割する。"""
    text = "".join(text.split("\n"))
    stride = size - overlap
    chunks, pos = [], 0
    while pos < len(text):
        chunks.append(text[pos : pos + size])
        if pos + size >= len(text):
            break
        pos += stride
    return chunks

def embed(model, texts, prefix):
    vecs = model.encode([prefix + t for t in texts])
    vecs = np.asarray(vecs, dtype=np.float32)
    return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)

chunks = chunk_text(DOC, size=300, overlap=75)
cvec = embed(model, chunks, "検索文書: ")
qvec = embed(model, [q for q, _ in QUERIES], "検索クエリ: ")
sims = qvec @ cvec.T          # (質問数, チャンク数) の類似度行列

チャンクサイズを6通り、オーバーラップを3通りに変えて、15問すべてで検索を回した結果が次の表です。18通りすべてを載せています。判定は、答えの書かれた一節を含むチャンクが上位3件に入ったかどうかで行いました。あわせて、答えの一節がチャンクの境界で切られずに一つのチャンクに収まっていた質問の割合も記録しています。

チャンクサイズオーバーラップチャンク数一節が分断されず残った割合Recall@3
8003460%0.60
12002393%0.93
20001487%0.87
30009100%0.93
50006100%1.00
80004100%1.00
80204593%0.93
1203030100%1.00
2005018100%0.93
3007512100%1.00
5001257100%1.00
8002005100%0.93
804066100%1.00
1206044100%1.00
20010026100%1.00
30015017100%1.00
50025010100%1.00
8004006100%1.00
チャンクサイズとオーバーラップを変えたときのRecall@3とチャンク数の変化

いちばんはっきり出たのは、チャンクサイズ80でオーバーラップを取らなかったときの落ち込みです。Recall@3が0.60まで下がりました。原因を調べると、取りこぼした6問すべてが「答えの一節がチャンクの境界で真っ二つに切られていた」ケースでした。「請求書の再発行は管理画面の請求履歴から、過去24か月分まで申請できます」という一文が前半と後半に分かれてしまうと、どちらの断片も質問との類似度が上がりません。前半だけでは何が申請できるのか分からず、後半だけでは何の話か分からないからです。ここで重要なのは、この失敗が検索の精度の問題ではなく、分割の設計の問題だという点です。ここで用いた「答えの一節が1つのチャンクに丸ごと含まれていること」という評価条件のもとでは、埋め込みモデルを高性能なものに替えても直りません。ただし、これは分割の失敗を取り返す手段が何も無いという意味ではありません。上位に来たチャンクの前後を一緒に取ってくる、あるいはチャンクが属する元の文書や節をまとめて渡すといった設計にすれば、切られた一節の残り半分を後から拾える場合があります。それでも、切り方そのものを直すほうが確実で、後段の工夫は補いだと考えたほうがよいと思います。

# 出力: サイズ80・重なりなしのときに取りこぼした質問
#   × 過去の請求書をもう一度発行したい … 正解の一節がチャンク境界で分断
#   × 在庫はどの時点で確保されますか … 正解の一節がチャンク境界で分断
#   × CSV出力の件数に上限はありますか … 正解の一節がチャンク境界で分断
#   × お客様の氏名が入った文章を外部のサービスに渡してよいですか … 正解の一節がチャンク境界で分断
#   × FAQ記事はどのくらいの頻度で見直しますか … 正解の一節がチャンク境界で分断
#   × 二次対応へ引き継ぐ目安を教えてください … 正解の一節がチャンク境界で分断
#   取りこぼし 6 / 15 件

同じサイズ80でも、オーバーラップを20文字取ると分断されなかった割合が93%に、40文字取ると100%になり、Recall@3もそれぞれ0.93、1.00に戻りました。オーバーラップは、境界をまたいだ一節がどこかのチャンクに丸ごと収まる可能性を上げるための仕組みです。ただし、どんな一節でも収まると保証してくれるわけではありません。保証されるのはオーバーラップの幅に収まる長さの一節までで、それより長い一節は、重なりを広げない限りどのチャンクでも分断されたままになります。20文字では93%にとどまり、40文字まで広げてはじめて100%になったのは、この性質がそのまま出た結果です。代償もあり、オーバーラップを50%取るとチャンク数は34から66へおよそ二倍になります。チャンク数は埋め込みの計算量、索引の大きさ、検索の負荷にそのまま比例します。

もう一つ測ったのは、上位何件をLLMに渡せば答えを含む断片が届くか、という観点です。渡す件数を増やせば根拠が届く確率は上がりますが、そのぶんプロンプトが長くなり、処理時間も費用も増えます。

LLMに渡すチャンク数kと、答えを含むチャンクが混ざっていた割合の関係

サイズ300でオーバーラップ75の設定は、k=1で0.93、k=2で1.00に達しました。サイズ800でオーバーラップ200の設定はk=4でようやく1.00になります。800字のチャンクを4つ渡すことになるので、プロンプトに載る文字数は3,200字程度です。実測では、同じ設定のk=3のときが約2,400字でした。興味深いのはサイズ120でオーバーラップなしの設定で、こちらはkをいくら増やしても0.93で頭打ちになりました。1問だけ、答えの一節が境界で分断されていて、どのチャンクにも丸ごと含まれていないからです。渡す件数を増やしても、そもそも索引の中に答えが存在しなければ届きません。分割の設計は、検索の段でいくら頑張っても取り返せない、という性質がここに表れています。

この実測から言えることは三つあります。第一に、オーバーラップは、小さいチャンクを使うなら必須です。第二に、大きすぎるチャンクは検索の当たりは良くても、渡す文字数が膨らみ、関係のない記述も一緒にLLMへ届きます。第三に、チャンクサイズとオーバーラップは、勘で決めずに、自社の文書と実際に来る質問で測るべきものです。今回の設定であれば、サイズ300前後でオーバーラップを25%程度取る組み合わせが、チャンク数と検索の当たりの釣り合いが取れていました。ただしこの数値は文書の書き方に依存します。一文が長い規程文書と、箇条書きの多いFAQでは最適な大きさが違います。なお実務では、文字数で機械的に切るのではなく、見出しや段落の区切りで切る方式のほうが良い結果になることが多いとされます。本章では効き方を見やすくするために、あえて最も単純な文字数固定の方式で測りました。

生成の工程は、検索で得た上位のチャンクをプロンプトに埋め込んでLLMに渡す形になります。ここでも本章では実行していません。設計上の要点は、根拠として渡した文書の範囲でだけ答えるよう明示し、根拠に書かれていない場合は分からないと答えさせ、答えの根拠になったチャンクの識別子を一緒に返させることです。この三つを入れておくと、後から答えの妥当性を検証できます。

ここで、識別子の作り方に注意が要ります。検索結果に上から順に1番、2番と番号を振って渡すやり方をよく見かけますが、この番号は「今回の検索で何位だったか」を表しているだけで、同じ質問を明日投げても同じ文書を指すとは限りません。索引を作り直せば意味を失いますし、回答だけがログに残った状態からは、どの文書のどの部分だったのかをたどれません。渡すのは、文書を一意に指すdoc_idと、その中での位置を指すchunk_id、そして根拠が古くないかを判断するための更新日です。この三つを本文と一緒に文脈へ入れ、回答側にも順位ではなくそのIDを返させます。こうしておけば、回答から元の文書へ戻る経路が残り、更新日が古い文書を根拠にした回答を後から洗い出すこともできます。

# 注意: このコードは本章では実行していない。生成の工程の形を示すための例。
TEMPLATE = """次の資料だけを根拠に、質問へ回答してください。
資料に書かれていないことは推測せず、「資料からは判断できません」と答えてください。
回答の末尾に、根拠にした資料の doc_id と chunk_id を列挙してください。
資料の並び順の番号ではなく、必ずこの2つのIDで示してください。

# 資料
{context}

# 質問
{question}
"""

# chunk_meta[j] = {"doc_id": "OPS-HANDBOOK", "chunk_id": "OPS-HANDBOOK#0007",
#                  "updated_at": "2026-06-30"} のように、索引を作った時点で持たせておく
def answer(question, chunks, chunk_meta, sims_row, k=3):
    top = np.argsort(-sims_row)[:k]
    blocks = []
    for j in top:
        m = chunk_meta[j]
        blocks.append(
            f"doc_id: {m['doc_id']} / chunk_id: {m['chunk_id']} / "
            f"更新日: {m['updated_at']}\n{chunks[j]}")
    context = "\n\n".join(blocks)
    prompt = TEMPLATE.format(context=context, question=question)
    return client.messages.create(...)      # 実行していない

従来手法とLLMの使い分け基準

LLMが使えるようになったから従来手法は不要になった、という理解は実務では成り立ちません。両者は得意な領域が違い、多くの現場では併用が前提になります。判断の材料になる観点を並べると次のようになります。

件数規模と処理の定型度から、LLMと従来手法の使い分けを整理した2軸マトリクス
観点従来手法LLM判断の目安
1件あたりの処理時間学習済みモデルの推論は高速で、多くはミリ秒の単位ネットワーク越しの呼び出しになり、秒の単位になることが多い画面の裏で即座に返す必要があるならLLM単体では厳しい
課金の考え方自社の計算資源のみ。件数が増えても資源を増やす形で対応する処理した文字量に応じた従量課金。件数と文章の長さの両方で増える件数が桁で増える見込みがあるなら、単価ではなく総量で試算する
再現性モデルとデータが同じなら出力は完全に一致する設定次第で揺れる。同じ入力でも別の出力が返りうるtemperatureを0に固定しても完全な一致は保証されない
バージョンの安定自社で固定でき、更新の時期も自社が決める提供側の都合でモデルが更新され、出力の傾向が変わりうるモデル名はバージョンまで固定し、更新時は再評価する
説明性どの単語がどう効いたかを係数や重要度として提示できる理由を文章で説明させられるが、それが実際の判断過程とは限らない監査で判断根拠を求められる用途では従来手法が有利
件数規模百万件を毎日処理しても現実的な範囲に収まる百万件を毎日となると、時間と費用の両面で設計が難しくなる大量処理は従来手法、例外的な少数をLLMに回す
データの外部送信自社の環境内で完結する外部のAPIを使う場合、テキストが社外に出る個人情報や機密が含まれる場合、送信の可否を先に確認する
立ち上げの速さ学習データの作成に数週間から数か月かかるプロンプトを書けばその日に試せる仕様が固まっていない段階の検証はLLMが向く
仕様変更への強さカテゴリを増やすと学習をやり直すことになるプロンプトに一行足せば済むことが多い変更が頻繁な領域はLLMのほうが維持しやすい

この表を一本の軸にまとめると、大量に発生し、処理の内容が定型で、判断の根拠を監査される可能性がある処理は従来手法が向きます。少量で、内容が非定型で、仕様が固まっておらず柔軟さが要る処理はLLMが向きます。この対比が実務での出発点になります。問い合わせの一次振り分けのように毎日数万件が流れる処理は前者、月に数十件の重要案件から論点を抜き出す処理は後者です。

金額そのものを試算に載せるかどうかは案件ごとの判断になりますが、考え方だけは共通しています。LLMは処理した文章の量に比例して課金され、従来手法は計算資源を用意した時点でおおむね固定になります。したがって件数が少ないうちはLLMのほうが総額で安く、件数がある規模を超えると逆転することが多いと考えています。ただし従来手法の側にも学習データの作成と初期構築にかかる人件費があり、自社の計算資源をすでに持っているかどうかでも分岐点は動くため、この向きが常に成り立つとは限りません。この分岐点が自社の想定件数のどちら側にあるのかを、企画の段階で押さえておくことが要ります。試作の段階では件数が少ないので、ほとんどの場合でLLMのほうが安く見えます。そのまま本番の件数で走らせて初めて逆転に気づく、という順序になりやすい点に注意が要ります。

ハイブリッド設計という現実解

実務で最も効くのは、どちらか一方を選ぶことではなく、両方を組み合わせる設計です。代表的な形が三つあります。

一つ目は、LLMに学習データを作らせて、軽量なモデルを育てる形です。数千件の文章にLLMでラベルを付け、そのラベルを正解として第7章で扱った分類器を学習させます。運用時に動くのは軽量なモデルなので、処理は速く、費用は資源の分だけで、出力は完全に再現します。大きなモデルの振る舞いを小さなモデルに移すという意味で、蒸留と呼ばれる考え方に近いやり方です。注意点は、LLMが付けたラベルの誤りがそのまま学習データの誤りとして固定されることで、一定量を人が検品する工程は省けません。それでも、ゼロから人手でラベルを付けるのに比べれば、作業量は大きく減ります。

二つ目は、LLMを一次スクリーニングに使い、確信度の低い分だけ人が見る形です。判定の確信度という考え方をそのまま使い、LLMの判定に確信度を出させるか、あるいは同じ入力を複数回処理して出力が一致するかどうかで安定性を測ります。判定が揺れた件だけを人のキューに回せば、全件を人が見る場合に比べて確認の量を大幅に減らせます。この形の良いところは、精度が足りないときの逃げ道が最初から設計に入っていることです。

ただし、モデルに「確信度を0から1で出してください」と書かせて返ってきた数値を、そのまま自動処理と人手のしきい値に使うのは危うい設計です。この数値は自己申告であって、実際の正解率と対応するように校正されていません。0.9と答えたものの正解率が9割になる保証はなく、多くの場合は高い側に偏ります。実務で使うなら、正解を付けた評価データで、申告された確信度の帯ごとに実際の正解率を測り、その対応表を作ってからしきい値を決めます。第5章で確率スコアの帯ごとに正解率を測ったのと同じ考え方です。あわせて、同じ入力を複数回処理して出力が一致するかを見る方法や、自動処理に回した分から毎月一定件数を抜き出して人が答え合わせをする監査を併用します。自己申告の数値は、単独の根拠ではなく、こうした実測と組み合わせて使う材料だと考えたほうが安全です。

三つ目は、辞書やルールで前処理してからLLMに渡す形です。第2章で作ったユーザー辞書や正規化の処理を通し、明らかにテンプレート文である部分を落とし、個人情報に当たる箇所を伏せ字に置き換えてから渡します。前処理で確実に判定できる部分は、LLMを呼ばずにルールで片付けます。「解約」という語が入っていれば必ず解約カテゴリに分類する、といった単純なルールでも、全体の相当な割合を先に処理できることがあります。LLMに渡す件数が減れば、処理時間も費用も、外部に出るテキストの量も同時に減ります。

運用に乗せるときのリスクと歯止め

最後に、業務で使うときに押さえておきたいリスクを整理します。第一が幻覚です。モデルが、もっともらしいが事実でない内容を出力する現象で、原理的になくすことはできません。本章で実装した根拠の引用と、その引用が原文に存在するかどうかの検証は、この歯止めの1つになります。ただし、この検証が捕まえるのは「原文にない引用」だけだという点は、はっきりさせておく必要があります。引用が原文に実在していても、そこから導けない結論を抽出項目に書いてくることは起こりますし、原文の一節を正しく引きながら、その一節が言っていない条件を足して答えることもあります。RAGでも同じで、渡した文書の中に書かれていない答えを返してきたら弾くという検証は、渡した文書から本当にその答えが導けるかまでは見ていません。幻覚全体への備えとしては、この自動検証に加えて、一定割合を人が原文と突き合わせて確かめる工程、抽出項目と根拠が対応しているかを照合する工程、そして根拠から結論が導けるかという含意の確認を別に置くことになります。自動で弾ける違反と、人が読まないと分からない違反は、分けて数えたほうが運用の実態がつかめます。

第二がプロンプトインジェクションです。処理の対象になるテキストの中に、モデルへの指示として読める文章が紛れ込み、本来の指示を上書きしてしまう問題です。VoCや問い合わせのように外部から来る文章を扱う以上、これは仮定ではなく想定すべき事象です。対策としては、システム側の指示と処理対象のデータを明確に分けて渡すこと、出力形式を厳格に検証して指定外の出力を弾くこと、そしてLLMの出力をそのまま外部への送信や削除といった副作用のある処理につながないことです。分類や抽出の用途であれば、出力を列挙型と数値に限定しておくだけで、実害はかなり抑えられます。

第三が個人情報の送信です。外部のAPIを使う場合、渡したテキストは社外に出ます。VoCや問い合わせの本文には、氏名、電話番号、住所、注文番号が普通に含まれます。送ってよいかどうかは契約と社内規程で決まる話であり、技術的に可能かどうかとは別の問題です。実務上の手当ては、渡す前に伏せ字処理を通すこと、送信する項目と保管期間を文書化しておくこと、そして自社の環境内で動かせるモデルという選択肢を検討することです。

第四がバージョンの固定と再評価です。モデルは提供側の都合で更新され、更新の前後で出力の傾向が変わることがあります。プロンプトを一文字も変えていないのに分類の傾向が変わった、ということが起こりえます。モデル名はバージョンまで指定して固定し、更新するときは、固定した評価データで前後を比べてから切り替えます。この評価データを持っていないと、変化に気づくことすらできません。学習データが不要になっても評価データは必要だ、というのはこの意味においてです。

第五が監査ログです。いつ、どのモデルの、どのバージョンに、どんなプロンプトで、どんな入力を渡し、何が返ってきて、検証がどう判定したかを、一件ずつ記録します。この一式を残しておかないと、後から「なぜこの案件が最優先になったのか」を追えません。ログの量は多くなりますが、後から原因を追える状態を作っておくことは、LLMを業務に組み込む条件だと考えています。あわせて、リトライで通らずに打ち切った件数と、検証で弾いた違反の種別も日次で記録しておくと、異常の兆候が数字として先に出ます。

本章では、LLMがテキストマイニングの入口にある学習データ作成の壁を取り払った一方で、目的設計と評価とデータ品質は変わらず残ることを確認しました。ゼロショット分類と構造化抽出は試作を大きく速めますが、出力を疑って検証する層を自分で持たなければ業務には載せられず、10種類の模擬出力のうち7種類が検証で弾かれたことがその必要性を示しています。RAGについても、うまくいくかどうかの大半は生成ではなく検索の側にあり、チャンクの切り方ひとつでRecall@3が0.60から1.00まで動きました。ここまでで手法の説明は一通り終わります。第10章では、これらをどうプロジェクトとして立ち上げ、評価し、運用に乗せていくかを扱います。

この章を深めたい方への参考書籍

『大規模言語モデル入門』(山田育矢 監修・著、鈴木正敏・山田康輔・李凌寒 著、技術評論社):本章ではLLMを道具として使う立場から書きましたが、その内部でどのような処理が行われているかを理解しておくと、出力が揺れる理由や、モデルの更新で傾向が変わる理由が腑に落ちます。Hugging Faceのライブラリを使いながら理論と実装を往復する構成で、第7章の分類や第8章の埋め込みとのつながりも見えてきます。

『LangChainとLangGraphによるRAG・AIエージェント[実践]入門』(西見公宏・吉田真吾・大嶋勇樹 著、技術評論社):本章ではRAGの四工程のうち前半だけを実際に動かし、生成の工程は設計の形を示すにとどめました。生成まで含めた実装と、RAGの評価をどう行うかを手を動かして確認したい読者に向いています。

『LLMのプロンプトエンジニアリング』(John Berryman・Albert Ziegler 著、服部佑樹・佐藤直生 訳、オライリー・ジャパン):本章で触れたラベル定義の書き方、出力形式の固定、該当なしの用意といった勘所を、モデルの仕組みに立ち返って体系的に扱った一冊です。プロンプトを試行錯誤で調整するのではなく、なぜその書き方が効くのかを理解したい読者に向いています。

第10章 実務プロジェクトの進め方

第9章までで、前処理から頻度分析、共起、感情分析、トピックモデル、文書分類、埋め込み、そして大規模言語モデルの活用まで、テキストを数字に変える手法を順に見てきました。手法が揃えば結果は出ます。しかし、出た結果が業務の中で使われ続けるかどうかは、手法とは別のところで決まります。この章では、テキストマイニングを一度きりの分析で終わらせず、業務に組み込んで回し続けるためのプロジェクトの進め方を扱います。

扱う範囲は5つです。プロジェクト全体の型、データの棚卸し、学習データを作るためのアノテーション設計、評価設計、そして精度をビジネス側へ伝える方法と運用の作り方です。このうちアノテーション設計と、精度の伝え方の2つは、技術書でも社内の議論でも手薄になりやすいところなので、厚めに書きます。個別のユースケースごとの組み立て方は第11章で、内製と外部支援の分担は終章で扱いますので、この章では全ユースケースに共通する骨組みに絞ります。

プロジェクトの5つの工程と、それぞれで決めること

テキストマイニングのプロジェクトは、課題定義、データ棚卸し、小さく検証、評価設計、運用移行という5つの工程に分けて考えると管理しやすくなります。この並びは開発の順序というより、決めなければならないことの順序です。前の工程で決めるべきことを決めないまま次に進むと、後戻りの量が工程を追うごとに増えていきます。

それぞれの工程で何を決め、決めずに進むと何が起きるかを整理します。

工程ここで決めること決めずに進むとどうなるか
課題定義誰のどの判断を、どう変えるのか。何が起きたら成功と言えるのか分析すること自体が目的になる。結果を見た人が「それで、次に何をすればよいのか」と言ったところで止まる
データ棚卸しどこに何件のテキストがあり、どの経路でどこまで取り出せるのか。個人情報と保存期間の扱い設計が進んだ後になって「そのログは3か月で消えていた」「その項目は個人情報なので使えない」が判明し、前提から作り直しになる
小さく検証全体のどの一部で、どこまで確かめたら次に進んでよいのかいきなり全社展開の設計に入る。成り立たないと分かった時点で、投じた設計が丸ごと無駄になる
評価設計何をどう測るか。どの水準に達したら導入するか出てきた数字に後から意味づけをすることになり、導入の可否がその場の空気や声の大きさで決まる
運用移行誰が、どの頻度で、何を更新するのか。壊れたことをどう気づくか動いているうちは誰も触らない。精度が落ちてから担当者を探し始め、そのまま使われなくなる

課題定義でつまずくプロジェクトは非常に多いように思います。「顧客の声を分析したい」は課題ではなく願望です。課題定義とは、分析結果を受け取る人と、その人が今どういう判断をしていて、その判断がどう変わってほしいのかを言葉にすることです。たとえば「商品企画の担当者が、次の改良点を月次会議で3つ挙げている。今はベテランの記憶と直感で挙げているので、根拠が示せず議論が長引く。件数の裏づけを付けて挙げられるようにしたい」であれば課題です。ここまで具体化すると、必要な粒度も、更新頻度も、許容できる誤りの量も自然に決まってきます。

小さく検証する工程では、何を確かめるのかを1つに絞ることが重要です。手法が成り立つかを見たいのか、データの品質が足りるかを見たいのか、業務の中で使ってもらえるかを見たいのかで、作るものがまったく変わります。3つを同時に確かめようとすると、どれも中途半端になり、うまくいかなかったときに原因の切り分けができません。

課題定義から運用移行までの5工程と各工程の成果物、決めずに進んだ場合の手戻り範囲を示した概念図

データ棚卸し:どこに何件、どんな形で眠っているか

データ棚卸しは、社内のどこにどれだけのテキストがあり、それが分析に使える形かどうかを1つずつ確認していく作業です。地味な工程ですが、ここを飛ばすと、後半で止まることが多いように思います。手法の検討よりも先に、この確認を終わらせておくことをお勧めします。

まず件数と期間です。「問い合わせが年間で数万件ある」という感覚値ではなく、システムから実際に件数を引いて、何年何月から何年何月までの分が残っているかを確認します。ここで多いのが、直近1年分しか残っていない、あるいは途中でシステムを移行していて古い分は別の形式で凍結されている、というケースです。時系列で傾向を見たい設計だったのに、比較できる期間が半年しかなかったということが起こります。

次に取得経路です。同じ「顧客の声」でも、どこから取り出すかによって、前段でかかる手間がまったく違います。主な経路と、それぞれで先に確認すべきことを整理します。

取得経路典型的な状態先に確認すること
業務システムのデータベース構造化された列の中に、自由記述の列が1つだけ混じっている文字数の上限で末尾が切れていないか。担当者が追記して上書きしていないか。履歴が残っているか
担当者が出しているCSV定期的に手作業で書き出されている。出力条件が途中で変わっていることがある文字コード(Shift_JISとUTF-8の混在)、改行を含むセルの引用符の扱い、出力条件の変更履歴
メール本文に引用返信と署名が何段も積み重なっている引用部分・署名・定型の挨拶をどこまで落とすか。添付ファイル側に本文がある割合
チャット・問い合わせフォーム1つの用件が複数の発言に分かれているどこからどこまでを1件と数えるか。自動応答の発言をどう除くか
PDF・スキャン文書段組みと表が混在し、ページをまたいで文が切れているテキスト層があるか。無い場合は文字認識の精度をどう見積もるか
音声の書き起こし話し言葉で、言い淀みや相づちが多い。固有名詞が誤変換されやすい書き起こしの品質水準。自社の製品名がどの程度正しく変換されているか

個人情報の混入は、棚卸しの段階で必ず確認します。氏名・電話番号・住所・会員番号・口座に関する記述が自由記述欄に書かれていることは珍しくありません。分析の対象がクレームの内容であっても、テキストの中に本人を特定できる記述が含まれていれば、そのデータは個人情報として扱う必要があります。マスキングするのか、対象から外すのか、閲覧できる人を限定するのかを、分析を始める前に社内の規程と照らして決めておきます。後から「実はこのデータは使えなかった」となると、それまでに作った学習データごと捨てることになります。

保存期間も同様です。ログの保持方針が3か月であれば、半年分の学習データを集めるには今から溜め始めるしかありません。分析用に別途保管する仕組みを作るのであれば、それ自体が方針の変更にあたるので承認が必要です。技術の話ではなく社内の合意の話なので時間がかかります。だからこそ最初にやります。

フォーマットの乱れと欠測・重複は、実際にデータを読み込んでみないと分かりません。文字コードが混在していれば文字化けが起こりますし、改行を含むセルが正しく引用符で囲まれていなければ行がずれます。重複は、同じ顧客が同じ内容を複数チャネルから送っているケースと、システム側の再送で同一レコードが二重に登録されているケースを分けて数えます。前者は業務上意味のある重複で、後者は単なる汚れです。

見落としやすいのがテンプレート文の割合です。問い合わせ対応の記録には、担当者が定型文を貼り付けている部分が大量に含まれます。どの記録にも現れる決まり文句は、頻度分析では上位を占め、分類モデルにとってはただの雑音になります。テンプレート文が全体の文字数の何割を占めるかを測り、除去の対象として洗い出しておきます。テンプレートの一覧は、対応マニュアルを持っている業務部門に聞けば手に入ることが多いはずです。

  • 件数と対象期間を、感覚値ではなくシステムから引いた実数で押さえたか
  • 取得経路ごとに、実際にサンプルを取り出して中身を目で見たか
  • 自由記述欄に個人情報が混入していないか、混入している場合の扱いを社内で確認したか
  • ログの保存期間を確認し、必要な期間分が今後も残る見込みがあるか
  • 文字コード・改行・引用符の乱れで読み込みが崩れないか、実際に読み込んで確認したか
  • 欠測(空欄)と重複の件数を数え、重複を業務上の重複とシステム上の重複に分けたか
  • テンプレート文が占める割合を測り、除去対象の一覧を業務部門から入手したか

ラベル体系の設計:既存の業務分類をそのまま使わない

教師あり分類を行うにせよ、トピックモデルの結果を人が解釈するにせよ、テキストに何らかのラベルを付ける作業は避けて通れません。第7章で分類そのものの技術を扱いましたので、ここではその手前にある、ラベル体系をどう設計するかという問題を扱います。

最初に検討されるのは、たいてい既存の業務分類です。問い合わせ管理システムに登録されている分類コード、商品マスタのカテゴリ、報告書の様式に書かれた区分などです。これをそのまま使えれば手間がかかりませんし、業務部門にも説明しやすいので、当然の選択肢に見えます。しかし、既存の業務分類をそのまま学習用のラベル体系として使うことには、いくつかの危険があります。

1つは、既存の分類が入力時の運用に強く影響されている点です。多くの業務システムでは、分類の選択が必須項目になっており、迷ったときに選ぶ「その他」や、先頭に表示される項目に件数が集中します。この偏りは業務の実態を反映したものではなく、画面の作りが生んだものです。そのまま学習させると、モデルは実態ではなく入力の癖を学んでしまいます。

もう1つは、分類の定義が担当者ごとに揺れている点です。同じ内容の問い合わせが、担当者Aでは「使い方」に、担当者Bでは「不具合」に登録されている、ということが日常的に起きています。人が付けたラベルが揺れているデータで学習すれば、モデルの出力も揺れます。既存の分類コードを使う前に、同じ内容の記録がどれだけ違うコードに散っているかを、サンプルを取って目で確認しておく必要があります。

3つ目は、業務分類が排他的とは限らない点です。1件の問い合わせが「料金が分かりにくい」と「操作が分かりにくい」の両方を含んでいることは普通にあります。既存の業務分類では、担当部署を決めるという目的のために1つだけ選ぶ運用になっていますが、分析の目的が「何が不満の種になっているか」であれば、複数のラベルを付けられる設計のほうが実態に合います。1件に1つのラベルを付ける単一ラベル分類にするのか、複数付けられる多ラベル分類にするのかは、モデルの選択より前に決めるべき設計事項です。

粒度の決め方にも原則があります。細かすぎるラベル体系は、1つのラベルあたりの件数が足りなくなり、学習もできませんし、人が一貫して付けることもできません。逆に粗すぎると、分類できても業務上の打ち手につながりません。目安として、そのラベルが付いた文書をまとめて見たときに、担当者が「では次にこれをやろう」と言える程度の粒度を選びます。ラベルの数は、まず10個以下から始め、件数が集まってから細分化するほうが、途中で破綻しにくいはずです。

ラベル体系を設計するときは、次の3点を必ず紙に書き出して確認します。第一に、どのラベルにも当てはまらない文書が出たときにどこへ入れるのか。第二に、複数のラベルに当てはまる文書が出たときにどうするのか。第三に、そのラベルは分析結果を受け取る人の意思決定にどうつながるのか。3つ目に答えられないラベルは、たいてい後で使われなくなります。

アノテーションガイドラインと境界事例集の作り方

ラベル体系が決まったら、次はアノテーションガイドラインを作ります。アノテーションとは、テキスト1件ずつに人手でラベルを付けていく作業のことで、ガイドラインはその作業手順と判断基準を書いた文書です。「見れば分かるから口頭で伝えればよい」と考えて省略されがちですが、ガイドラインが無いアノテーションは、作業者が3人いれば3通りの基準で進みます。そして、その揺れを後から直すには、全件を付け直すしかありません。直せないわけではなく、直すための費用が跳ね上がるということです。

ガイドラインには、少なくとも次の項目を書きます。ラベルの一覧と、それぞれの定義。定義は「〜に関する内容」といった曖昧な書き方ではなく、「〜という記述が含まれる場合に付ける」という判定可能な形で書きます。次に、各ラベルの典型例を3件から5件。実際のデータから取ってきた文章をそのまま載せます。そして、迷いやすいケースの判断ルール。ラベルが複数当てはまる場合の優先順位、判断できない場合の逃がし先、作業の対象外とする文書の条件です。

  • ラベルの一覧と、判定可能な形で書かれた定義
  • ラベルごとの典型例を、実データから3件から5件
  • 複数のラベルに当てはまるときの優先順位、または複数付与を許すかどうかの規定
  • 判断できないときの逃がし先(保留ラベル、あるいはコメント欄への記入)
  • 作業の対象外とする文書の条件(空欄、テンプレート文のみ、他言語など)
  • 作業の単位と1件あたりの目安時間、休憩の取り方
  • 個人情報を見つけたときの扱いと報告先
  • ガイドラインの版数と更新履歴、更新した場合に過去分をどうするか

ガイドラインの質を決めるのは、典型例ではなく境界事例集のほうです。境界事例集とは、どちらのラベルを付けるべきか迷った実例と、その場合はどちらを選ぶかという裁定を、理由付きで蓄積した文書です。作り方は単純で、作業者が迷った件をその都度書き留めてもらい、責任者が裁定して集に追記していきます。作業が進むほど集が厚くなり、新しく参加した人でも同じ判断ができるようになります。

境界事例集を作るときの原則は、裁定に必ず理由を添えることです。「この文はAではなくBにする」だけでは、似て非なる次の事例に応用できません。「この文は不具合の報告に見えるが、製品の仕様どおりの動作を誤解しているだけなので、使い方の分類に入れる。仕様どおりかどうかが判断の分かれ目である」と書けば、次に同種の事例が来たときに作業者が自分で判断できます。この理由の積み重ねが、そのままラベル定義の精緻化になっていきます。

ガイドラインは一度書いて終わりにせず、最初の50件から100件を試しに付けてみて、その結果を見て改訂します。試行の段階で気づける矛盾を、本番の作業に持ち込まないためです。この試行は、後の一致率の測定と兼ねて実施すると効率的です。

複数人での作業、一致率の測り方、必要件数とツール選定

第7章では、複数人でラベルを付けるときに一致率を測ること、そして一致率が低いときに疑うべきはアノテーターの能力ではなくラベル定義の曖昧さであることに触れました。ここでは、その測り方と、測った後に何をするのかを設計として詰めます。使う物差しがアノテーター間一致率です。単純に「2人の付けたラベルが一致した割合」を数えるだけでは不十分で、偶然一致する分を差し引いた指標を使います。ラベルの1つに件数が集中している場合、両者が何も考えずにそのラベルばかり付けても、単純一致率は高く出てしまうからです。

代表的な指標が2つあります。1つはコーエンのカッパ係数です。2人の評価者が同じ対象すべてに名義尺度のラベルを付ける設計で使い、\( \kappa = \frac{P_o – P_e}{1 – P_e} \) と定義されます。\( P_o \) は実際に2人のラベルが一致した割合、\( P_e \) は各評価者のラベル付与の偏り(周辺分布)から計算される、偶然に一致すると期待される割合です。値は1で完全一致、0で偶然と同程度、負の値は偶然を下回る状態を意味します。

もう1つはクリッペンドルフのアルファ係数です。\( \alpha = 1 – \frac{D_o}{D_e} \) と定義され、\( D_o \) は観測された不一致の大きさ、\( D_e \) は偶然に期待される不一致の大きさです。こちらは評価者が3人以上でも使え、一部の評価者が一部の対象しか見ていない(欠測がある)状況にも対応し、名義・順序・間隔・比率のいずれの尺度にも適用できます。3人以上で作業を分担しながら一部を重複させて測る、という実務でよくある設計にはこちらが向いています。

いずれの指標も、値がいくつ以上なら十分という絶対的な基準はありません。慣用的な目安が紹介されることはありますが、それは分野ごとの経験則であって、業務の要求から導かれたものではありません。実務では、一致率の絶対値を追うより、同じラベル体系で測り続けて推移を見るほうが有益です。ガイドラインを改訂した後で一致率が上がったのであれば、改訂は効いています。上がらなければ、改訂が的を外しています。

ここで、第7章で触れた原則を設計の手順まで落とします。一致率が低いとき、直すのはモデルではなくラベル定義です。人間が2人いて同じ基準で判断できない対象を、モデルが安定して分類できるはずがありません。一致率が低い箇所を特定し、どのラベルの組み合わせで食い違っているかを行列の形で並べると、たいてい特定の2つのラベルの間に食い違いが集中しています。そこがラベル定義の穴です。定義を書き直し、境界事例集に追記し、必要ならラベルを統合します。この作業を飛ばして学習データを増やしても、揺れたデータが増えるだけで精度は上がりません。

ラベルの一致率を食い違い行列で測り、ラベル定義の修正へつなげる循環を示した概念図

必要件数に厳密な計算式はありませんが、実務上の見当の付け方はあります。まず、最も件数の少ないラベルを基準に考えます。全体で何万件あっても、あるラベルの学習データが20件しかなければ、そのラベルは分類できません。件数の見当については、第7章に実測があります。5クラスの分類で1クラス10件のときの macro-F1 は 0.5993 でしたが、1クラス40件まで増やすと 0.7690 に達し、そこから80件、120件と増やしても 0.7726、0.7808 と伸びは緩やかでした。伸びの大半が最初の数十件で出ているという形です。ただし、この実測に使ったのは語彙の狭い架空のサンプルデータなので、実データでは同じ件数で頭打ちにはなりません。ここから引き出せるのは「1ラベルあたり何件必要か」という数字ではなく、「まず数十件を付けて測り、伸びしろを確認してから追加分を決める」という進め方のほうです。必要件数の目安をどうしても先に置きたい場合も、それは根拠のある確定値ではなく、学習曲線が描けるまでの仮置きだと扱います。件数を増やしながら精度がどう変わるかを描いた学習曲線を見て、伸びなくなったところで打ち止めにする進め方が確実です。増やしても伸びない場合は、データが足りないのではなくラベル定義か特徴量に問題があります。

アノテーションツールの選定は、次の観点で見ます。第一に作業のしやすさで、1件あたり数秒の差が数千件では大きな差になります。キーボードだけで操作が完結するか、直前の判断を取り消せるかを確認します。第二に作業ログが残るかどうかで、誰がいつ何を付けたかが残っていないと一致率が測れません。第三にガイドラインと境界事例集を作業画面から参照できるかどうかで、別ウィンドウで探す作りだと参照されなくなります。第四にデータの持ち出し方で、個人情報を含むテキストを外部サービスへ送ってよいかは棚卸しで確認した方針に従います。第五に出力形式で、分析側で扱いやすい形に書き出せるか、途中経過を保存して再開できるかを見ます。

評価設計:何点なら導入するかを着手前に決める

評価には大きく2種類あります。手元に取り置いたデータでモデルの出力を測るオフライン評価と、実際の業務に組み込んで結果を測るオンライン評価です。オフライン評価は速く安く繰り返せますが、業務での有用性は測れません。オンライン評価は実態を測れますが、準備に時間がかかり、失敗したときの影響が業務に及びます。順序としては、オフライン評価で見込みが立ったものだけをオンライン評価に進めます。

オフライン評価で最初に決めるのは、テストデータの作り方です。学習データからランダムに一部を取り分けるのが基本ですが、テキストマイニングの場合はこれだけでは不十分なことが多いように思います。テストデータは、本番で処理する文書の分布に合わせる必要があります。学習データが過去のクレーム対応記録に偏っているのに、本番では問い合わせ全般を処理するのであれば、テストデータは問い合わせ全般から取らなければ、評価値は実力より高く出ます。

時間的な分割も重要です。テキストは時間とともに内容が変わります。新製品が出れば新しい製品名が現れ、キャンペーンがあればその話題が増えます。ランダムに分割すると、同じ時期の文書が学習側とテスト側の両方に入るため、時期特有の言い回しを覚えたことが精度として現れます。本番では未来の文書を処理するのですから、過去の期間で学習し、それより後の期間で評価する分割にしておくと、実力に近い値が得られます。

データリークにも注意が必要です。テキストマイニングで起きやすいリークは3種類あります。1つ目は重複によるリークで、同一の文書が学習側とテスト側の両方に入っている状態です。棚卸しで重複を数えていれば防げます。2つ目は前処理によるリークで、単語の重み付けや語彙の作成を学習データとテストデータをまとめた状態で行うと、テストデータの情報が学習側に漏れます。3つ目は正解由来の特徴量によるリークで、対応完了後に付けられた担当部署の情報を特徴量に入れてしまうと、実質的に正解を見ていることになります。分類したい時点で手に入る情報だけを使うという原則で洗い出します。

そして、評価指標をビジネス指標に接続します。第7章で扱った適合率と再現率は、業務上のコストと直接結びついています。見逃し、つまり本当は対応が必要なのに拾えなかった件のコストと、確認、つまり自動処理せず人が見ることになった件のコストを比べると、どちらを優先すべきかが決まります。重大なクレームの見逃しが大きな損失につながる業務であれば、確認件数が増えても再現率を優先します。逆に、人手が限られていて確認の余力がない業務であれば、確信度の高い部分だけを自動化し、残りを従来どおりに回す設計にします。この判断が、分類の確信度をどこで区切るかという閾値の設計になります。

最後に、この章で最も強調したい点です。何点に達したら導入するかを、着手前に決めておきます。これを後回しにすると、出てきた数字を見てから基準を作ることになり、その基準は必ず出てきた数字に都合よく寄ります。着手前に「月間の誤振り分けが50件以内で、人の確認が400件以内に収まるなら導入する」と決めておけば、結果が出たときの判断は事務的に済みます。決められないのであれば、それは課題定義がまだ足りていないという合図です。

精度をビジネス側へ伝える:件数と工数に翻訳する

指標の値をそのまま報告しても、受け取った側は良し悪しを判断できません。「F1スコアが0.82です」という報告は、事実としては正しくても、判断材料としては何も渡していないのと同じです。0.82が良いのか悪いのか、それで業務がどう変わるのかが、指標の名前からは読み取れないからです。

精度をビジネス側へ伝えるときの原則は、件数と工数に翻訳することです。指標は、翻訳の途中式であって、報告の結論ではありません。翻訳の起点には、件数へ素直に直せる指標を選びます。ここでは、全体のうち正しく分類できた割合を示す正解率を起点に置きました。以下は、月間の問い合わせ1000件を5つの分類に自動振り分けする仕組みを想定した、架空の例による翻訳です。数値はいずれも説明のために置いたもので、実在の案件の実績ではありません。

伝え方の段階報告の文言受け手に伝わること
指標のまま正解率が0.82ですほぼ何も伝わらない。良し悪しの判断ができない
件数に翻訳月に届く1000件のうち、約820件は正しい分類に入り、約180件は違う分類に入ります誤りの規模が分かる。180件という数を業務の感覚と照らせる
運用込みの件数確信度が高い600件だけを自動処理すると、その中の誤りは約30件です。残る400件は従来どおり人が振り分けます実際に起きることが分かる。全件自動化ではないと理解できる
工数に翻訳全件を人が振り分けると1件1分として約17時間。この運用なら確認に約7時間、誤りの手戻りに約5時間で、合計約12時間です効果の大きさが分かる。削減分だけでなく増える確認工数も見える
誤りの種類で分ける30件の誤りのうち、料金と請求の取り違えが約20件、重大なクレームを一般の問い合わせに落とすものが約3件です誤りの業務影響が分かる。件数が同じでも重みが違うと理解できる

翻訳の起点にどの指標を置くかは、件数へ直せるかどうかで決めます。正解率は「全体のうち正しかった割合」なので、総件数に掛ければそのまま件数になります。一方でF1スコアは、第7章で扱ったとおり適合率と再現率の調和平均であって、正しかった件数の割合ではありません。0.82という値に1000を掛けても、意味のある件数は出てきません。F1スコアを報告に使うのであれば、件数へ直さずに「取りこぼしと誤検出のバランスを1つの数にまとめた値で、前回の測定と比べて上がった」という使い方に留めます。そのうえで業務に効く量は、分類ごとの適合率と再現率を、見逃し件数と確認件数に翻訳して示します。クラスごとに件数の偏りが大きい業務ほど、全体をならした1つの指標では少数の重要な分類の取りこぼしが見えなくなるので、この分解が要ります。

この翻訳で欠かせないのが、削減できる工数と増える確認工数を並べて示すことです。自動化の提案は、削減側だけを示すと必ず過大に見えます。上の例でも、削減だけを見れば17時間がゼロになるように聞こえますが、実際には確認と手戻りで12時間が新たに発生し、正味の削減は5時間ほどです。この数字を先に出しておけば、導入後に「思ったより楽にならない」という不満が出ることを防げます。増える工数を自分から出す報告は、信用されます。

誤りの種類ごとに業務影響が違うことを示すのも同様です。上の例では30件の誤りのうち、料金と請求の取り違えは、次の担当者が読めばすぐ気づいて転送できる種類の誤りです。一方で、重大なクレームを一般の問い合わせに落とす誤りは、対応が遅れて事態が悪化する種類の誤りです。件数は20件と3件ですが、業務上の重みは逆かもしれません。数を並べるだけでなく、その誤りが起きたときに何が起きるかを1行添えます。

100%にはならないという前提は、最初の会議で握っておきます。後から伝えると言い訳に聞こえますが、着手前に伝えれば設計条件になります。同時に、100%でないから使えないのではなく、人が全件を見ている今の運用も100%ではない、という点を数字で示せると議論が噛み合います。現状の誤りがどれくらいあるかを実際に測っておくと、比較の土台ができます。現状を測らずに自動化の精度だけを議論すると、暗黙のうちに現状は完璧だという前提が置かれてしまいます。

そして、期待値だけでなく最悪ケースも見せます。上の例で言えば、「重大なクレームの取りこぼしが月に約3件」は期待値です。月によっては10件になることもあり得ます。テストデータでの評価値には幅がありますから、その幅を示し、悪いほうに振れたときに何件になるのかを出します。そのうえで、悪いほうに振れたときの受け止め方を先に決めておきます。たとえば、確信度が低い件は必ず人が見る運用にする、重大なクレームを示す特定のキーワードを含む文書は分類結果によらず優先対応に回す、といった安全側の仕掛けです。最悪ケースを出して安全側の仕掛けを添える報告は、期待値だけを示す報告より、はるかに承認されやすいはずです。

正解率という指標を件数・工数・業務影響へ言い換えていく変換の概念図

運用と辞書メンテ:モデルは放っておくと劣化する

導入して精度が出た仕組みも、そのまま置いておけば精度は落ちていくものと考えたほうが安全です。原因は、モデルが壊れるからではなく、入ってくるテキストのほうが変わるからです。この現象をデータドリフトと呼びます。新製品が出れば知らない製品名が入ってきますし、キャンペーンや料金体系の変更があれば、それまで存在しなかった類型の問い合わせが増えます。世の中の言葉づかいも変わります。学習した時点の言葉の世界と、今日入ってくるテキストの世界はずれていくので、劣化は起こるものとして設計しておきます。入力の傾向が安定している業務では、測ってみるとほとんど劣化しない期間もありますが、それは測って初めて言えることです。

もう1つ、入ってくるテキストが変わる現象と、正解の定義そのものが変わる現象は分けて考えます。同じ文が去年は使い方に、今年は不具合に分類されるようになるといった変化はコンセプトドリフトと呼ばれ、入力テキストの分布をいくら見ていても気づけません。次に述べる3段構えの監視のうち、これを捕まえられるのは定期サンプリング監査だけです。監視項目を決めるときは、どの項目がどちらの変化に効くのかを対応づけておくと、項目を置いた理由が後任にも伝わります。

劣化に気づく仕組みは3段構えで用意します。1つ目は入力分布の監視です。入ってくるテキストの長さの分布、未知語の割合、頻出語の顔ぶれを定期的に記録し、以前と比べて大きく変わっていないかを見ます。正解ラベルが無くても実施できるのが利点で、未知語の割合が急に上がっていれば新しい語彙が流入している合図です。2つ目は予測分布の監視です。モデルが出力する分類の内訳と確信度の分布を記録します。特定の分類の比率が急に増えた、確信度の低い出力が増えたといった変化は、入力側の変化を反映しています。3つ目は定期サンプリング監査で、毎月一定件数を無作為に抜き出して人が正解を付け直し、実際の精度を測ります。1つ目と2つ目は早期の警報、3つ目は確定診断だと考えると位置づけが整理できます。

辞書とストップワードの更新も、運用の中心的な作業です。第2章で作ったユーザー辞書には、自社の製品名やサービス名、業界特有の言い回しが入っています。新製品が出れば追加が必要ですし、旧製品名の表記ゆれも増えます。更新のフローは、誰が候補を挙げ、誰が採否を決め、誰が反映し、どこに記録するかを決めておきます。候補を挙げるのは新しい語が生まれる現場に近い人が向いており、商品企画や広報が正式名称と略称を、問い合わせ対応の担当者が顧客の側で使われている呼び方を提供する分担が現実的です。

辞書の更新は、更新した瞬間に分析結果が変わるという性質を持っています。先月と今月の集計を辞書の版が違う状態で比べると、傾向が変わったのか辞書が変わったのかを区別できません。辞書には版数を付け、どの集計がどの版で作られたかを記録します。時系列で比較したいときは、同じ版の辞書で過去分を作り直すか、更新した時点を報告書に明記します。

再学習のトリガーは、2つの考え方を組み合わせます。1つは定期実行で、3か月ごと、あるいは半期ごとといった一定の頻度で機械的に学習し直します。もう1つは条件発火で、監査で測った精度が定めた水準を下回った、未知語の割合が閾値を超えたといった条件で学習し直します。定期実行だけでは急な変化に間に合いませんし、条件発火だけでは条件の設計が甘いと永遠に発火しません。両方を仕掛けておくのが安全です。

再学習を行うには、モデルとデータのバージョン管理が要ります。どの期間のどの学習データで、どの前処理の設定でモデルを作ったのかが記録されていないと、精度が下がったときに何を戻せばよいかが分かりません。ガイドラインの版、辞書の版、学習データの版、モデルの版の4つが互いにどう対応するかを1枚の表で管理できていれば、担当が変わっても復元できます。

そして、担当者が変わっても回る形にすることが、運用設計の最終的な目標です。テキストマイニングの仕組みは、作った人の頭の中に判断の理由が大量に残ります。なぜこの語をストップワードにしたのか、なぜこの分類を統合したのか、なぜこの閾値にしたのか。これらが文書に残っていないと、次の担当者は触れなくなり、触れないものはやがて使われなくなります。境界事例集とガイドラインは、その意味で学習データと同じくらい重要な資産です。

  • 入力分布の監視項目(文書長・未知語率・頻出語)を決め、記録が自動で残る形にしたか
  • 予測分布の監視項目(分類の内訳・確信度の分布)を決め、変化に気づける形にしたか
  • 定期サンプリング監査の件数と頻度、実施担当を決めたか
  • 辞書とストップワードの更新フロー(候補提出・採否判断・反映・記録)と担当を決めたか
  • 辞書・ガイドライン・学習データ・モデルの4つに版数を付け、対応表を作ったか
  • 再学習のトリガーを、定期実行と条件発火の両方で設計したか
  • 判断の理由(語の除外・分類の統合・閾値の設定)を文書に残したか
入力分布・予測分布・サンプリング監査の監視と、再学習の判断および版管理の関係を示した概念図

体制と分担、そしてよくある失敗

体制は、業務部門、分析担当、情報システム部門の3者で組むのが基本形です。業務部門は、課題の定義とラベル体系の妥当性判断、そして境界事例の裁定を担います。ラベルの意味を決められるのは業務を知っている人だけなので、ここを分析担当が代行すると必ず歪みます。分析担当は、データの加工とモデルの作成、評価の設計と実施、そして結果の翻訳を担います。情報システム部門は、データの取得経路の確保、個人情報の扱いの確認、運用環境の整備を担います。この3者のうち1つでも当事者として関わっていない体制は、どこかで止まります。

内製と外部支援の線引きについては、ここでは方針だけ書きます。ラベル体系の設計と境界事例の裁定は、外部に出しにくい部分です。業務の判断そのものだからです。一方で、アノテーション作業そのものや、モデルの実装、運用基盤の構築は、外部の力を借りやすい部分です。判断は内側に置き、手を動かす部分は柔軟に、というのが基本の考え方になります。具体的な分担の設計は終章で扱います。

最後に、テキストマイニングのプロジェクトで繰り返し見られる失敗を整理します。いずれも、この章で述べた工程のどこかを飛ばしたときに起きるものです。

よくある失敗何が起きているか飛ばした工程と対処
目的が「分析すること」になっているワードクラウドや共起ネットワークの図は出るが、見た人が次の行動を決められない。報告の場が感想の共有で終わる課題定義。誰のどの判断を変えるのかを一文で書き、書けなければ着手しない
検証の評価基準が後付けになっている出た数字を見てから基準を作るため、いつも「悪くない結果」になる。導入するかどうかの判断が先送りされる評価設計。着手前に、件数と工数の水準で合格ラインを決めて文書に残す
アノテーションを分析担当だけでやっている業務の判断を分析担当が推測で代行している。出来上がったモデルを業務部門が見て「この分類は実務と違う」となり、学習データごと作り直しになるラベル体系の設計。業務部門を裁定者として最初から入れ、境界事例集を共同で作る
ダッシュボードを作って誰も見ない閲覧の導線が業務の流れの外にある。見に行く動機が個人の関心に依存している課題定義と運用移行。既存の会議や日次の業務の中に、その画面を見る手順を組み込む
辞書を誰も更新しない導入時に整えた辞書がそのまま放置される。新製品名が未知語のまま分割され、分析結果から新しい話題が消える運用移行。更新フローと担当を決め、監視で未知語率の上昇に気づける形にする
一致率が低いままモデルの改良を続けている人間同士で判断が割れているデータで学習しているため、どんな手法に変えても精度が頭打ちになるアノテーション設計。ラベル定義を書き直し、必要なら分類を統合してから学習し直す

この章では、テキストマイニングを一度の分析で終わらせないための進め方を扱いました。要点は3つです。第一に、ラベル体系とアノテーションガイドラインの品質が、後段のどの手法よりも結果を左右すること。一致率が低いときに直すのはモデルではなくラベル定義です。第二に、評価は着手前に設計し、何点で導入するかを先に決めること。第三に、精度は指標のままではなく、件数と工数に翻訳して伝え、増える確認工数と最悪ケースも併せて示すことです。次の第11章では、ここまでの手法と進め方を踏まえて、VoC分析、コールセンターログ、ECレビュー、アンケート自由記述、社内ナレッジ活用という5つのユースケースごとに、手法の組み合わせ方を具体的に見ていきます。

この章を深めたい方への参考書籍

『仕事ではじめる機械学習 第2版』(有賀康顕・中山心太・西林孝、オライリー・ジャパン):本章で扱った課題定義から評価設計、運用移行までの流れを、機械学習プロジェクト全般の視点から確認できます。何を作らずに済ませるかという判断や、システムに組み込んだ後の運用の話が具体的に書かれており、テキストマイニングに限らず適用できる考え方が得られます。

『Human-in-the-Loop機械学習:人間参加型AIのための能動学習とアノテーション』(Robert(Munro)Monarch著、上田隼也・角野為耶・伊藤寛祥訳、共立出版):本章の中心に置いたアノテーション設計を、さらに踏み込んで学べます。アノテーションの品質管理、作業者の評価と管理、アノテーションツールの設計、そして人が付けるべき事例を選ぶ能動学習まで扱っており、学習データを資産として育てたい読者に向いています。

『機械学習システムデザイン:実運用レベルのアプリケーションを実現する継続的反復プロセス』(Chip Huyen著、江川崇・平山順一訳、オライリー・ジャパン):本章の後半で触れたデータドリフトの検知、モデルの監視、再学習の設計を体系的に確認したい読者に向いています。作って終わりにせず、運用の中で改善を回し続けるという視点で全体が構成されています。

第11章 ユースケース詳説

第10章では、アノテーションの設計、評価の設計、そして精度をビジネス側へ伝える方法と運用の続け方を確認しました。手法の説明と、プロジェクトを回すための土台は、これで一通りそろったことになります。残っているのは、それらを実際の業務の形にどう並べるかという問題です。

本章では、企業が取り組むことの多い5つのユースケースを取り上げ、それぞれについて手法をどの順番で組み合わせ、どんな成果物として出すのかを具体化します。第1章で挙げた5類型に対応させて、VoC分析、コールセンターログ分析、ECレビュー分析、アンケート自由記述の分析、社内ナレッジ活用の順に見ていきます。手法そのものの中身は第2章から第9章までで扱っているため、本章では触れず、必要に応じて参照先の章を示す形にしました。読み方としては、自社に近いものを1本選んで組み合わせ表をそのまま設計の下敷きにする使い方も想定しています。

5本を同じ枠組みで読む

5つのユースケースは、対象となるテキストも部署も異なりますが、つまずく場所には共通の構造があります。そこで本章では、5本すべてを次の同じ枠組みで記述することにしました。まず「よくある課題」で、その業務が何に困っているのかを整理します。次に「データの性質」で、扱うテキストがどういう形をしていて、他のユースケースと何が違うのかを押さえます。ここを飛ばすと、他社でうまくいった手順をそのまま持ち込んで失敗します。

続く「手法の組み合わせ」が本章の中心です。工程ごとにどの手法を使い、それが本コラムのどの章に書かれているかを表にまとめました。そのうえで「アウトプットの形」として、分析結果を誰にどういう見た目で渡すのかを決めます。分析が業務に効かない原因の多くは、精度ではなく渡し方にあります。最後の「落とし穴」には、そのユースケース固有の失敗しやすい点を挙げました。

この枠組みで並べると、5本の違いがはっきりします。VoC分析はチャネルの統合、コールセンターログ分析は音声認識テキストの癖、ECレビュー分析は星評価という既存の数値との付き合い方、アンケート自由記述は件数の少なさ、社内ナレッジ活用は権限とフォーマットが、それぞれ設計上の主要な制約になります。

5つのユースケースと前処理から検索までの6工程の対応を示したマトリクス

ユースケース1 VoC分析:散らばった顧客の声を1つの台帳にまとめる

よくある課題:顧客の声は1か所に集まっていません。問い合わせフォーム、コールセンターの記録、通販サイトのレビュー、満足度アンケートの自由記述、ソーシャルメディアの投稿と、入り口ごとに別のシステムに溜まっているのが普通です。それぞれの担当部署が個別に読んで報告を上げるため、経営会議には「問い合わせが増えています」「レビューの評価が下がりました」という断片が並び、全体としてどの問題が一番大きいのかが誰にも分かりません。結果として、声の大きい1件が優先され、件数の多い地味な不満が後回しになります。

データの性質:チャネルごとに文の長さも語彙も違います。問い合わせフォームは説明的で長く、レビューは短く感情的で、コールセンターの記録は担当者が書いた要約であることが多いといった具合です。同じ不満でも表現が変わるため、単純に結合して頻度を数えると、文の長いチャネルの語彙が上位を独占します。また、チャネルごとに書き手の層が偏っている点も重要です。わざわざフォームから連絡する人と、購入直後に星を付ける人では、そもそも母集団が違います。

手法の組み合わせ:VoC分析は、単一の手法ではなく集計の連鎖として設計します。前処理で表記を揃え、あらかじめ決めたカテゴリに振り分け、カテゴリごとに件数と不満の濃さを出し、特徴語と代表文で中身を裏づける、という流れです。カテゴリの振り分けは、初期はキーワードルールで始め、学習データが溜まったら第7章の教師あり分類に切り替えるのが現実的だと考えています。件数の多いカテゴリと不満の濃いカテゴリは一致しないことが多く、両方を並べて初めて優先順位がつきます。件数と不満の濃さに分解して、どの要因が全体の評価を押し下げているかを見る考え方は、ドライバー分析と呼ばれる枠組みに連なります。厳密には、総合満足度や推奨度といった総合評価に対する各要因の寄与度を、回帰などで推定する手法を指します。件数とネガ比率を並べただけでは寄与度の推定にはなっていませんが、寄与度を推定する前に、どの要因を候補に載せるかを決める必要があります。その入口として、まず件数と濃さを並べるところから始めます。

工程使う手法参照する章アウトプット
チャネル統合と前処理正規化、記号除去、形態素解析、ストップワード第2章共通スキーマの文書テーブル
カテゴリ振り分けキーワードルール、のちに教師あり分類第7章カテゴリ列つきの文書テーブル
カテゴリ別の特徴語TF-IDF、n-gram第3章カテゴリごとの上位語リスト
不満の濃さ極性辞書、機械学習による感情分析第5章カテゴリ別のネガ比率
話題の俯瞰LDA、NMF第6章トピック一覧と構成比
裏づけ代表文書の抽出、KWIC第3章、第6章各トピックの実際の文

アウトプットの形:経営報告に載せるのは、カテゴリを行に取り、件数、構成比、ネガ比率、特徴語を列に取った1枚の表です。これに前月との差分を添えれば、どこが悪化したかが読み取れます。図は2枚あれば足ります。件数の棒グラフと、ネガ比率の棒グラフを横に並べたものです。この2枚を並べる意味は、量と濃さのどちらで判断するかを読み手に委ねるところにあります。加えて、各カテゴリの代表文を2件ずつ添えます。数字だけの報告は反論しにくいぶん、行動にもつながりません。実際の文が1つ入るだけで、会議の議論が具体的になります。

落とし穴:3つあります。1つ目は、チャネルを統合したことで母集団の偏りが見えなくなることです。レビューだけ件数が多いと、レビューの傾向が全体の傾向として報告されます。チャネル別の内訳を必ず残してください。2つ目は、カテゴリ設計を分析側だけで決めてしまうことです。カテゴリは、施策を打つ部署の担当範囲と一致していなければ、報告を受け取った側が動けません。3つ目は、感情分析の絶対値を信じることです。後述する実測のとおり、辞書ベースの判定は不満を大きく取りこぼすことがあり、月ごとの比較には使えても、水準そのものを語る根拠にはなりません。

ユースケース2 コールセンターログ分析:応対の記録を品質と一次解決の指標に変える

よくある課題:コールセンターには毎日大量の応対が積み上がりますが、活用されているのは通話時間や放棄呼といった件数系の指標だけ、という状態がよくあります。何が原因で電話がかかってきたのか、その電話で問題が解決したのか、同じ人が何度かけ直しているのかは、記録を読まないと分かりません。管理者が抜き取りで数十件を聴くのが精一杯で、全体の傾向は誰も把握していません。結果として、よくある問い合わせがFAQに反映されず、同じ質問に人手で答え続けることになります。

データの性質:ここが他のユースケースと最も違う部分です。音声認識で書き起こしたテキストは、書き言葉として整っていません。フィラーと呼ばれる「えーと」「あの」が大量に入り、句読点は認識エンジンが推定して付けるため文の切れ目が信用できません。固有名詞や商品名は誤認識されやすく、同じ商品が数種類の表記で現れます。さらに、テキストは発話単位で分割されており、1件の応対が数十から数百の短い発話行として並びます。1発話をそのまま1文書として扱うと、内容語がほとんど含まれない行が大量に生まれます。

もう1つ、応対には話者が2人います。オペレーターの発話と顧客の発話を混ぜて集計すると、応対マニュアルの定型句が上位語を占め、顧客が何に困っていたのかが埋もれます。話者を分けて集計できるかどうかが、この分析の成否をかなりの部分で決めます。

手法の組み合わせ:まず、発話行を通話単位に結合し直します。分析の単位は通話であって発話ではありません。そのうえで顧客側の発話だけを抜き出し、フィラーと定型あいさつをストップワードとして落とします。誤認識された商品名は、第2章のユーザー辞書と表記ゆれ辞書で吸収します。ここまでが前処理で、工数の大半はここに掛かります。分析側では、用件のカテゴリを分類器で振り、通話ごとの感情の推移を見て、話題ごとの構成比をトピックモデルで押さえます。応対品質の観点では、確認や謝罪といった応対上の必須要素が含まれているかをキーワードで検出し、抜けの多い通話を人の確認に回します。

業務指標との接続でよく使われるのが一次解決率です。1回の応対で用件が完了した割合を指し、同じ顧客からの再入電を追跡することで測ります。テキスト側からは、直前の通話と同じカテゴリで再入電しているかを分類結果で判定できるため、カテゴリ別の一次解決率を出すことができます。ここが低いカテゴリは、オペレーターの力量ではなく、案内資料や製品側に原因があることが多いように思います。なお、コンタクトセンターの運営上のガイドラインは業界団体からも公開されており、一般社団法人日本コンタクトセンター協会が業務倫理綱領やカスタマーハラスメント対策のガイドラインを掲載しています。指標や運用の枠組みを社内で議論するときの共通の土台として参照できます。

工程使う手法参照する章アウトプット
発話の結合と話者分離通話ID単位の結合、話者ラベルでの絞り込み第2章通話単位の顧客発話テキスト
認識誤りの吸収ユーザー辞書、表記ゆれ辞書、フィラー除去第2章正規化済みテキスト
用件の分類教師あり文書分類第7章用件カテゴリと一次解決率
話題の俯瞰LDA、共起ネットワーク第4章、第6章入電理由の構成比
応対品質の確認キーワード検出、感情の推移第3章、第5章要確認通話のリスト
FAQ整備類似検索によるクラスタリング第8章頻出質問の候補と既存FAQの過不足

アウトプットの形:センター長に渡すのは、用件カテゴリ別の件数と一次解決率と平均通話時間を並べた表です。件数が多く一次解決率が低いカテゴリが、改善の投資対象になります。品質管理の担当者に渡すのは、要確認通話のリストです。全件を聴くことはできないので、機械が拾った候補に絞って人が聴く運用にします。FAQ担当者に渡すのは、頻出質問の候補と、それに対応する既存FAQの有無です。既存FAQがあるのに問い合わせが減らない項目は、内容ではなく見つけやすさの問題である可能性が高くなります。

落とし穴:音声認識の精度を分析前に測っておかないと、結果の解釈ができなくなります。数十件でよいので人が書き起こした正解と突き合わせ、どの語がどう間違うかを把握してください。次に、通話の要約テキストを分析対象にする場合の注意です。オペレーターが入力した要約は、書いた人の解釈が入っており、テンプレート文が使い回されていることもあります。生の書き起こしと要約では、まったく別のデータだと考えたほうがよいでしょう。最後に、応対品質のスコアを個人の評価に直結させないことです。評価に使うと分かった時点で、オペレーターは指標に合わせた話し方をするようになり、指標が現実を映さなくなります。

ユースケース3 ECレビュー分析:星の数では見えない不満をアスペクト別に取り出す

よくある課題:通販サイトのレビューは、平均星評価という便利な数字が最初からついています。そのため多くの現場では、星の平均を見て終わりになっています。ところが平均が4.2から4.0に下がったとき、何が起きたのかは星の数からは分かりません。商品そのものが悪いのか、梱包なのか、配送なのか、サポートなのかが混ざったまま1つの数字になっているためです。また、競合商品と比べたいという要望はほぼ必ず出ますが、星の平均を並べても、どの点で負けているのかは出てきません。

データの性質:レビューは短く、1件のなかに複数の評価対象が混在します。「商品自体は良いが箱がつぶれていた」という1文には、商品への肯定と配送への否定が同居しています。第5章で見たとおり、文書全体に1つの極性を割り当てる方式では、この2つが打ち消し合ってしまいます。さらに、星評価とテキストの内容は必ずしも一致しません。星5でありながら本文には改善要望が書かれている例や、星1でありながら本文は配送業者への不満で商品には触れていない例が混ざります。この乖離こそが分析の起点になります。

手法の組み合わせ:軸になるのは、第5章で扱ったアスペクトベース感情分析です。評価対象ごとに極性を取るという考え方そのものは第5章に譲り、ここでは、それをECレビューの業務にどう組み込むかだけを見ます。まず商品、価格、配送、梱包、サポートといったアスペクトの一覧を決めます。次にレビューを文単位に分割し、各文がどのアスペクトについて書かれているかを分類し、その文の感情を判定します。これで、1件のレビューから複数の評価が取り出せます。アスペクトの割り当ては、初期は語彙リストで十分に機能します。「箱」「梱包」「潰れ」といった語を配送に紐づけるだけで、かなりの部分が拾えます。精度を上げる段階で、第7章の分類器に切り替えます。

競合比較は、同じアスペクト一覧を競合商品のレビューにも適用して、アスペクト別のネガ比率を並べる形で行います。星の平均が同じでも、内訳はまったく違うことが分かります。ただし、並べれば比較になるわけではありません。販売開始からの期間、レビューを取得したサイト、購入者の層、そしてレビュー投稿を促す施策の有無が揃っていないと、差が商品の違いから来ているのか集め方の違いから来ているのかを切り分けられません。比較の条件をどこまで揃えたのかを表に添えておかないと、読み手は差をすべて商品の差として読みます。サクラレビューの疑いについては、テキスト側の手がかりとして、極端に短い、語彙が定型的で複数レビュー間の類似度が高い、投稿日が特定の日に集中している、といった特徴が使えます。第8章の埋め込みで文書ベクトルを作り、類似度が異常に高いレビューの塊を検出する方法が実務的です。ただし、これはあくまで内部の品質管理のための兆候であって、個々のレビューを断定する根拠にはなりません。なお、広告であることを隠した表示については、消費者庁が令和5年内閣府告示第19号により令和5年10月1日から景品表示法違反となる旨を公表しています。自社が依頼する側にならないための線引きとして、分析担当者も内容を確認しておく価値があります。

工程使う手法参照する章アウトプット
文分割と正規化文境界の判定、正規化、形態素解析第2章文単位のレビューテーブル
アスペクト割り当て語彙リスト、のちに教師あり分類第7章文ごとのアスペクトラベル
アスペクト別の感情極性辞書、否定処理、機械学習第5章アスペクト別のネガ比率
星評価との突き合わせクロス集計第3章星とテキストの乖離一覧
競合比較同一アスペクトでの比率比較第5章競合との差分表
不自然な投稿の検出文書ベクトルの類似度、投稿日の分布第8章要確認レビューの候補

アウトプットの形:商品担当者に渡すのは、アスペクトを行、自社と競合を列に取ったネガ比率の比較表です。ここで差の大きいアスペクトが、次の商品改良か、サイト上の説明の見直しの対象になります。星評価との乖離は、星5なのにネガ文を含むレビューの件数と、その代表文で示します。この乖離が大きい商品は、期待と実物のずれが起きており、商品ページの記載に手を入れるだけで改善することがあります。改善の効果測定は、施策の実施日を境にアスペクト別のネガ比率がどう動いたかで見ます。

落とし穴:まず、レビューを書く人は購入者全体の一部にすぎません。強い不満と強い満足の両端が過剰に集まる傾向があるため、比率をそのまま顧客全体の割合として報告しないでください。次に、サイト側の規約と権利関係の確認です。レビュー本文の取得と保管には規約上の制約がかかることがあります。技術的に取得できることと、取得してよいことは別の問題です。最後に、アスペクトの一覧を細かくしすぎないことです。数を増やすほど1つあたりの件数が減り、月次の変動がノイズに埋もれます。第5章では7つのアスペクトで運用しましたが、そのなかでも件数の少ないものは数十件しか集まりませんでした。適切な個数を決める一般的な基準はないので、まずは少ない数から始め、件数が溜まったものだけを分割していくほうが安定します。分けるかどうかの判断は、そのアスペクトの件数が月次の変動を語れるだけ溜まっているかで決めます。

ユースケース4 アンケート自由記述の分析:少ない件数を選択肢設問と掛け合わせる

よくある課題:アンケートの自由記述は、集計の最後にまとめて誰かが読み、「その他の意見」として数行に要約されて終わることがよくあります。選択肢設問はきれいなグラフになるのに、自由記述だけが定性的な付録として扱われるためです。一方で、選択肢設問では取りこぼした理由が自由記述には書かれているため、施策を決める段になると「なぜこの項目の満足度が低いのか」が分からず止まります。

データの性質:件数が少なく、1件あたりの文が短いのが最大の特徴です。数百件、場合によっては数十件しかありません。この規模では、トピックモデルは安定しません。第6章で見たとおり、LDAは1文書あたりの語数が少ないと、乱数の種を変えるだけで文書の割り振りがほとんど別物になります。アンケートの自由記述は、件数も1件あたりの語数も少ないため、この条件に正面から当たります。トピックの中身が実行のたびに変わるようでは、報告に使える再現性は得られません。もう1つの特徴は、設問文に引きずられることです。「サービスの改善点をご記入ください」と尋ねれば回答は改善点に偏り、「良かった点」と尋ねれば肯定に偏ります。自由記述の感情比率をそのまま顧客満足度の指標として扱うと、設問の設計を測っているだけになります。

また、回答者の属性が分かっている点が他のユースケースとの大きな違いです。VoCやレビューでは書き手が誰か分かりませんが、アンケートでは年代、利用歴、契約プラン、そして各設問への回答が同じ行に並んでいます。自由記述を単独で読むのではなく、この構造を活用できます。

手法の組み合わせ:件数が少ないため、機械に判断させる範囲を絞り、人が読む作業を効率化する方向で設計します。具体的には、頻度とTF-IDFで語のあたりを付け、KWICで前後の文脈を確認し、共起ネットワークで語のまとまりを見る、という第3章と第4章の手法が中心になります。分類は、トピックモデルによる自動抽出ではなく、選択肢設問の結果や事前の仮説からカテゴリを人が定義し、それに沿って振り分けるほうが安定します。数百件であれば、人手でのラベル付けが現実的な選択肢になります。

そのうえで行うのが、選択肢設問とのクロス集計です。総合満足度が低いと答えた回答者の自由記述だけを抜き出して特徴語を見る、あるいは特定のカテゴリに言及した回答者と言及しなかった回答者で、満足度の分布を比較します。定量調査が示した数値の理由を、自由記述が説明するという関係になります。自由記述の分析は、定量調査に取って代わるものではなく、定量調査の解釈を確定させるための補完だと位置づけると、目的がぶれません。

工程使う手法参照する章アウトプット
前処理正規化、形態素解析、ストップワード第2章語のリストつき回答テーブル
語のあたり付け単語頻度、TF-IDF、n-gram第3章頻出語と特徴語のリスト
文脈の確認KWIC、共起ネットワーク第3章、第4章語の使われ方の一覧と関係図
カテゴリ付与人手ラベリング、必要に応じて分類器第7章、第10章カテゴリ列つき回答テーブル
属性との掛け合わせクロス集計第3章属性別、満足度別のカテゴリ構成比
要約代表文の抽出、必要に応じてLLMによる要約第9章カテゴリごとの要約と原文

アウトプットの形:報告書に載せるのは、選択肢設問のグラフのすぐ隣に置く、カテゴリ別の件数表と代表的な原文です。順序を逆にしないことが大切で、まず定量の結果を示し、その理由として自由記述を置きます。件数が少ないので比率は書かず、実数で示すほうが誠実です。20件中7件を35%と書くと、実態より強い根拠に見えてしまいます。カテゴリごとに原文を2件から3件そのまま載せると、読み手が自分で判断できるようになります。

落とし穴:最も多いのは、件数の少なさを無視して比率で語ることです。次に、無回答と実質的な無回答の扱いです。「特になし」「とくにありません」といった回答は、集計上は文字が入っているため件数に数えられますが、内容はありません。これらを除いた有効回答数を必ず併記してください。3つ目は、設問文の語をそのまま特徴語として報告してしまうことです。「改善」を問う設問で「改善」が頻出語の1位になるのは当然であり、情報がありません。設問文に含まれる語は、あらかじめストップワードに入れておくのが実務的です。

ユースケース5 社内ナレッジ活用:文書を探せる状態にしてから検索とRAGにつなぐ

よくある課題:社内には議事録、報告書、手順書、提案資料、過去案件の記録が大量に蓄積されています。それでも担当者は、過去に似た案件があったかどうかを人づてに聞いて探しています。ファイル名検索では見つからず、全文検索を入れても、キーワードが一致しないと出てこないためです。結果として、同じ調査が何度も行われ、退職者が持っていた知識はそのまま失われます。近年は、この状況を解決する手段として社内文書を対象とした生成AIの導入が検討されますが、対象の文書が整理されていない状態で仕組みだけを入れても、古い手順書を根拠にした回答が返ってくるだけです。

データの性質:他の4つと決定的に違うのは、テキストそのものより、テキストを取り巻く条件のほうが難しいという点です。第一に、アクセス権限があります。人事情報、個人情報を含む案件記録、取引先との守秘契約がかかる資料は、全員が読めるわけではありません。検索の仕組みを作るときは、検索結果に権限のない文書が出ないようにする必要があり、これは後付けが極めて難しい要件です。第二に、フォーマットが統一されていません。文書ファイル、表計算ファイル、プレゼンテーション資料、PDF、社内掲示板の投稿、チャットのログが混在し、それぞれ本文の取り出し方が違います。スキャンしたPDFは、そもそも文字情報を持っていないことがあります。

第三に、更新されない文書が混ざります。3年前の手順書と昨月の手順書が同じ検索結果に並び、どちらが最新かは本文からは判断できません。第四に、1つの文書が長く、話題が複数含まれます。100ページの報告書を1つの文書として扱うと、検索でヒットしても該当箇所にたどり着けません。

手法の組み合わせ:この用途では、分析よりも整備の工程が主役になります。各形式からの本文抽出、文書の分割、メタデータの付与という順で土台を作り、その上に検索を載せます。メタデータには、作成日、更新日、部署、文書種別、そして参照可能な権限グループを持たせます。項目立てに迷ったときは、第10章のデータ棚卸しで整理した観点を、分析対象のテキストではなく文書そのものに当ててみると決めやすくなります。どこに何件あり、どの経路で取り出せ、誰が見てよいのかという問いは、そのまま検索用のメタデータの項目になります。分割は、章や見出しの単位で数百字から千字程度にまとめるのが扱いやすい範囲です。検索は、キーワード一致による検索と、第8章の埋め込みによる意味的な類似検索を併用します。用語が正確に分かっている検索にはキーワードが強く、言い換えられた表現にはベクトル検索が強いため、両者の結果を統合する構成が実用的です。

その先に、第9章で扱った検索拡張生成をつなぎます。検索で絞り込んだ文書片を根拠として渡し、回答に出典を必ず付ける形にします。ここで重要なのは、権限のフィルタを検索の段階で適用することです。生成の段階で除外する設計にすると、権限のない情報がモデルに渡ることになります。全社的な知識の共有と再利用の取り組みはナレッジマネジメントと呼ばれ、システムだけでなく、文書を書く時点でのメタデータ入力や、古い文書を廃止する運用まで含めて設計されるべきものです。

工程使う手法参照する章アウトプット
本文の抽出各形式からのテキスト抽出、必要に応じてOCR第2章プレーンテキスト化した文書
正規化と分割正規化、見出し単位のチャンク分割第2章数百字単位の文書片
メタデータ付与更新日、部署、文書種別、権限グループの付与第10章検索用のメタデータ列
索引作成キーワード索引と埋め込みベクトル索引第3章、第8章検索インデックス
検索キーワード検索とベクトル検索の統合第8章権限で絞り込んだ候補文書片
回答生成検索拡張生成、出典の明示第9章出典つきの回答

アウトプットの形:利用者に見せるのは、回答文そのものよりも、根拠として使われた文書片と、その元文書へのリンク、そして更新日です。更新日が古い文書を根拠にしている場合は、その旨を画面に表示します。管理者に見せるのは、検索されたのに適切な文書がなかった質問の一覧です。これは、ナレッジの不足箇所を示すリストであり、次に何を文書化すべきかの優先順位になります。検索ログは、この仕組みが生む最も価値の高い副産物だと考えています。

社内文書の検索経路と、権限フィルタが必ず通過点になることを示した概念図

落とし穴:最大の失敗は、権限の設計を後回しにすることです。試験導入では全文書を対象にしておき、本番で権限を入れようとすると、索引の作り直しが必要になり、事実上の再構築になります。次に、更新されない文書への対処です。更新日だけでは、内容が現在も有効かどうかは分かりません。文書種別ごとに有効期限の考え方を決め、期限を過ぎたものは検索結果で下位に落とすか、警告を出す運用にします。3つ目は、利用が定着しないことです。既存の探し方より明確に速くならなければ、人は元のやり方に戻ります。導入前に、代表的な質問を20問ほど用意し、現行の方法との所要時間を比較しておくことをおすすめします。

VoC月次レポートを1本のスクリプトにまとめる

ここまでの5本のうち、最も多くの工程を含むVoC分析を、実際に動くスクリプトとして書いてみます。使うのは第2章で用意したサンプルVoCデータです。前処理、カテゴリ別件数、特徴語、感情比率、上位トピック、代表文書の順に集計し、最後に経営報告へそのまま載せられる表と図を出力します。呼び出している手法はすべて第2章から第6章までで解説したもので、新しい手法は使っていません。組み合わせる順番と、出力の形だけが本章の主題です。

先に断っておきます。このデータは乱数で生成した架空のサンプルであり、実在の顧客の声ではありません。したがって、以下に出てくる比率そのものを実務の見積もりに使うことはできません。ここで示せるのは、手法を組み合わせたときにどういう数字が出て、それをどう読み違えやすいかという現象の向きだけです。また、掲載しているコードは主要な工程の抜粋で、図を描画する部分と、集計表の一部の列を組み立てる処理は紙幅の都合で省いています。

import io, sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")

import numpy as np
import pandas as pd
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

import ch05_lexicon as lex
from ch03_common import tokenize
from voc_corpus import load_voc

# 工程1 前処理(第2章)
df = load_voc()
df["tokens"] = df["text"].map(lambda t: tokenize(t))
df["wakati"] = df["tokens"].map(" ".join)

# 工程2 カテゴリ別件数
cat_order = df["category"].value_counts().index.tolist()

# 工程3 カテゴリ別の特徴語(第3章 TF-IDF)
#   カテゴリごとに全文を1つの文書へまとめ、5文書の間でTF-IDFを取る
docs = [" ".join(df.loc[df["category"] == c, "wakati"]) for c in cat_order]
vec = TfidfVectorizer(token_pattern=r"(?u)\S+")
X = vec.fit_transform(docs)
vocab = np.array(vec.get_feature_names_out())
feature_rows = {}
for i, c in enumerate(cat_order):
    row = X[i].toarray().ravel()
    idx = row.argsort()[::-1][:8]
    feature_rows[c] = [vocab[j] for j in idx]

# 工程4 感情比率(第5章 極性辞書+否定スコープ)
df["polarity"] = df["text"].map(lambda t: lex.score_negation(t)[0])
df["pred"] = df["polarity"].map(
    lambda s: "negative" if s < 0 else ("positive" if s > 0 else "neutral"))

# 工程5 上位トピック(第6章 LDA)
cv = CountVectorizer(token_pattern=r"(?u)\S+", min_df=5, max_df=0.5)
C = cv.fit_transform(df["wakati"])
lda = LatentDirichletAllocation(
    n_components=6, random_state=20260901, learning_method="batch", max_iter=30)
W = lda.fit_transform(C)
df["topic"] = W.argmax(axis=1)
df["topic_p"] = W.max(axis=1)

# 工程6 代表文書(トピックごとに所属確率の高い順)
for k in range(6):
    sub = df[df["topic"] == k].nlargest(2, "topic_p")

実行結果を順に見ていきます。前処理の段階で、1200件、平均52.2文字のテキストから内容語が1件あたり平均8.2個取れ、コーパス全体の語彙は178語になりました。内容語が1つも残らなかった文書は0件です。この語彙数の少なさは、後段のトピックモデルの安定性に直結します。実務のVoCでは語彙が数千から数万になるため、同じ設定をそのまま持ち込むことはできませんが、前処理の直後にこの数字を確認する習慣をつけておくと、辞書やストップワードの効きすぎに気づけます。

カテゴリ別の集計では、料金・請求が320件で全体の26.7%、配送・在庫が261件で21.8%、不具合・エラーが240件で20.0%、使い方・操作が211件で17.6%、対応・接客が168件で14.0%となりました。ネガ比率は順序が入れ替わり、不具合・エラーが80.4%で最も高く、料金・請求が72.2%、配送・在庫が66.3%、使い方・操作が65.9%、対応・接客が58.3%です。全体のネガ比率は69.5%でした。ここまでのネガ比率は、いずれも架空データに付いている正解ラベルから数えた値です。辞書の判定から数えた場合との差は、次の段落で扱います。件数の順位とネガ比率の順位が一致しないことが、量と濃さを並べて見る必要がある理由です。

カテゴリ別の件数とカテゴリ別のネガ比率を横に並べた棒グラフ

感情比率では、辞書ベースの判定結果と実際のラベルとの差が明確に出ました。辞書判定の内訳は否定35.8%、肯定33.0%、中立31.2%でした。中立と判定された374件は、スコアが0になり判定を保留したものです。この中立を除いた826件での一致率は0.861でした。ここで注目したいのは、カテゴリごとのずれの大きさです。実際のネガ比率が58.3%の対応・接客に対し、辞書が否定と判定できたのはわずか10.1%でした。「折り返しの連絡をもらえず、こちらから何度も電話しました」という文には、辞書に載るような否定的な語が1つも含まれていません。事実の記述として不満を述べる文は、極性辞書では拾えないのです。この結果は、感情分析の絶対値を経営報告に載せてはいけない理由をそのまま示しています。使えるのは、同じ手法を同じ条件で回したときの前月との差だけです。

トピック抽出では、1200文書と178語の行列に対してトピック数6のLDAを実行しました。当てはまりの指標であるパープレキシティは87.1でしたが、第6章で述べたとおりこの値単独では良し悪しを判断できないため、月次で同条件のまま推移を見る使い方に限定します。抽出されたトピックは、料金プランと請求内容に関するもの、配送の遅延と在庫に関するもの、見積もりと契約条件に関するもの、画面表示の不具合に関するもの、管理画面の設定に関するもの、そして肯定的な評価がまとまったものの6つです。最大のトピックは292件で全体の24.3%を占め、主カテゴリは料金・請求、そのトピック内でのネガ比率は92.8%でした。

ここで実務上重要な発見が2つあります。1つは、トピックが感情の軸と混ざることです。件数15.7%のトピックは主カテゴリこそ不具合・エラーですが、そのトピック内のネガ比率は19.1%しかありませんでした。上位語に「早い」「計画」といった語が並んでおり、複数カテゴリの肯定的な文が1つのトピックにまとまった結果です。トピックモデルは話題を分けますが、話題と感情を独立に分けてくれるわけではありません。もう1つは、件数の少ないカテゴリがトピックとして立たないことです。168件の対応・接客は、6つのトピックのどれについても主カテゴリになりませんでした。経営報告でトピック一覧だけを見せると、このカテゴリは存在しないことになってしまいます。カテゴリ別の集計とトピック分析を必ず併記すべき理由がここにあります。

チャネル別の内訳も残しておきます。ネガ件数をチャネルとカテゴリで交差させると、コールセンターでは料金・請求が61件、不具合・エラーが59件と多く、問い合わせフォームでは料金・請求が60件で突出する一方、配送・在庫は31件にとどまりました。配送・在庫について見ると、レビューが52件で、他のどのチャネルよりも多くなっています。同じ不満でも、顧客がどの窓口を選ぶかには偏りがあり、1つのチャネルだけを見ていると、そのチャネルに来やすい不満だけが全体像として報告されます。

チャネルとカテゴリを交差させたネガ件数のヒートマップ

最後に、これらを1枚の表にまとめます。行にカテゴリ、列に件数、構成比、ネガ比率、主なトピック、特徴語の上位3語を並べたものです。ここで、ネガ比率の列をどの判定から作っているかを断っておく必要があります。以下の表と、それを組み立てるコードのネガ比率は、サンプルVoCデータにあらかじめ付いているsentimentラベルから数えたものです。つまり、辞書がどれだけ取りこぼしたかを見るための、架空データだからこそできる検証用の集計です。実務の月次レポートには正解ラベルが存在しませんから、報告に載せる比率は辞書やモデルの判定結果、このコードでいえばpredの列から集計することになります。同じ集計をpredで回すと、全体のネガ比率は69.5%ではなく35.8%、カテゴリ別では料金・請求が30.9%、配送・在庫が41.0%、不具合・エラーが67.9%、使い方・操作が20.9%、対応・接客が10.1%になります。数字が別物になるので、どちらで数えた列なのかを表の脚注に必ず書きます。主なトピックの列には、そのカテゴリを主カテゴリとするトピックのうち最も件数の多いものを入れ、どのトピックの主カテゴリにもなっていないカテゴリには記号を置いています。対応・接客の行が記号のままであることが、先ほどの「件数の少ないカテゴリはトピックとして立たない」という所見の裏づけになります。スクリプトの末尾では、この表を組み立てて表示させたうえで、報告資料へ貼り付ける主要な列を、HTMLの行としても出力させました。集計から報告資料への転記は手作業になりやすく、転記の過程で数字がずれる事故が起きます。出力の形まで自動化しておくと、月次の運用が続きます。

カテゴリ件数構成比(%)ネガ比率(%):正解ラベル主なトピック特徴語(上位3)
料金・請求32026.772.2T1請求・料金・プラン
配送・在庫26121.866.3T4在庫・配送・商品
不具合・エラー24020.080.4T2表示・アプリ・エラー
使い方・操作21117.665.9T5画面・設定・管理
対応・接客16814.058.3電話・折り返し・問い合わせ
# 工程7 経営報告に載せる表を組み立て、HTMLの行として出力する
#
# 注意: ここで使っている sentiment は、架空データにあらかじめ付いている正解ラベル。
#       辞書がどれだけ取りこぼしたかを見るための検証用の集計であって、
#       実務では正解ラベルが無いので、工程4で作った pred の列で集計する。
#       その場合は下の is_neg を (df["pred"] == "negative") に差し替える。
neg_tbl = (
    df.assign(is_neg=(df["sentiment"] == "negative"))
      .groupby("category")
      .agg(件数=("doc_id", "count"), ネガ件数=("is_neg", "sum"))
      .reindex(cat_order)
)
neg_tbl["ネガ比率%"] = (neg_tbl["ネガ件数"] / neg_tbl["件数"] * 100).round(1)

report = neg_tbl[["件数", "ネガ比率%"]].copy()
report["構成比%"] = (report["件数"] / len(df) * 100).round(1)
report["特徴語(上位3)"] = ["・".join(feature_rows[c][:3]) for c in report.index]

for c in report.index:
    r = report.loc[c]
    print(f"<tr><td>{c}</td><td>{r['件数']}</td>"
          f"<td>{r['構成比%']}</td><td>{r['ネガ比率%']}</td>"
          f"<td>{r['特徴語(上位3)']}</td></tr>")

# 出力: <tr><td>料金・請求</td><td>320</td><td>26.7</td><td>72.2</td>…
# 出力: <tr><td>配送・在庫</td><td>261</td><td>21.8</td><td>66.3</td>…

前処理から報告用の表と図までを、スクリプト1本にまとめて再現できる形にしました。ここに掲載した抜粋では、図を描画する処理、トピックごとの代表文書を書き出す処理、そして表の「主なトピック」の列を組み立てる処理を省いていますが、完全版にはそれらも含まれています。ただし、来月同じ形式のデータで回したときに、比較できるものと比較できないものがあります。件数、構成比、ネガ比率は、同じ手続きで測っている限り月をまたいで比較できます。一方でトピックは比較できません。乱数の種は固定してありますが、固定されるのは同じ入力に対する結果だけです。来月は入力するデータそのものが変わるため、推定されるトピックの中身も番号も動きます。先月のT1と今月のT1が同じ話題である保証はありません。

第6章では、同じコーパスのまま乱数の種だけを変えても、LDAの割り振りが元の結果とほとんど一致しないことを実測しました。入力が変わればなおさらです。したがって、トピックを作り直したときは、それが先月と地続きの地図ではなく新しい地図であることを、報告のなかではっきり書きます。トピックの入れ替わりを施策の効果として読ませないための最低限の注記です。月次レポートで最も価値があるのは、単月の絶対値ではなく、同じ物差しで測り続けた差分です。その物差しが月ごとに引き直されていないかを、数字を並べる前に確認します。

5つのユースケースに共通する成功要因

ここまでの5本を並べて見ると、使う手法も出す成果物も違うのに、うまくいくかどうかを分けている条件はよく似ています。最後に、共通する要因を4つ挙げます。

1つ目は、分析の単位を業務の単位に合わせることです。コールセンターログでは通話が単位であって発話ではなく、ECレビューでは文が単位であってレビュー全体ではありませんでした。VoCではカテゴリが単位ですが、そのカテゴリは施策を打つ部署の担当範囲と一致していなければ意味を持ちません。分析の単位が業務の単位とずれていると、どれほど精度が高くても、受け取った側が何をすればよいか分からない報告になります。設計の最初にこの単位を決め、関係部署と合意しておくことが、後の手戻りを最も減らします。

2つ目は、数値と原文を必ずセットで出すことです。5本すべてで、アウトプットに代表文書や原文の抜粋を含めました。数字だけの報告は、精度への疑問がそのまま結論への疑問になり、議論が「その分析は本当に正しいのか」に流れます。原文が1つ添えられていれば、読み手は自分で妥当性を判断でき、議論は「では何をするか」に進みます。テキスト分析の成果物は、集計値そのものではなく、集計値と原文を往復できる状態だと考えています。

3つ目は、絶対値ではなく差分で語ることです。本章の実測では、極性辞書による否定判定が実際のネガ比率を大きく下回り、カテゴリによっては58.3%に対して10.1%という差が出ました。この水準を「不満は10%」と報告すれば誤りですが、同じ辞書で毎月測り続けた値が、仮に10%から16%へ動いたのであれば、その差には意味があります。手法の限界を前提にしたうえで、条件をそろえて測り続ける設計にすれば、完全でない手法も業務指標として使えます。

4つ目は、前処理と整備に工数の大半を配分することです。コールセンターログの発話結合と誤認識の吸収、社内文書の本文抽出とメタデータ付与、アンケートの無効回答の除外は、いずれも分析手法ではなく地道な整備です。5本を通じて、成果を分けたのは手法の新しさではなく、この整備がどこまで行き届いているかでした。手法は第2章から第9章までのもので足りており、選択の余地はそれほど大きくありません。投資すべきは、データを分析可能な状態に保ち続ける仕組みのほうです。

本章では、5つのユースケースを同じ枠組みで整理し、それぞれの手法の組み合わせを工程と参照章の対応表として示しました。あわせて、VoC分析の月次レポートを1本のスクリプトにまとめ、前処理から報告用の表と図までを実際に出力させています。実測からは、件数の順位とネガ比率の順位が一致しないこと、極性辞書が事実として述べられた不満を取りこぼすこと、件数の少ないカテゴリがトピックとして立たないことが確認できました。次の終章では、これらを踏まえて、導入の順番と社内での役割分担についての考えをまとめます。

この章を深めたい方への参考書籍

『ファンをつくる顧客体験の科学 「顧客ロイヤルティ」丸わかり読本』(渡部弘毅、リックテレコム):本章のVoC分析で扱った、顧客の声を集計値に変えて打ち手につなげる流れを、顧客ロイヤルティを定量化するという業務側の視点から確認できます。ロイヤルティの定義から構造化、定量化、分析、考察までの手順が事例つきで整理されているため、カテゴリ設計や指標の置き方を社内で議論する際の土台になります。

『図解でわかる コンタクトセンターの作り方・運用の仕方』(有山裕孝、仲江洋美、市瀬眞、日本実業出版社):本章のコールセンターログ分析で前提とした、通話単位の考え方や応対品質、指標の設計を、センター運営そのものの側から押さえたい読者に向いています。オペレーションの組み立てとマネジメントが図で説明されているため、分析結果を誰にどう渡せば運用が動くのかを具体的に検討できます。

『知識創造企業(新装版)』(野中郁次郎、竹内弘高、梅本勝博 訳、東洋経済新報社):本章の社内ナレッジ活用で触れた、暗黙知を文書として形式化することの意味を理論から確認できます。検索や生成の仕組みを入れる前に、そもそも何を書き残し、どう共有すれば組織の知識になるのかを整理したい読者に向いています。

終章 小さく検証し、辞書と学習データを資産化する

本編11章で、テキストを数字に変える手法と、それを業務に組み込むための設計を見てきました。終章では、これから自社でテキスト活用を始める、あるいは仕切り直す場合の進め方について、本コラム全体を踏まえた提言をまとめます。

第一に、最初の一歩は小さく、ただし出口まで通すことを勧めます。最初の案件で理想の全体像を作ろうとすると、データ基盤の整備やツール選定に時間を使い切り、業務に届く前に熱が冷めます。逆に、対象を1つのデータ類型(たとえば問い合わせログ3か月分)に絞り、前処理から集計、報告までを一度通してしまえば、どこに手間がかかり、どんな結果が出て、誰がそれを使うのかが具体的に分かります。第3章の頻度分析と第4章の共起分析だけでも、初回の報告資料としては十分に成立します。高度な手法は、単純な手法で答えられない問いが実際に現れてから足せばよい、というのが本コラムを通しての立場です。

第二に、途中で作られるものを使い捨てにしないことです。テキスト分析のプロジェクトでは、成果物として目立つのは報告資料やダッシュボードですが、長期的に価値を持つのはむしろ途中で作られる地味な資産のほうです。自社の商品名や業界用語を登録したユーザー辞書。人手で付けたラベル付きの学習データ。判断に迷った事例と裁定を書き残した境界事例集。評価に使うテストセット。これらは一度作れば次の案件でも、手法を入れ替えても、担当者が替わっても使い回せます。第10章で述べたとおり、モデルは劣化し、手法は入れ替わりますが、整備された辞書とラベル付きデータは陳腐化しにくい。テキスト活用の実力は、モデルの新しさではなく、この蓄積の厚さに現れると考えています。

第三に、生成AIの時代においても、測る仕組みを自社で持つことです。大規模言語モデルの登場で、分類も要約も抽出も、試すだけならプロンプト1つでできるようになりました。しかし第9章と第10章で繰り返し述べたとおり、その出力が業務に使える水準かどうかを判定するのは、依然として自社のテストセットと評価設計です。評価の物差しを持たないままモデルの出力を信じることは、検品なしで納品を受け入れることと変わりません。逆に物差しさえあれば、従来手法とLLMのどちらが適するか、新しいモデルに乗り換えるべきかを、その都度事実で決められます。使い分けの主導権は、評価の仕組みを持つ側にあります。

最後に、内製と外部支援の分担について触れます。業務の言葉とラベルの定義を決める仕事、つまり「何を知りたいか」「どの区分で仕分けるか」「どこまでを正解とするか」の裁定は、業務を知る自社側にしか担えません。一方で、手法の選定、実装、評価設計の型は、経験のある外部の支援を受けることで立ち上がりが大きく速くなる部分です。すべてを外注すると資産が社内に残らず、すべてを内製しようとすると最初の壁で止まる。境界事例の裁定と辞書の中身は必ず社内に残す、という線引きを最初に決めておくことを勧めます。

テキストは、顧客と現場が自分の言葉で書き残してくれた、加工前の一次情報です。それを数えられる形にする技術は、本コラムで見てきたとおり、すでに十分に実用の水準にあります。あとは、問い合わせログなど対象を1つ選び、その最初の3か月分を決めて、前処理から報告まで通してみるところからになります。

Anagraftでは、テキストデータの活用構想の整理から、分析の実装、業務への組み込みまで一貫したご支援を行っています。会社概要・ご支援内容の詳細は、以下の資料からご覧いただけます。

Anagraft 会社概要資料(ダウンロードページへ)

本コラムで紹介した書籍一覧

各章末で紹介した33冊を、章の順にまとめて再掲します。詳しい紹介文は各章末をご覧ください。

第1章 テキストマイニングの全体像と業務適用の型

『顧客と知識を見える化する テキストマイニング概論』(石井哲、東洋経済新報社):顧客の声を経営に生かすという課題設定から通しで学べる概論です。

『動かして学ぶ! はじめてのテキストマイニング』(樋口耕一・中村康則・周景龍、ナカニシヤ出版):手を動かして分析の全体の流れをつかむ入門書です。

『新テキストマイニング入門』(喜田昌樹、白桃書房):テキストマイニングの位置づけを研究と実務の両面から整理した1冊です。

第2章 日本語テキストの前処理と形態素解析

『形態素解析の理論と実装』(工藤拓、近代科学社):MeCab開発者による、形態素解析の仕組みを深く知るための定番書です。

『Pythonによるテキストマイニング入門』(山内長承、オーム社):前処理から基本の分析までをPythonで一通り追える入門書です。

『機械学習・深層学習による自然言語処理入門』(中山光樹、マイナビ出版):前処理と機械学習の橋渡しを実装ベースで学べます。

第3章 頻度分析とキーワード抽出

『情報検索と言語処理』(徳永健伸、東京大学出版会):TF-IDFをはじめとする重み付けの背景理論を押さえられます。

『Pythonではじめる 情報検索プログラミング』(佐藤進也、森北出版):検索と重み付けの考え方を小さな実装で確かめられます。

『Rによるやさしいテキストマイニング』(小林雄一郎、オーム社):頻度分析を中心とした分析の型をR環境で学べます。

第4章 共起分析とネットワーク

『Pythonで学ぶネットワーク分析 ColaboratoryとNetworkXを使った実践入門』(村田剛志、オーム社):本章で使ったNetworkXを体系的に学べる実践書です。

『複雑ネットワーク:基礎から応用まで』(増田直紀・今野紀雄、近代科学社):中心性などネットワーク指標の数理的な背景を補強できます。

『テキストアナリティクス』(金明哲、共立出版):共起を含むテキスト分析手法を統計の視点から整理した1冊です。

第5章 感情分析・評判分析

『意見分析エンジン 計算言語学と社会学の接点』(大塚裕子・乾孝司・奥村学、コロナ社):評判分析という分野の成り立ちと技術を知るための1冊です。

『テキストマイニングの基礎技術と応用』(那須川哲哉ほか、岩波書店):感情分析を含む要素技術を応用事例とともに概観できます。

『言語処理のための機械学習入門』(高村大也 著、奥村学 監修、コロナ社):分類器の中身を数式から理解したい読者向けの定番書です。

第6章 トピックモデルで大量文書を俯瞰する

『トピックモデル』(岩田具治、講談社):LDAの数理を段階を追って学べる機械学習プロフェッショナルシリーズの1冊です。

『トピックモデルによる統計的潜在意味解析』(佐藤一誠、コロナ社):推定アルゴリズムまで踏み込んだ専門書です。

『Pythonではじめるテキストアナリティクス入門』(榊剛史 編著、講談社):トピックモデルを含む分析の実装を通しで確認できます。

第7章 文書分類で仕分けを自動化する

『IT Text 自然言語処理の基礎』(岡崎直観ほか、オーム社):分類を含む自然言語処理の全体像を現代的な視点で学べる教科書です。

『評価指標入門〜データサイエンスとビジネスをつなぐ架け橋』(高柳慎一・長田怜士、技術評論社):精度・再現率などの指標をビジネスの言葉に翻訳するための1冊です。

『Pythonではじめる機械学習』(Andreas C. Müller・Sarah Guido 著、中田秀基 訳、オライリー・ジャパン):scikit-learnによる分類の実務的な使い方を学べる定番書です。

第8章 文書ベクトルと類似検索

『ゼロから作るDeep Learning ❷ 自然言語処理編』(斎藤康毅、オライリー・ジャパン):word2vecの仕組みを実装しながら理解できます。

『BERTによる自然言語処理入門:Transformersを使った実践プログラミング』(ストックマーク株式会社 編、オーム社):日本語BERTの実践的な使い方を学べます。

『情報検索:検索エンジンの実装と評価』(Stefan Büttcher ほか 著、森北出版):検索システムの実装と評価を本格的に学ぶための専門書です。

第9章 LLM時代のテキストマイニング

『大規模言語モデル入門』(山田育矢 監修・著、技術評論社):LLMの仕組みから日本語での活用までを体系的に学べます。

『LangChainとLangGraphによるRAG・AIエージェント[実践]入門』(西見公宏・吉田真吾・大嶋勇樹 著、技術評論社):RAGの実装を実際のコードで学べる実践書です。

『LLMのプロンプトエンジニアリング』(John Berryman・Albert Ziegler 著、オライリー・ジャパン):プロンプト設計を場当たりでなく設計として扱うための1冊です。

第10章 実務プロジェクトの進め方

『仕事ではじめる機械学習 第2版』(有賀康顕・中山心太・西林孝、オライリー・ジャパン):機械学習を業務に載せるときの設計と落とし穴を扱う実務書です。

『Human-in-the-Loop機械学習:人間参加型AIのための能動学習とアノテーション』(Robert(Munro)Monarch 著、共立出版):アノテーションと能動学習を正面から扱った希少な1冊です。

『機械学習システムデザイン』(Chip Huyen 著、オライリー・ジャパン):運用まで含めたシステム設計の考え方を学べます。

第11章 ユースケース詳説

『ファンをつくる顧客体験の科学 「顧客ロイヤルティ」丸わかり読本』(渡部弘毅、リックテレコム):VoC分析の成果をつなぐ先である顧客ロイヤルティの考え方を学べます。

『図解でわかる コンタクトセンターの作り方・運用の仕方』(有山裕孝・仲江洋美・市瀬眞、日本実業出版社):コールセンター業務の全体像を押さえるための実務書です。

『知識創造企業(新装版)』(野中郁次郎・竹内弘高 著、梅本勝博 訳、東洋経済新報社):社内ナレッジ活用の土台となる知識経営の古典です。

参考情報

本コラムで紹介・参照したライブラリ・言語資源の公式ドキュメントです(実行環境の都合で本コラムが実際には動かしていないものも、手法の紹介として取り上げたものは含めています。いずれも2026年9月時点でアクセスを確認しています)。