はじめに
本記事では、日本語テキストを対象に LDA(Latent Dirichlet Allocation) を用いて文書に潜む話題構造(トピック)を自動抽出し、サッカー戦術に関する国別文書から 「各国の戦術的特徴」 を定量的に分析します。
さらに、抽出したトピックをもとに 文書ごとのトピック分布(θ) と トピックごとの単語分布(ϕ) を可視化し、国別の戦術傾向を比較するまでの一連の流れをまとめます。
題材として、2026年ワールドカップのグループF(日本・オランダ・チュニジア)を想定し、各国のサッカー戦術を説明する短文データ(各10文、計30文書)を用意しました。
Janome で形態素解析を行い、助詞・動詞など分析ノイズになりやすい要素や国名などのストップワードを除外して、戦術的な特徴を表しやすい語(主に名詞)に絞って扱います。
トピックの抽出には、gensim ライブラリのLDA実装を使用し、各文書が「どのトピックをどれだけ含むか(θ)」と「各トピックでどの単語が重要か(ϕ)」を同時に推定します。
最後に、国別の平均トピック分布を 棒グラフで可視化 し、さらに Coherenceスコア を用いて最適なトピック数を探索することで、より解釈しやすい分析結果を得られるようにします。
この記事でわかること
Janomeを使った日本語テキストの前処理
ストップワード除去
品詞フィルタリング(名詞のみ抽出)
簡易複合語処理(「組織性」「ポジショナルプレー」など)
gensimでLDAモデルを学習する方法
辞書(Dictionary)の作成
Bag-of-Words(BoW)コーパスの作成
LDAモデルのパラメータ設定(α、β、トピック数)
トピックごとの単語分布(ϕ:ファイ)の解釈
各トピックの上位単語を確認
トピックに意味のあるラベルを付ける方法
「守備トピック」「攻撃トピック」などの識別
文書ごとのトピック分布(θ:シータ)の取得
各文書がどのトピックをどれだけ含むか
国別の平均トピック分布の計算
「日本は守備中心」「オランダはライン間支配」などの定量化
Coherenceスコアで最適なトピック数を探索する方法
トピック数(k)を変えて評価
c_vメトリックによる一貫性の測定
最適なトピック数の推奨
可視化結果の読み方
棒グラフで国別のトピック構成を比較
トピック分布から戦術的特徴を解釈
国ごとの戦術プロファイルの理解
本記事の前提となる、トピックモデル(LDA)の基本的な考え方を解説した記事です →
トピックモデル(LDA)の仕組みと考え方
実行環境
Python 3.8以上
janome 0.4.2
gensim 4.3.0
numpy 1.24.0
matplotlib 3.7.0
japanize-matplotlib 1.1.3
Step 1: データ準備と前処理
- サッカー戦術文書の準備
- Janomeによる形態素解析
- ストップワード除去と品詞フィルタリング
- 複合語の処理
# 標準ライブラリ
import re
# 形態素解析
from janome.tokenizer import Tokenizer
# サッカー戦術文書(各国10文書ずつ、計30文書)
soccer_documents = [
# 日本(守備重視・組織的連動)
"日本はゾーン守備とスライドを徹底し、連動性と規律でライン間の間延びを防ぐ。ハーフスペースを消し、外誘導から回収を狙う。",
"日本は連動プレスでパスコースを制限し、囲い込みで奪回する。奪った後はサイドチェンジで相手のスライドを逆手に取る。",
"日本はビルドアップで三角形を作り、3人目の受け手で前進する。ハーフスペースの受け手と逆サイドの幅取りを同時に行う。",
"日本は中盤の規律を保ち、ゾーンの間に侵入されない配置を優先する。セカンドボール回収で攻守の安定を作る。",
"日本は前線の連動でプレッシングトリガーを共有し、限定した方向へ誘導して奪回する。中央閉鎖と外回収が基本。",
"日本はサイドで数的優位を作り、ワンツーと3人目で崩す。クロスよりもカットバックを重視してゴール前を攻略する。",
"日本は最終ラインのラインコントロールで背後を管理し、ゾーンの間隔を一定に保つ。スライドとカバーで中央を固める。",
"日本は守備からの切り替えで即座に前進よりも保持を選び、再配置して攻撃を組み立てる。規律と連動を崩さない。",
"日本はハーフスペースの受け手を増やし、三角形のパスワークで前進する。外の幅と内側の受けを両立する。",
"日本は連動性で局面の数的不利を補い、規律でブロックを維持する。奪回後は素早いサイドチェンジで形勢を変える。",
# オランダ(ポジショナルプレー・ライン間支配)
"オランダはポジショナルプレーで5レーンを使い、ライン間の占有を最優先する。立ち位置で相手の守備を固定し、前進を作る。",
"オランダはローテーションで配置を崩し、ハーフスペースの受け手と外幅のウイングで相手のスライドを引き裂く。",
"オランダは偽9番で中盤を厚くし、第三者の動きで背後を取る。中央の数的優位からスルーパスを狙う。",
"オランダは即時奪回(カウンタープレス)でボールロスト直後に回収する。高い最終ラインと前向き守備が特徴。",
"オランダはビルドアップで3-2の基盤を作り、レーン固定と角度でパスコースを連続的に生成する。",
"オランダはライン間の受け手を常に確保し、相手のブロックに対して外→内の順で侵入する。5レーンの維持が鍵。",
"オランダは幅と深さを同時に確保し、ボールサイドに寄せた相手を逆手に取る。逆サイドの孤立を作り突破する。",
"オランダはポジショナルの原則で立ち位置を厳密に管理し、ローテーションは秩序ある交代で行う。配置の再現性が高い。",
"オランダは前線の圧力で相手のビルドアップを窒息させ、即時奪回で二次攻撃を連発する。押し込む時間が長い。",
"オランダはライン間の支配と即時奪回を両立し、5レーンの配置からテンポ変化で崩す。偽9番とハーフスペースが軸。",
# チュニジア(ローブロック・速攻)
"チュニジアはローブロックで密集を作り、中央封鎖を徹底する。ブロック外では撤退優先で背後を渡さない。",
"チュニジアはミドルブロックからデュエルで止め、奪取後は縦への速攻を狙う。ロングボールとセカンド回収が重要。",
"チュニジアはサイドに追い込み、タッチラインを味方にして回収する。中央は人数で閉鎖し、危険地帯を消す。",
"チュニジアはターゲットへのロングボールで前進し、落としからセカンドボールを回収する。二次攻撃で押し上げる。",
"チュニジアは撤退の速さでブロックを整え、スペース管理を優先する。攻撃は少人数の速攻で完結させる。",
"チュニジアはセットプレーを重視し、CKやFKで得点機会を作る。流れの攻撃よりも再開局面の強度が高い。",
"チュニジアは密集守備で中央を消し、外側のクロスに対応する。空中戦とクリアで耐え、回収から速攻へ出る。",
"チュニジアは守備のブロック間隔を狭く保ち、ライン間を空けない。デュエルと身体能力で前進を止める。",
"チュニジアは攻撃の形がシンプルで、縦パスとドリブルで前進する。奪った瞬間の縦速度を最優先する。",
"チュニジアはローブロック+速攻で試合を制御する。密集、撤退、ロングボール、セカンド回収、セットプレーが柱。"
]
# 各文書のラベル(国名)
document_labels = (["日本"] * 10) + (["オランダ"] * 10) + (["チュニジア"] * 10)
# 分析対象の国
countries = ["日本", "オランダ", "チュニジア"]
# ========================================
# 前処理の設定
# ========================================
# ストップワード(分析から除外する単語)
# 国名や一般的すぎる単語を除外
stop_words = {
"日本", "オランダ", "チュニジア", # 国名
"サッカー", "戦術", "スタイル", # 一般的な単語
"選手", "ピッチ", "特徴", "ボール",
"これら", "もと", "もの", "ごと", "こと", "この"
}
# 除外する品詞
# 名詞のみを抽出するため、それ以外を除外
exclude_pos = {
"動詞", "形容詞", "助詞", "助動詞",
"記号", "補助記号", "空白"
}
# ========================================
# 形態素解析と前処理
# ========================================
# Janomeトークナイザーの初期化
tokenizer = Tokenizer()
def extract_words(text):
"""
テキストから名詞を抽出し、前処理を行う
処理内容:
1. 形態素解析で単語に分割
2. 品詞フィルタリング(名詞のみ)
3. ストップワード除去
4. 数字のみの単語を除外
5. 複合語の処理
"""
raw = []
# 形態素解析
for token in tokenizer.tokenize(text):
# 品詞情報を取得
pos = token.part_of_speech.split(",")[0]
# 基本形を取得
word = token.base_form.strip()
# 空文字や無効な単語をスキップ
if not word or word == "*":
continue
# 除外品詞をスキップ
if pos in exclude_pos:
continue
# ストップワードをスキップ
if word in stop_words:
continue
# 数字のみをスキップ
if re.fullmatch(r"[0-90-9]+", word):
continue
# 有効な文字(ひらがな、カタカナ、漢字、英数字)のみを許可
if re.fullmatch(r"[ぁ-んァ-ン一-龥a-zA-Z0-90-9ー・]+", word) is None:
continue
raw.append(word)
# 複合語の処理
merged = []
i = 0
while i < len(raw):
w = raw[i]
# 「組織性」「連動性」の処理
if i + 1 < len(raw) and raw[i + 1] == "性" and w in ("組織", "連動"):
merged.append(w + "性")
i += 2
continue
# 「ポジショナルプレー」の処理
if i + 1 < len(raw) and raw[i + 1] == "プレー" and w == "ポジショナル":
merged.append("ポジショナルプレー")
i += 2
continue
merged.append(w)
i += 1
# 1文字の単語とストップワードを最終除去
return [w for w in merged if len(w) > 1 and w not in stop_words]
# 全文書を前処理
tokenized_docs = [extract_words(doc) for doc in soccer_documents]
print("=" * 60)
print("前処理完了")
print("=" * 60)
print(f"文書数: {len(tokenized_docs)}")
print(f"\n最初の文書の抽出単語:")
print(tokenized_docs[0])
文書数: 30
最初の文書の抽出単語:
['ゾーン', '守備', 'スライド', '徹底', '連動性', '規律', 'ライン', '間延び', 'ハーフ', 'スペース', '誘導', '回収']
Step 2: 辞書・コーパス作成とLDA学習
- gensimの辞書作成
- Bag-of-Words(BoW)コーパスの作成
- LDAモデルの学習
- トピックごとの単語分布(ϕ)の表示
# 数値計算
import numpy as np
# gensim(LDA実装)
from gensim import corpora
from gensim.models import LdaModel
# ========================================
# 辞書とコーパスの作成
# ========================================
# gensimの辞書を作成
# 辞書 = 単語とIDの対応表
dictionary = corpora.Dictionary(tokenized_docs)
print("=" * 60)
print("辞書作成")
print("=" * 60)
print(f"作成前の語彙数: {len(dictionary)}")
# レア語と頻出語の除去
# no_below: 最低この数の文書に出現する単語のみ残す
# no_above: この割合以上の文書に出現する単語は除外
dictionary.filter_extremes(no_below=2, no_above=0.6)
print(f"フィルタ後の語彙数: {len(dictionary)}")
# Bag-of-Words(BoW)コーパスの作成
# 各文書を (単語ID, 出現回数) のリストに変換
corpus = [dictionary.doc2bow(tokens) for tokens in tokenized_docs]
print(f"\nコーパスサイズ: {len(corpus)}")
print(f"最初の文書のBoW(一部): {corpus[0][:5]}")
# ========================================
# LDAモデルの学習
# ========================================
# トピック数の設定
K = 3 # 国が3つなので、まず3トピックで試す
print("\n" + "=" * 60)
print(f"LDAモデルの学習(トピック数: {K})")
print("=" * 60)
# LDAモデルの作成と学習
lda = LdaModel(
corpus=corpus, # BoWコーパス
id2word=dictionary, # 辞書
num_topics=K, # トピック数
random_state=42, # 再現性のための乱数シード
passes=30, # 全文書を何回学習するか
iterations=400, # 各パスでの最大イテレーション数
alpha="auto", # α(文書→トピック分布)を自動調整
eta="auto" # β(トピック→単語分布)を自動調整
)
print("学習完了!")
# ========================================
# トピックごとの単語分布(ϕ)を表示
# ========================================
print("\n" + "=" * 60)
print("トピックごとの上位単語(ϕ:ファイ)")
print("=" * 60)
# 各トピックの上位12単語を表示
for topic_id in range(K):
# show_topic: トピックの単語分布を取得
# 戻り値: [(単語, 確率), ...] のリスト
words = lda.show_topic(topic_id, topn=12)
print(f"\n【トピック {topic_id}】")
for word, prob in words:
print(f" {word:<15} {prob:.4f}")
# トピックの解釈(手動でラベル付け)
print("\n" + "=" * 60)
print("トピックの解釈")
print("=" * 60)
print("トピック0: 守備・ブロック系")
print("トピック1: ライン間・ポジショナル系")
print("トピック2: 速攻・ロングボール系")
print("\n※上位単語から推測したラベルです")
============================================================
辞書作成
============================================================
作成前の語彙数: 150
フィルタ後の語彙数: 61
コーパスサイズ: 30
最初の文書のBoW(一部): [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1)]
============================================================
LDAモデルの学習(トピック数: 3)
============================================================
学習完了!
============================================================
トピックごとの上位単語(ϕ:ファイ)
============================================================
【トピック 0】
ライン 0.0480
スペース 0.0480
相手 0.0390
奪回 0.0390
ブロック 0.0390
ハーフ 0.0390
回収 0.0388
配置 0.0300
受け手 0.0300
即時 0.0300
スライド 0.0300
守備 0.0299
【トピック 1】
前進 0.0625
ライン 0.0481
受け手 0.0337
規律 0.0337
ビルドアップ 0.0337
サイド 0.0337
守備 0.0337
固定 0.0337
優先 0.0337
レーン 0.0337
攻撃 0.0337
パス 0.0337
【トピック 2】
回収 0.0619
中央 0.0616
サイド 0.0501
密集 0.0385
攻撃 0.0270
撤退 0.0270
ロング 0.0270
背後 0.0270
セカンド 0.0270
速攻 0.0270
ロック 0.0270
閉鎖 0.0270
============================================================
トピックの解釈
============================================================
トピック0: 守備・ブロック系
トピック1: ライン間・ポジショナル系
トピック2: 速攻・ロングボール系
※上位単語から推測したラベルです
Step 3: 文書のトピック分布(θ)と国別分析
- 各文書のトピック分布(θ)を取得
- 国別の平均トピック分布を計算
- 結果の可視化
# 数値計算
import numpy as np
# 可視化
import matplotlib.pyplot as plt
import japanize_matplotlib
japanize_matplotlib.japanize()
# ========================================
# 文書ごとのトピック分布(θ)を取得
# ========================================
def get_document_topic_distribution(model, bow, num_topics):
"""
文書のトピック分布を密なベクトルで取得
Args:
model: LDAモデル
bow: 文書のBoW表現
num_topics: トピック数
Returns:
トピック分布のnumpy配列 [トピック0の確率, トピック1の確率, ...]
"""
# minimum_probability=0 で全トピックの確率を取得
topic_dist = model.get_document_topics(bow, minimum_probability=0)
# [(トピックID, 確率), ...] から確率のみを抽出
probs = np.array([prob for _, prob in topic_dist])
return probs
# 全文書のトピック分布を取得
doc_topic_matrix = np.vstack([
get_document_topic_distribution(lda, bow, K)
for bow in corpus
])
print("=" * 60)
print("文書ごとのトピック分布(θ:シータ)")
print("=" * 60)
print(f"行列のサイズ: {doc_topic_matrix.shape}")
print(f"({doc_topic_matrix.shape[0]}文書 × {doc_topic_matrix.shape[1]}トピック)")
# 最初の3文書のトピック分布を表示
print("\n最初の3文書のトピック分布:")
for i in range(3):
print(f"文書{i+1}({document_labels[i]}): {doc_topic_matrix[i].round(3)}")
# ========================================
# 国別の平均トピック分布を計算
# ========================================
# 各国の文書インデックスを取得し、平均を計算
country_mean_topics = {}
for country in countries:
# その国の文書のインデックスを取得
indices = [i for i, label in enumerate(document_labels) if label == country]
# その国の文書のトピック分布の平均を計算
country_mean_topics[country] = doc_topic_matrix[indices].mean(axis=0)
print("\n" + "=" * 60)
print("国別平均トピック割合(θの平均)")
print("=" * 60)
for country in countries:
print(f"{country}: {country_mean_topics[country].round(3)}")
# ========================================
# 可視化: 国別平均トピック分布
# ========================================
# 棒グラフの設定
x = np.arange(K) # トピックの位置
width = 0.25 # 棒の幅
# 図の作成
plt.figure(figsize=(10, 6))
# 各国の棒を横並びで配置
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1'] # 国ごとの色
for i, country in enumerate(countries):
# 棒の位置をずらして配置
plt.bar(
x + (i - 1) * width, # x座標(中央を0として左右にずらす)
country_mean_topics[country], # 高さ(トピック確率)
width=width, # 棒の幅
label=country, # 凡例用のラベル
color=colors[i], # 色
edgecolor='white', # 枠線の色
linewidth=1.5 # 枠線の太さ
)
# グラフの装飾
plt.xticks(x, [f'トピック{t}' for t in range(K)]) # x軸のラベル
plt.ylim(0, 1) # y軸の範囲
plt.ylabel('平均トピック比率(θ)', fontsize=12)
plt.title('国別:平均トピック比率(LDA)', fontsize=14, pad=15)
plt.legend(fontsize=11)
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()
# 表示
plt.show()
print("可視化完了!")
============================================================
文書ごとのトピック分布(θ:シータ)
============================================================
行列のサイズ: (30, 3)
(30文書 × 3トピック)
最初の3文書のトピック分布:
文書1(日本): [0.991 0.004 0.005]
文書2(日本): [0.99 0.004 0.006]
文書3(日本): [0.007 0.987 0.006]
============================================================
国別平均トピック割合(θの平均)
============================================================
日本: [0.401 0.397 0.202]
オランダ: [0.499 0.201 0.3 ]
チュニジア: [0.303 0.103 0.594]
結果の解釈
日本のトピック分布は [0.401, 0.397, 0.202] となりました。
これはそれぞれ以下の戦術的テーマに対応します:
- トピック0(守備・組織):約40%
- トピック1(ライン間・ポジショナル):約40%
- トピック2(速攻・ロングボール):約20%
この結果から、日本は
- 守備組織(40%)
- ライン間を意識した攻撃構築(40%)
をほぼ同程度に重視しており、 一方で 速攻型のプレーは相対的に少なめ(20%) であると解釈できます。
つまり、日本は「守備の安定」と「構造的な攻撃」を両立させながら、 全体として 組織重視の戦術モデル を採用していることがわかります。
応用可能性
本記事で紹介した手法は、以下のような分野にも応用可能です。
- ニュース記事の分類: 政治、経済、スポーツなどのトピック抽出
- 顧客レビュー分析: 商品の評価ポイントの自動抽出
- 論文の分類: 研究分野ごとのトピック分析
- SNS投稿の分析: トレンドトピックの発見
