はじめに
LDAによるトピック分析は確かに便利である。しかし実務では、次のような不満を抱くことが多い。
- 製品の改善を続けてきたけど、この間で不満に関するトピックは廃れたり、新たなトピックが出てきているのか確認したい
- 評価スコアが低いレビューほど、どのトピックを語りがちなのかを、一発で知りたい
- 部署ごと・属性ごとに、同じトピックでも使う言葉が違う気がする。これを定量化できないか
LDAはこれらに直接答えてくれない。トピックを出したあと、自分でクロス集計してグラフを描いて……と地道に頑張ることになる。しかもそのやり方では「メタデータがトピックに与える効果」を統計的に検定できない。また、属性ごとにLDAを適用して、それぞれのモデルの各トピックを比較し、同じトピックを推測で繋ぐことも大変である。
これを最初からモデルに組み込んだのが Structural Topic Model(STM) である。文書のメタデータ(日付、評価、属性、部署……)を共変量としてモデルに入れ、「メタデータがトピックをどう動かすか」を推定する。政治学を中心に、社会科学のテキスト分析で使われてきた手法である。
ただ、STMの実装はRの stm パッケージが中心で、Pythonには手軽に使えるものが見当たらなかった。私はふだんPythonでデータをさわっているのでPythonで動かしたく、scikit-learn風のAPIで使えるSTMの実装を、Claude Codeと一緒に作ってみた。
GitHub: https://github.com/hirata-keisuke/pystm
pip install structural-topic-model
本記事では、STMが何を可能にするのかをLDAとの違いから説明し、実際に国会会議録のデータへ適用して「原発・エネルギー政策の議論がこの数年でどう変わったか」を可視化するところまでを扱う。
STMはLDAの何が違うのか
一言でいえば、メタデータ(共変量)をモデルに入れられる。これに尽きる。STMでは2種類の共変量を定義できる。
prevalence共変量:どのトピックが「語られやすい」か
これは「どのトピックが出現しやすいか」に効く変数を表している。
大学生のポストを例にとると、1年生はサークルのトピックを出すことが多いが、2年生・3年生と徐々にインターンや就活のトピックを語りがちになるというように、属性によって、そもそも出てくるトピックの比率が違う。これをモデルに教えるのがprevalence共変量である。
他にも「投稿日(時系列トレンド)」「評価スコア」のような、トピックの違いを確認したいタグを入れればいい。
content共変量:同じトピックでも「使う言葉」がどう違うか
こちらは単語に関わる共変量で、同じトピックを語っていても、グループによって使う単語が違う、その違いを可視化する。
飲み会というトピックに大学生は「宅飲み」「安い」「渋谷」という語を使って語るが、社会人は「上司」「愚痴」「新橋」という語を使う傾向にあるかもしれない。
ここでcontent共変量を使わないと、モデルは「大学生の飲み会トピック」と「社会人の飲み会トピック」を別物の2トピックに割ってしまうことがある。content共変量で「ここに2つのグループがある」と教えると、両者を飲み会トピックにまとめたうえで、使う単語の差だけを抽出してくれる。これがcontent共変量の旨味である。
ついでに:共変量がないとどうなるか
pystmでは、共変量を一切指定しないとSTMは自動的に CTM(Correlated Topic Model) に縮約される。これはLDAと違ってトピック間の相関を許すモデルであり、「政治トピックと経済トピックは一緒に出やすい」といった関係を表現できる。
使い方:scikit-learn風で動く
APIはsklearnに寄せてある。.fit() して .theta_(文書ごとのトピック比率)を見る、いつもの流れである。
from stm import StructuralTopicModel
model = StructuralTopicModel(n_components=10, init="spectral", random_state=0)
model.fit(X, prevalence=covariates) # X は文書-語数行列(CountVectorizerの出力でOK)
print(model.theta_.shape) # (文書数, トピック数)
X はscikit-learnの CountVectorizer が吐く疎行列をそのまま渡せる。既存の前処理パイプラインに差し込めるのが狙いである。
実践:国会会議録で「原発議論の移り変わり」を可視化する
抽象論はここまで。以降は実データで試す。題材は国会会議録である。国立国会図書館が検索用APIを公開しており、発言テキスト・発言者・会派・日付がまとめて取れる。STMのための共変量が最初から揃っている、格好の練習台である。
ここでは「原子力・エネルギー」に関する近年(2022〜2025年)の発言、約1,400件を対象に、
- prevalence共変量 = 発言年(トピック比率が年でどう動くか)
- content共変量 = 会派(会派で語彙がどう変わるか)
を入れて分析する。
データ取得
APIを叩いて発言を集める。マナーとして、リクエスト間隔は数秒空ける。
import time, json, urllib.parse, urllib.request
import pandas as pd
BASE = "https://kokkai.ndl.go.jp/api/speech"
def fetch_speeches(keyword, date_from, date_until, max_total=2000, sleep=3.0):
records, start = [], 1
while len(records) < max_total:
params = {
"any": keyword, "from": date_from, "until": date_until,
"maximumRecords": "100", "startRecord": str(start),
"recordPacking": "json",
}
url = BASE + "?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(url, timeout=60) as r:
data = json.load(r)
recs = data.get("speechRecord", [])
if not recs:
break
records.extend(recs)
nxt = data.get("nextRecordPosition")
if not nxt:
break
start = nxt
time.sleep(sleep)
rows = [{
"speech": sp.get("speech", ""),
"group": sp.get("speakerGroup", ""), # 会派
"date": sp.get("date", ""),
} for sp in records[:max_total]]
return pd.DataFrame(rows)
df = fetch_speeches("原子力 エネルギー", "2022-01-01", "2025-12-31", max_total=2000)
前処理:形態素解析 → 文書-語数行列
日本語なので形態素解析が要る。fugashi で内容語(名詞・動詞・形容詞)を原形で抜き、CountVectorizer で行列化する。
from fugashi import Tagger
from sklearn.feature_extraction.text import CountVectorizer
tagger = Tagger()
KEEP_POS = {"名詞", "動詞", "形容詞"}
STOP = set("こと もの これ それ 私 思う 質問 答弁 委員 大臣 政府 国民".split())
def tokenize(text):
toks = []
for w in tagger(str(text)):
if w.feature.pos1 in KEEP_POS:
lemma = w.feature.lemma or w.surface
if len(lemma) >= 2 and lemma not in STOP:
toks.append(lemma)
return toks
docs = [" ".join(tokenize(t)) for t in df["speech"]]
vec = CountVectorizer(token_pattern=r"(?u)\b\w+\b", min_df=20, max_df=0.5)
X = vec.fit_transform(docs)
min_df(何文書以上に出現する語を残すか)を下げすぎると、語彙が数万語に膨れてSTMが終わらなくなる。今回は文書約1,400件・min_df=20 で、語彙は約2,100語に落ち着いた。文書1,000〜2,000件なら、語彙2,000〜5,000語程度が速度と安定性のバランスが良い。ちなみにRの stm 公式チュートリアルでも、データに応じて語彙数千〜1万語弱で回しているので、このあたりが相場である。
STMを回す
prevalence に年(標準化)、content に会派を渡す。
import numpy as np
from stm import StructuralTopicModel, estimate_effect
year = pd.to_datetime(df["date"]).dt.year.to_numpy()
year_std = ((year - year.mean()) / year.std()).reshape(-1, 1)
groups = df["group"].astype(str).to_numpy()
model = StructuralTopicModel(
n_components=7, init="spectral",
max_iter=100, tol=1e-4,
max_vocab=3000,
content_interactions=False,
verbose=1,
random_state=0,
)
model.fit(X, prevalence=year_std, content=groups)
重くて収束しないときは:まず
verbose=1で進捗を見るとよい。ELBOが横ばいになっていれば実質収束である。それでも遅ければ、min_dfを上げて語彙を削る →max_vocabを下げる →contentを一旦外す、の順で効く。
トピックのラベルは FREX(頻度と排他性のバランスで語を選ぶ指標)で確認する。単純な頻度順より、そのトピックらしい語が出る。
vocab = np.array(vec.get_feature_names_out())
frex = model.top_words(n_words=8, kind="frex")
for k in range(7):
print(f"Topic {k}:", " ".join(vocab[frex[k]]))
実際に出力された FREX 上位語から、トピックは次のように分かれた。
| Topic | FREX上位語 | 付けたラベル |
|---|---|---|
| 0 | 本当 遣る 起きる 聞く 今日 フランス france 仰る | 質疑・国際比較 |
| 1 | 化石 カーボン carbon エネ 導入 ニュートラル neutral 火力 | 脱炭素・カーボンニュートラル |
| 2 | 官房 提出 二百 関する 法律 異議 聴取 調査 | 法案・国会手続 |
| 3 | 循環 担当 保全 余り 環境 区域 貢献 処理 | 環境保全・資源循環 |
| 4 | 物価 賃金 所得 賃上げ 尋ねる 保険 防衛 財源 | 物価・賃金・財政 |
| 5 | 運転 期間 規制 利用 延長 反省 審査 事故 | 原発の運転期間・規制 |
| 6 | 教育 研究 学校 文化 大学 充実 学び sport | 教育・研究 |
「原発の運転期間・規制」(運転期間延長と原子力規制委員会の審査)、「脱炭素・カーボンニュートラル」(化石燃料・火力からの転換)といった、原子力・エネルギー政策の核がきれいに分離できている。一方で「物価・賃金・財政」「教育・研究」のように、エネルギー予算が他分野と一緒に語られる文脈もトピックとして立ち上がっている。FREXは単純な頻度順より、こうした「そのトピックらしい語」を拾ってくれるので、ラベル付けがやりやすい。
年でトピックがどう動いたか
ここからがSTMの本領である。estimate_effect で「年がトピック比率に与える効果」を、不確実性込みで推定する。
eff = estimate_effect(model, year_std, uncertainty="Global", nsims=25)
tables = eff.summary()
for k in range(7):
t = tables[k] # index 1 が year の係数
print(f"Topic {k}: year coef={t['estimate'][1]:+.4f} p={t['p_value'][1]:.3f}")
文書ごとのトピック比率(model.theta_)を年で平均してプロットすると、次のようになる。
注目すべきは 「原発の運転期間・規制」(茶)と「質疑・国際比較」(青)のクロスである。運転期間延長や規制委員会の審査をめぐる議論は2023年にピーク(平均比率0.39)を打ったあと2024年に急落し、入れ替わるように「質疑・国際比較」が年を追って増えていく。estimate_effect の係数でも、
- 原発の運転期間・規制:year係数 −0.061(p < 0.001) → 有意に減少
- 質疑・国際比較:year係数 +0.050(p < 0.001) → 有意に増加
LDAでもトピックは出せるが、「年がトピック比率に与える効果を、有意性つきで言える」のがSTMの強みである。微妙な変化の結果も、係数とp値があるからこそ「ノイズではなく有意な傾向だ」と判断して、その原因の仮説を考えることに移れる。
会派でトピックはどう違うか
発言数の多い主要6会派について、トピック比率を平均したのが次の図である(少数会派はサンプルが少なく不安定なので除いている)。
この図では会派差があまりくっきり出ていない。どの会派も「原発の運転期間・規制」と「脱炭素」が二大トピックで、全体の形がよく似ている。わずかな差としては、日本共産党で「原発の運転期間・規制」が突出して高い(約0.36)、自由民主党や公明党で「法案・国会手続」がやや高い、といった傾向は読み取れるが、勢いのある差とは言いがたい。
STMは「年がトピック比率にどう効くか」「会派がトピック比率にどう効くか」を同時に推定しているので、考えられる理由のひとつは共変量同士の交絡である。
仮に「2023年は自民党の発言が多く、2025年は野党の発言が多い」といった偏りがあったとすると、次の2つが見分けられない。
- 2023年に原発規制トピックが多い
- 自民党が原発規制トピックを語りがち
トピックに対して年の効果なのか会派の効果なのかデータで見分けられず、片方の効果がもう片方に吸われて見える。
会派差をきちんと見たいなら、年でサブセットを揃えて(例:2023年だけに絞って)会派比較する、content 共変量に会派を入れて「同じトピック内での語彙の違い」として見る、といった工夫が要る。
トピック数 K はどう決める?
「では、トピックをいくつにすればよいのか」という当然の疑問には search_k がある。複数のKで回して、Held-Out Likelihood(高いほど良い)やsemantic coherence、残差を比較できる。
from stm import search_k
res = search_k(X, K_values=[5, 8, 10, 15], prevalence=year_std)
# res["heldout"], res["semcoh"], res["exclus"], res["residual"] を比較
Held-Out Likelihoodが頭打ちになるあたりで、かつ解釈しやすいKを選ぶ、というのが実務的な落とし所である。今回の分析で K=7 にしたのも、これより少ないと「原発規制」と「脱炭素」が混ざってしまい、多すぎると似たトピックが分裂して解釈しづらくなる、というバランスからである。なお n_components=0 を渡すと、トピック数を自動推定するモードも使える。
おわりに
自由記述のテキストを、とりあえずLLMに突っ込めば、きちんと要約や分類は返ってくる。手軽だし、実際に便利でもある。ただ、「いつから増えたのか」「どの属性が語りがちなのか」を、再現性のある数字と有意性つきで言いたい場面では、形態素解析して、共変量を設計して、係数とp値を眺める。結果から仮説を立てることもあれば、結果そのものが答えを与えてくれることもあるだろう。STMはそういう作業にうまくはまる手法だが、Pythonからは少し使いにくかった。その不便を自分で解消したくて、Claude Codeと一緒に機能のテストや再現をしつつ、移植したのが本ライブラリである。
sklearnの CountVectorizer の出力をそのまま渡し、.fit() して estimate_effect するだけ。既存のPythonワークフローに無理なく差し込めるはずである。Rで stm を使っていた人や、LDAで止まっていた人が試すきっかけになれば嬉しい。
バグ報告・機能要望など大歓迎である。
GitHub: https://github.com/hirata-keisuke/pystm
出典: 国会会議録検索システム(国立国会図書館)
参考
- Roberts, M., Stewart, B., Tingley, D., et al. "A model of text for experimentation in the social sciences." JASA 111.515 (2016).
- Rの
stmパッケージ: https://www.structuraltopicmodel.com/

