アンケートの分析は、選択肢設問の集計と自由記述のテキスト分析で手順がまったく違います。前者はpandasの数行で終わりますが、後者は形態素解析・共起ネットワーク・トピックモデルと段階を踏む必要があり、途中のパラメータ次第で結論そのものが入れ替わります。この記事では12件のサンプル回答を題材に、集計・検定・Excel出力からLDAまでを実行可能なコードでたどります。件数をあえて小さく取ったのは、閾値やseedや検定の方式を変えたときに結論がどう動くかを目に見える形で示すためです。掲載しているコードと出力は、すべて下記の検証環境で実際に実行した結果です。
まとめ
- 選択肢設問はpandasの
value_countsとcrosstabで完結し、複数回答はstr.get_dummies(sep=",")で展開して、分母を回答者数に固定します。 - クロス集計のカイ二乗検定は、期待度数が5未満のセルを数えてから結果を読みます。検証データでは20セルすべてが5未満で、2×2にまとめてもFisherの正確確率検定はp=1.0でした。
- 自由記述は品詞フィルタが必須です。フィルタなしでは頻出語の上位3件が「。」「が」「も」で埋まります。
- 共起ネットワークはJaccard係数の閾値で結論が変わります。閾値0.2から0.3へ上げただけで、中心性トップが「機能」から「にくい」「分かる」「設定」の3語へ入れ替わりました。
- 単語と満足度の相関はスピアマン相関で見ますが、SciPyの近似p値は500件を超えないと正確ではありません。12件で置換検定に掛け直すと、補正後に残る語は1語からゼロになりました。
- LDAは
random_stateを変えるだけでトピックの構成語が入れ替わります。回答数が100件を下回るなら、共起ネットワークとLDAは判断材料にせず、集計と原文の通読に絞ります。
検証環境とアンケートデータの持ち方
実行環境とライブラリのバージョン
本記事のコードは、macOS上のPython 3.13.14に仮想環境を作って実行しました。
python3 -m venv venv
venv/bin/pip install pandas janome scikit-learn networkx scipy matplotlib openpyxl
venv/bin/python -c "import sys, pandas, janome, sklearn, networkx, scipy, matplotlib, openpyxl; print(sys.version.split()[0], pandas.__version__, janome.__version__, sklearn.__version__, networkx.__version__, scipy.__version__, matplotlib.__version__, openpyxl.__version__)"
# 3.13.14 3.1.0 0.5.0 1.9.1 3.7 1.18.1 3.11.2 3.1.5
Pythonの版は3.12以降を選んでください。2026年10月時点のPyPI最新版は、pandas 3.1.0・scikit-learn 1.9.1・matplotlib 3.11.2が requires_python を3.11以上、networkx 3.7とscipy 1.18.1が3.12以上に設定しています。条件を満たさない環境では、pipがエラーも警告も出さずに旧版を選ぶため、バージョンを表示するまで気づきません。Python公式の開発者ガイドによると、3.9は2025年10月31日、3.10は2026年10月1日にサポートが終了しています。なお本記事の出力は、2026年8月にPython 3.9.6+pandas 2.3.3の構成で実行したときと全ブロックで一致しました。pandas 3.0以降はCopy-on-Writeが既定で有効です。代入まわりの変更点はCopy-on-Write(コピーオンライト)とは?仕組みとfork・ファイルシステム・Pandas 3.0での使われ方で扱っています。
1行1回答のローデータ形式と複数回答の格納
分析を始める前に、データを「1行1回答」の形に整えます。集計済みのクロス表から始めると、後段のクロス集計も相関分析もやり直しになります。複数回答(MA)はカンマ区切りの1列に入れておくと、後からダミー変数へ展開できます。
import pandas as pd
rows = [
(1, "20代", "満足", 5, "料金,サポート", "画面が見やすくて操作に迷わない。サポートの返信も早い。"),
(2, "30代", "やや満足", 4, "料金", "料金は安いが、検索が遅いのが気になる。"),
(3, "40代", "普通", 3, "機能", "機能は多いけれど、設定画面が分かりにくいと思う。"),
(4, "20代", "不満", 1, "サポート", "サポートの返信が遅い。問い合わせても回答が来ない。"),
(5, "30代", "満足", 5, "機能,サポート", "サポートが丁寧で助かる。機能追加も早い。"),
(6, "50代", "やや不満", 2, "機能", "検索が遅い。画面の表示も崩れることがある。"),
(7, "40代", "やや満足", 4, "料金,機能", "料金の割に機能が充実している。操作も簡単だ。"),
(8, "20代", "不満", 1, "", "設定画面が分かりにくい。マニュアルも古いままだと思う。"),
(9, "30代", "普通", 3, "料金", "料金は妥当。ただし検索が遅いので改善してほしい。"),
(10, "50代", "満足", 5, "サポート", "サポートの対応が丁寧で、返信も早い。"),
(11, "40代", "やや不満", 2, "機能,サポート", "機能が多すぎて設定画面が分かりにくい。"),
(12, "20代", "やや満足", 4, "料金", "料金が安い。画面も見やすいと思う。"),
]
df = pd.DataFrame(rows, columns=["id", "age", "satisfaction", "score", "reasons", "comment"])
id=8の reasons が空文字である点に注目してください。無回答をこの形で持っておくと、後述の展開処理で全ゼロ行として扱われます。str.get_dummies はNaNを渡してもエラーにはならず、空文字と同じく全ゼロ行を返します(pandas 2.3.3/3.1.0で確認)。そのため展開後は、無回答と「選択肢を一つも選ばなかった」を区別できません。どちらの意味かは読み込み時点で決めておきます。
NaNが実害を出すのは自由記述列のほうです。pd.read_csv は空欄を既定でNaNとして読むため、そのまま形態素解析へ渡すと AttributeError: 'float' object has no attribute 'strip' で止まります。keep_default_na=False を付けて空文字のまま読むか、fillna("") を挟んでください。
重複回答・表記ゆれ・直線回答の除去
集計に入る前に、二重送信の候補と全設問に同じ値を付けた直線回答を抽出し、選択肢の表記ゆれを正規化します。
import unicodedata
key = ["age", "satisfaction", "score", "reasons", "comment"]
print(int(df.duplicated(subset=key).sum()))
# 0
dup = pd.concat([df, df.iloc[[3]]], ignore_index=True)
print(int(dup.duplicated(subset=key).sum()), dup.index[dup.duplicated(subset=key)].tolist())
# 1 [12]
このコードはid列を外した回答内容の一致を検出します。同一人物の二重送信と確定するには、回答者識別子や送信記録も確認します。id込みで duplicated() を呼ぶと、連番が振られている限り重複は検出されません。
raw = ["料金,サポート", "料金, サポート", "サポート"]
print([unicodedata.normalize("NFKC", s) for s in raw])
# ['料金,サポート', '料金, サポート', 'サポート']
NFKC正規化は全角カンマを半角へ、半角カナを全角へ揃えます。ただし2件目のように区切り文字のあとの空白は残るため、replace(" ", "") を別にかけます。ここを飛ばすと、str.get_dummies が「サポート」と「 サポート」を別の選択肢として列に開きます。
items = pd.DataFrame({"q1": [5, 3, 1, 4], "q2": [5, 3, 2, 4],
"q3": [5, 4, 1, 4], "q4": [5, 2, 1, 4]})
print(items.index[items.nunique(axis=1) == 1].tolist())
# [0, 3]
評価設問が複数ある場合は、行方向のユニーク値が1つだけの回答者を直線回答として抽出できます。全問同じ評価が常に不正とは限らないため、自由記述が空か極端に短いといった条件と併せて判断してください。
選択肢設問の集計|単純集計・クロス集計・複数回答
単純集計とクロス集計のpandas実装
満足度のような順序尺度は、value_counts の結果は、既定では件数の多い順に並びます。reindex で選択肢の並びを固定してから構成比を計算します。集計表の読み方そのものは単純集計(GT集計)とは?集計表の見方とExcelでの作り方で解説しています。
order = ["満足", "やや満足", "普通", "やや不満", "不満"]
tab = df["satisfaction"].value_counts().reindex(order, fill_value=0)
print(pd.DataFrame({"件数": tab, "構成比": (tab * 100 / len(df)).round(1)}))
# 件数 構成比
# satisfaction
# 満足 3 25.0
# やや満足 3 25.0
# 普通 2 16.7
# やや不満 2 16.7
# 不満 2 16.7
print(pd.crosstab(df["age"], df["satisfaction"]).reindex(columns=order, fill_value=0))
# satisfaction 満足 やや満足 普通 やや不満 不満
# age
# 20代 1 1 0 0 2
# 30代 1 1 1 0 0
# 40代 0 1 1 1 0
# 50代 1 0 0 1 0
クロス集計にも reindex(columns=..., fill_value=0) を付けます。省くと該当者0人の選択肢が列ごと消え、表の列数が集計のたびに変わります。行や列を位置で取り出す書き方はilocの使い方|pandasで行・列を抽出、locとの違いも解説にまとめています。
複数回答(MA)のダミー変数展開
複数回答は Series.str.get_dummies で選択肢ごとの0/1列に開きます。文字列を分割してループで数える必要はありません。
ma = df["reasons"].str.get_dummies(sep=",")
print(ma.dtypes.to_dict())
# {'サポート': dtype('int64'), '料金': dtype('int64'), '機能': dtype('int64')}
print((ma.sum() * 100 / len(df)).round(1).sort_values(ascending=False))
# サポート 41.7
# 料金 41.7
# 機能 41.7
# dtype: float64
返る列のdtypeはint64です。pandasのAPIリファレンスも既定を np.int64 としています。一方、単一選択向けの pd.get_dummies はpandas 2.0で既定のdtypeがuint8からboolに変わったため、両者を混ぜて集計すると型が揃いません。pandas全体の使い方はPandasとは?できること・使い方とPandas 3.0の変更点を解説を参照してください。
複数回答の属性別集計と回答者ベースの分母
回答者ベースの複数回答の比率は、合計が100%を超えることがあります。分母を回答総数(選択された件数の合計)にすると、選択肢を多く選ぶ層ほど1件あたりの重みが薄まり、層の比較が歪みます。分母は回答者数に固定し、表に「回答者ベース」と明記します。年代別に見るなら、ダミー列を属性で合計し、属性ごとの回答者数で割ります。
base = df.groupby("age").size()
ma_age = ma.groupby(df["age"]).sum()
print((ma_age.div(base, axis=0) * 100).round(1).assign(回答者数=base))
# サポート 料金 機能 回答者数
# age
# 20代 50.0 50.0 0.0 4
# 30代 33.3 66.7 33.3 3
# 40代 33.3 33.3 100.0 3
# 50代 50.0 0.0 50.0 2
40代の「機能」100.0%は3人全員が選んだという意味で、50代は2人しかいません。属性別の複数回答表は、比率の横に回答者数の列を必ず並べてください。比率だけを並べると、2人の層の50.0%と4人の層の50.0%が同じ重さに見えます。
クロス集計のカイ二乗検定と期待度数の確認
年代によって満足度に差があるかを確かめるには、クロス表にカイ二乗検定をかけます。scipy.stats.chi2_contingency は検定統計量・p値・自由度に加えて期待度数の表を返すので、p値を見る前に期待度数を数えます。SciPyのドキュメントも、各セルの観測度数と期待度数が5以上の場合に限って使うという目安を紹介しています。
from scipy import stats
ct = pd.crosstab(df["age"], df["satisfaction"]).reindex(columns=order, fill_value=0)
chi = stats.chi2_contingency(ct)
print(round(chi.statistic, 3), round(chi.pvalue, 4), chi.dof)
# 11.0 0.5289 12
print(round(chi.expected_freq.min(), 2), int((chi.expected_freq < 5).sum()), chi.expected_freq.size)
# 0.33 20 20
4年代×5段階の表では、20セルすべての期待度数が5を下回り、最小は0.33でした。p=0.5289という数字は出ますが、前提を満たしていないので読みません。セルを減らすために「満足計(満足+やや満足)」と「その他」、「20-30代」と「40-50代」の2×2にまとめ、Fisherの正確確率検定に切り替えます。
g_age = df["age"].isin(["20代", "30代"]).map({True: "20-30代", False: "40-50代"})
g_sat = df["satisfaction"].isin(["満足", "やや満足"]).map({True: "満足計", False: "その他"})
ct2 = pd.crosstab(g_age, g_sat)
print(ct2)
# satisfaction その他 満足計
# age
# 20-30代 3 4
# 40-50代 3 2
print(stats.chi2_contingency(ct2).expected_freq.min())
# 2.5
odds, p = stats.fisher_exact(ct2)
print(round(float(odds), 2), round(float(p), 4))
# 0.5 1.0
2×2にまとめても期待度数の最小は2.5で、目安の5に届きません。Fisherの正確確率検定はp=1.0で、今回の2×2への集約では、有意水準5%で年代と満足計の関連は検出されませんでした。選択肢をまとめる基準は、検定結果を見たあとで決めないでください。p値が小さくなるまとめ方を探す作業になります。検定の考え方とExcelでの手順はアンケートのクロス集計とは?やり方・表の見方から単純集計との違い、カイ二乗検定まで解説で扱っています。
自由記述の前処理と頻出語の抽出
janomeの形態素解析と品詞フィルタ
日本語の自由記述は単語に区切られていないため、まず形態素解析にかけます。janomeの公式ドキュメントによると、janomeはPure Pythonで書かれた辞書内包の形態素解析器で、内包辞書はmecab-ipadic-2.7.0-20070801です。MeCab本体のインストールが要らないので、分析環境をpipだけで組めます。
from collections import Counter
from janome.tokenizer import Tokenizer
t = Tokenizer()
allc = Counter(tok.base_form if tok.base_form != "*" else tok.surface
for s in df["comment"] for tok in t.tokenize(s))
for w, c in allc.most_common(6):
print(w, c)
# 。 20
# が 17
# も 8
# 画面 6
# の 6
# て 5
フィルタをかけない頻出語のトップ3は「。」20回、「が」17回、「も」8回でした。句読点と助詞が上位を占め、内容語は4位の「画面」まで出てきません。品詞で絞り込みます。
TARGET_POS = ("名詞", "動詞", "形容詞")
DROP_NOUN = ("数", "非自立", "代名詞", "接尾")
STOPWORDS = {"する", "ある", "いる", "なる", "思う", "こと", "もの", "ため"}
def extract(text):
words = []
for tok in t.tokenize(text):
pos = tok.part_of_speech.split(",")
if pos[0] not in TARGET_POS:
continue
if pos[0] == "名詞" and pos[1] in DROP_NOUN:
continue
base = tok.base_form if tok.base_form != "*" else tok.surface
if len(base) < 2 or base in STOPWORDS:
continue
words.append(base)
return words
df["words"] = df["comment"].apply(extract)
cnt = Counter(w for ws in df["words"] for w in ws)
for w, c in cnt.most_common(6):
print(w, c)
# 画面 6
# サポート 4
# 料金 4
# 遅い 4
# 機能 4
# 返信 3
名詞・動詞・形容詞に限定し、名詞のうち「数」「非自立」「代名詞」「接尾」を除外したうえで、1文字の語と汎用動詞のストップワードを落としています。上位が「画面」6回、「サポート」4回、「料金」4回、「遅い」4回に変わり、改善要望の所在が読める形になりました。
ストップワードと同梱辞書の限界
ストップワードは固定リストを使い回さず、対象データを一度素の状態で集計してから決めます。アンケートでは「思う」「感じる」が上位に来やすく、残すと共起ネットワークの中心に居座って図が読めなくなります。
もう一つの制約が辞書の古さです。同梱辞書は2007年8月版のipadicを基礎としており、その後に広まった複合語には分割されるものがあります。
for tok in t.tokenize("キャッシュレス決済に対応"):
print(tok.surface, tok.part_of_speech)
# キャッシュ 名詞,一般,*,*
# レス 名詞,サ変接続,*,*
# 決済 名詞,サ変接続,*,*
# に 助詞,格助詞,一般,*
# 対応 名詞,サ変接続,*,*
「キャッシュレス決済」が3語に割れ、「レス」が名詞・サ変接続として解析されています。このまま頻出語を数えると、回答者が書いていない「レス」という語が集計に混ざります。製品名や業界用語が出るアンケートでは、MeCab辞書と同じ形式のユーザー辞書を用意します。
# 列の並びは 表層形,左文脈ID,右文脈ID,コスト,品詞,細分類1-3,活用型,活用形,原形,読み,発音
row = "キャッシュレス決済,-1,-1,1000,名詞,一般,*,*,*,*,キャッシュレス決済,キャッシュレスケッサイ,キャッシュレスケッサイ"
open("userdic.csv", "w", encoding="utf8").write(row + "\n")
t2 = Tokenizer("userdic.csv", udic_enc="utf8")
for tok in t2.tokenize("キャッシュレス決済に対応"):
print(tok.surface, tok.part_of_speech)
# キャッシュレス決済 名詞,一般,*,*
# に 助詞,格助詞,一般,*
# 対応 名詞,サ変接続,*,*
CSVを1行足すだけで1語として認識されます。辞書整備の手間を避けたい場合は、クラウドの言語処理APIに寄せる選択肢もあります。判断材料は自然言語処理APIとは?クラウド3社・LLM・OSSの比較と実装組み込み手順にまとめています。
共起ネットワークの作成と読み方
Jaccard係数で辺を張る実装
共起ネットワークは、同じ回答に一緒に出てくる語をつないだ図です。単純な共起回数で辺を張ると出現数の多い語ばかりが太くなるため、集合の重なり具合を見るJaccard係数で正規化します。
import itertools
import networkx as nx
docs = [set(ws) for ws in df["words"]]
freq = Counter(w for d in docs for w in d)
vocab = {w for w, c in freq.items() if c >= 2}
pair = Counter()
for d in docs:
for a, b in itertools.combinations(sorted(d & vocab), 2):
pair[(a, b)] += 1
edges = [(a, b, co, co / (freq[a] + freq[b] - co)) for (a, b), co in pair.items()]
edges.sort(key=lambda x: -x[3])
for a, b, co, j in edges[:5]:
print(a, b, co, round(j, 3))
# にくい 分かる 3 1.0
# にくい 設定 3 1.0
# 分かる 設定 3 1.0
# サポート 早い 3 0.75
# サポート 返信 3 0.75
「にくい」「分かる」「設定」の3語がJaccard 1.0、つまり常に同じ回答に同時出現しています。「分かりにくい設定画面」という定型的な不満がひとかたまりで存在することが、図を描く前の数値で読み取れます。
共起ネットワークの閾値と中心性の変化
共起ネットワークで最も注意すべきなのは、辺を残す閾値です。分析者が任意に決める値でありながら、結論そのものを左右します。
for th in (0.2, 0.3, 0.5):
G = nx.Graph()
G.add_weighted_edges_from([(a, b, j) for a, b, co, j in edges if j >= th])
deg = nx.degree_centrality(G)
top = sorted(deg.items(), key=lambda x: -x[1])[:3]
print(th, G.number_of_nodes(), G.number_of_edges(),
nx.number_connected_components(G),
[f"{w}={v:.2f}" for w, v in top])
# 0.2 15 33 1 ['機能=0.50', 'にくい=0.36', '分かる=0.36']
# 0.3 14 23 3 ['にくい=0.38', '分かる=0.38', '設定=0.38']
# 0.5 14 17 4 ['にくい=0.31', '分かる=0.31', '設定=0.31']
閾値0.2ではグラフが1つにつながり、次数中心性のトップは「機能」(0.50)でした。0.3へ上げると連結成分が3個に割れ、トップは「にくい」「分かる」「設定」(各0.38)に交代します。「機能が課題の中心」という読みと「設定の分かりにくさが中心」という読みは、同じデータから閾値の違いだけで生まれています。0.5まで上げると連結成分は4個に増えますが、トップ3は0.3のときと同じ顔ぶれです。この3語のまとまりは、今回比較した閾値0.2・0.3・0.5で維持されました。「サポート」と「早い」「返信」の辺(係数0.75)も3つの閾値すべてで残ります。報告するのは閾値を動かしても残る辺で、どれを載せるかは原文と照合して決めます。
図を報告書に載せるときは、閾値と語の出現回数の下限を必ず併記してください。併記しなければ、パラメータの選び方をそのまま結論として報告することになります。
ネットワーク図の描画と日本語フォントの設定
数値で構造を確かめたら図にします。描画はnetworkxとmatplotlibで完結します。
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
G = nx.Graph()
G.add_weighted_edges_from([(a, b, j) for a, b, co, j in edges if j >= 0.3])
pos = nx.spring_layout(G, k=0.6, seed=42)
plt.rcParams["font.sans-serif"] = ["Hiragino Sans"]
fig, ax = plt.subplots(figsize=(7, 5))
nx.draw_networkx(G, pos, ax=ax, node_color="#cfe3f7", font_size=10)
fig.savefig("network.png", dpi=120)
print(G.number_of_nodes(), G.number_of_edges(), nx.number_connected_components(G))
# 14 23 3
フォントを指定しないと、日本語ラベルは全て豆腐(□)になります。matplotlib 3.11.2の既定フォントDejaVu Sansに日本語グリフが無いためで、無指定で描くと Glyph 12395 (\N{HIRAGANA LETTER NI}) missing from font(s) DejaVu Sans. という警告が32件出ました。
間違えやすいのが指定の仕方です。plt.rcParams["font.family"] = "Hiragino Sans" と書いても、警告は32件のまま減りません。networkxのリファレンスにあるとおり draw_networkx の font_family は既定が 'sans-serif' で、ラベル描画のたびにこの値が渡されて font.family の設定を上書きするからです。効くのは上のコードのように font.sans-serif の先頭を差し替えるか、nx.draw_networkx(..., font_family="Hiragino Sans") と引数で渡すかで、どちらも警告は0件でした(macOSでの実測。WindowsならMeiryo、LinuxならNoto Sans CJK等に読み替えます)。spring_layout は乱数で初期配置を決めるため、seed を固定しないと実行のたびに図の形が変わります。
単語出現と満足度スコアの相関分析
スピアマン相関の実装
「どの言及が満足度と結びついているか」は、単語の出現有無(0/1)と満足度スコアの相関で見ます。5段階評価は間隔が等しい保証のない順序尺度なので、ピアソンの積率相関ではなく順位相関のスピアマンを使います。順序尺度として扱う理由はリッカート尺度とは?アンケート5段階評価の選択肢例と4・7段階の選び方、集計方法で詳しく扱っています。
targets = sorted(w for w, c in freq.items() if c >= 3)
rows = []
for w in targets:
flag = df["words"].apply(lambda ws: int(w in ws))
rho, p = stats.spearmanr(flag, df["score"])
r, _ = stats.pearsonr(flag, df["score"])
rows.append((w, int(flag.sum()), round(rho, 3), round(p, 4), round(r, 3)))
res = pd.DataFrame(rows, columns=["単語", "件数", "spearman", "p", "pearson"])
print(res.sort_values("spearman").to_string(index=False))
# 単語 件数 spearman p pearson
# にくい 3 -0.512 0.0890 -0.508
# 分かる 3 -0.512 0.0890 -0.508
# 設定 3 -0.512 0.0890 -0.508
# 遅い 4 -0.392 0.2080 -0.373
# 画面 6 -0.295 0.3512 -0.293
# 検索 3 -0.142 0.6595 -0.102
# 機能 4 0.104 0.7467 0.124
# 料金 4 0.183 0.5696 0.249
# 返信 3 0.227 0.4772 0.169
# サポート 4 0.444 0.1483 0.373
# 早い 3 0.768 0.0036 0.711
targets を sorted で並べているのは、集合から作った Counter をそのまま回すと同順位の語の並びが実行ごとに入れ替わるためです。2つの相関係数は実際にずれ、「早い」はスピアマン0.768に対しピアソン0.711、「検索」は-0.142に対し-0.102でした。
多重比較の補正と置換検定によるp値の測り直し
語ごとに検定を繰り返すほど、偶然に有意となる語が混ざります。11語すべてが実際には無相関でも、有意水準5%なら期待値で0.55語が有意と判定されます。検定回数で有意水準を割るBonferroni補正をかけます。
print(int((res["p"] < 0.05).sum()), len(res))
# 1 11
bonf = 0.05 / len(res)
print(round(bonf, 4), int((res["p"] < bonf).sum()))
# 0.0045 1
補正前にp<0.05を満たしたのは11語中1語(「早い」p=0.0036)で、補正後の閾値0.0045でも同じ1語が残ります。ただし、この結論はまだ信用できません。SciPyの spearmanr のドキュメントは、このp値が正確なのは500件を超える大きな標本に限られ、小さな標本では置換検定を検討するよう書いています。12件はその条件から大きく外れているため、満足度と単語の組み合わせを並べ替える置換検定でp値を測り直します。
import numpy as np
y = df["score"].to_numpy()
perm_p = []
for w in res["単語"]:
x = df["words"].apply(lambda ws: int(w in ws)).to_numpy()
r = stats.permutation_test((x,), lambda xs: stats.spearmanr(xs, y).statistic,
permutation_type="pairings", n_resamples=9999,
rng=np.random.default_rng(0))
perm_p.append(round(r.pvalue, 4))
res["p_perm"] = perm_p
print(res[["単語", "spearman", "p", "p_perm"]].sort_values("p", kind="stable").head(4).to_string(index=False))
# 単語 spearman p p_perm
# 早い 0.768 0.0036 0.0092
# にくい -0.512 0.0890 0.1282
# 分かる -0.512 0.0890 0.1282
# 設定 -0.512 0.0890 0.1282
print(int((res["p_perm"] < bonf).sum()))
# 0
置換検定では「早い」のp値が0.0036から0.0092へ上がり、補正後の閾値0.0045を超えました。補正後に残る語はゼロです。このデータでは近似のp値が置換検定より低く出ており、その差がちょうど有意・非有意の境目をまたいでいます。相関係数0.444の「サポート」も、近似p=0.1483のとおり最初から残りません。「サポートや対応の早さへの言及が満足度と相関する」と書きたくなる場面ですが、この12件ではどちらも主張できません。置換検定は11語で数分かかるため、件数が増えたら n_resamples を減らすより、対象語を事前に絞るほうが確実です。
トピックモデル(LDA)による自由記述の分類
scikit-learnでのLDA実装
トピックモデルは、文書の背後にある話題を統計的に推定する手法です。LDA(潜在的ディリクレ配分法)が代表格で、scikit-learnの LatentDirichletAllocation で実行できます。前処理済みの単語リストを空白区切りにして CountVectorizer に渡します。
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
corpus = [" ".join(ws) for ws in df["words"]]
vec = CountVectorizer(token_pattern=r"(?u)\b\w+\b", min_df=2)
X = vec.fit_transform(corpus)
names = vec.get_feature_names_out()
for seed in (0, 42):
lda = LatentDirichletAllocation(n_components=3, random_state=seed,
learning_method="batch", max_iter=50).fit(X)
print(seed, round(lda.perplexity(X), 1))
for i, comp in enumerate(lda.components_):
print(" ", i, [names[j] for j in comp.argsort()[::-1][:5]])
# 0 25.4
# 0 ['サポート', '遅い', '料金', '早い', '返信']
# 1 ['機能', '画面', '料金', '遅い', '検索']
# 2 ['画面', '設定', 'にくい', '分かる', '機能']
# 42 21.7
# 0 ['サポート', '早い', '返信', '丁寧', '操作']
# 1 ['料金', '遅い', '検索', '安い', '画面']
# 2 ['画面', '機能', '設定', '分かる', 'にくい']
token_pattern の既定値 (?u)\b\w\w+\b は2文字以上の語しか拾わないため、1文字の日本語語彙を残したい場合は書き換えます。本記事の extract() は既に1文字語を落としているので今回の出力は変わりませんが、前処理を差し替えたときに語が黙って消えるのを防げます。min_df=2 は2文書以上に出る語だけを残す指定です。
トピック数とseedの決め方
seedを0から42へ変えただけで、perplexityは25.4から21.7へ下がり、トピック0の構成語は「サポート/遅い/料金/早い/返信」から「サポート/早い/返信/丁寧/操作」へ入れ替わりました。前者は肯定・否定の表現が混在しており、上位語だけでは対象となる話題や評価の向きを判断できません。
この12件の例では結果の不安定さが確認できますが、回答数だけが原因とは断定できません。seedを変えて結果が動くうちは、トピック数を減らすか手法自体を見送り、複数のseedで共通して現れるトピックだけを採用します。n_components も数値だけでは決められません。Chang らの「Reading Tea Leaves: How Humans Interpret Topic Models」(NIPS 2009)は、held-out尤度で良いスコアを出すモデルほど人間には意味の取りにくいトピックを返しうると報告しています。perplexityは尤度の裏返しなので、下がり続けても解釈可能性は保証されません。2から5の範囲で、人が読んで決めてください。
回答数が数千件規模あり、より細かい意味の近さで分類したい場合は、文をベクトル化してクラスタリングする方法もあります。実装はPythonでembedding(ベクトル化)を生成する方法|OpenAI APIとsentence-transformersで解説しています。数百件以下で分類の軸が決まっているなら、人手で自由回答をコード化するアフターコーディングとは?自由回答の分類手順とExcel・AIでの自動化を解説のほうが早く確実です。
手法の使い分けとExcel・KH Coderとの分担
回答数別の適用可否
ここまでの手法は、必要な回答数がそれぞれ違います。検証データの12件では、頻出語の集計までは意味のある出力が得られましたが、検定は前提を満たさず、共起ネットワークは閾値で、LDAはseedで結論が動きました。次の表は統計的に導かれた下限ではなく、本記事が説明用に置いた暫定的な目安です。各件数での安定性は検証していないため、手法を選ぶ順番の目安として使ってください。カイ二乗検定の期待度数は周辺度数から計算するため、総回答数だけでは各セルで5以上になるか判断できません。
| 手法 | 目安回答数(本記事の暫定目安) | 主な出力 | 不安定になる要因 |
|---|---|---|---|
| 単純集計 | 30以上 | 件数・構成比 | 選択肢ごとの度数不足 |
| クロス集計・カイ二乗検定 | 各セルの期待度数を確認 | 表側×表頭の度数・p値 | 期待度数の不足 |
| 頻出語カウント | 50以上 | 語と出現回数 | 辞書・ストップワード |
| 共起ネットワーク | 100以上 | 語のつながり | Jaccard閾値 |
| 相関分析 | 100以上 | 相関係数・p値 | 多重比較・近似p値 |
| トピックモデル(LDA) | 300以上 | トピック別上位語 | seed・トピック数 |
本記事では、100件未満の回答は共起ネットワークやLDAの図だけで結論を出さない方針とします。その規模なら、まず自由記述を全件読み、分類基準を決めてから集計する進め方を勧めます。
集計結果のExcel出力とシート設定
集計結果を共有する相手がExcelで見るなら、表ごとにシートを分けて1ファイルに書き出します。pd.ExcelWriter は、pandasのリファレンスのとおり.xlsxではXlsxWriterが入っていればそれを、無ければopenpyxlを使います。下のコードは列幅とウィンドウ枠の設定でopenpyxlのワークシートを直接触るため、engine="openpyxl" を明示しています。XlsxWriterも入った環境で省略すると、シートがXlsxWriterの Worksheet になり column_dimensions が無いという AttributeError で止まります(実測)。
from openpyxl import load_workbook
with pd.ExcelWriter("survey_result.xlsx", engine="openpyxl") as xw:
pd.DataFrame({"件数": tab, "構成比": (tab * 100 / len(df)).round(1)}).to_excel(xw, sheet_name="単純集計")
ct.to_excel(xw, sheet_name="年代別満足度")
ma_age.div(base, axis=0).mul(100).round(1).assign(回答者数=base).to_excel(xw, sheet_name="年代別理由")
for ws in xw.sheets.values():
ws.column_dimensions["A"].width = 14
ws.freeze_panes = "B2"
wb = load_workbook("survey_result.xlsx")
print(wb.sheetnames, wb["年代別満足度"].dimensions, wb["年代別満足度"].freeze_panes)
# ['単純集計', '年代別満足度', '年代別理由'] A1:F5 B2
見出し列の幅を広げ、1行目と1列目をウィンドウ枠の固定で止めておくと、受け取った側がスクロールしても選択肢名と属性名が隠れません。複数回答のシートには、前述のとおり回答者数の列を足してから渡してください。
KH CoderとExcelへ切り替える判断基準
分析を1回だけ実施して結果を共有すれば終わる案件では、環境構築とコード保守のコストが回収できません。GUIで共起ネットワークまで描けるKH Coderの方が早く済みます。KH Coderは共起の強さを測る指標としてJaccard係数のほかCosine係数とEuclid距離を選べ、描いたネットワークをGraphML形式やPajek形式で保存して、GephiやCytoScapeで見た目を詰められます。導入条件はKH Coderのダウンロードページで確認してください。無料のWindows版Starting Editionは分析できるデータと一部機能に制限があり、やや古いBeta版です。制限のないKH Coder 5正式版は有償で、Mac版も有償で提供されています。
集計軸が確定していて毎月同じ表を出すだけなら、Excelのピボットテーブルで足ります。Pythonが優位なのは、毎月届く回答を同じ前処理で処理し続ける場合、ユーザー辞書やストップワードをバージョン管理したい場合、集計結果を他システムへ渡す場合です。回答の収集から集計・帳票出力までを業務システムに組み込むなら、アンケート管理システム開発として設計したほうが、前処理の規則を画面と帳票に固定できます。判断基準は分析の高度さではなく、同じ処理を繰り返すかどうかです。
よくある質問
アンケートの自由記述はどう分析すればよいですか?
形態素解析で単語に分割し、品詞フィルタとストップワードで内容語だけを残してから頻出語を数えるのが出発点です。語のつながりを見たい場合に共起ネットワーク、話題ごとに束ねたい場合にトピックモデルへ進みます。回答数が100件に満たない場合、本記事では共起ネットワークとLDAの図で結論を出さず、頻出語の集計と原文の通読で分類基準を決めることを勧めます。
共起ネットワークはどう読めばよいですか?
線でつながった語の集まり(連結成分)は話題を探る手掛かりです。多くの語とつながる語は次数中心性が高くなりますが、話題の中心かどうかは原文と照合します。本記事の検証では、Jaccard閾値を0.2から0.3へ変えるだけで中心の語が入れ替わりました。図を読む前に、閾値と語の出現回数の下限を確認してください。
形態素解析にMeCabのインストールは必要ですか?
janomeを使う場合は不要です。janomeはmecab-ipadic-2.7.0-20070801を内包したPure Pythonの実装で、pipだけで導入できます。ただし辞書が2007年時点のものなので、新しい複合語は分割されます。用語の精度が要る場合はユーザー辞書を追加してください。
Pythonで自由記述の感情分析はできますか?
英語向けのライブラリをそのまま日本語に使うことはできません。TextBlob 0.20.1の sentiment は英文「The support team is very kind.」にpolarity 0.78を返しましたが、「サポートの対応が丁寧で、返信も早い。」と「サポートの返信が遅い。」はどちらもpolarity 0.0でした(本記事の検証環境で実測)。アンケートに満足度などの評価設問があるなら、本記事の相関分析のように評価スコアを正解として単語との関係を見るほうが確実です。日本語の感情分析が必要な場合は、日本語に対応した言語処理APIかモデルを選びます。
アンケートの分析に有意差の検定は必要ですか?
標本で見られた差や相関を母集団にも当てはまると推測するなら、検定や信頼区間などで不確実性を評価します。ただし検定の前提も確かめてください。本記事の検証では、クロス集計の期待度数が全セルで5を下回り、スピアマン相関の近似p値で残った「早い」も、置換検定で測り直すとp=0.0092となりBonferroni補正後の閾値0.0045を超えました。今回検定した年代区分と満足計の関連、および11語と満足度の相関では、採用した有意水準と多重比較補正の下で有意な結果は得られませんでした。