アンケートの分析は、選択肢設問の集計と自由記述のテキストマイニングで手順がまったく違います。前者はpandasの数行で終わりますが、後者は形態素解析・共起ネットワーク・トピックモデルと段階を踏む必要があり、途中のパラメータ設定次第で結論そのものが入れ替わります。この記事では12件のサンプル回答を題材に、集計からLDAまでを実行可能なコードでたどります。件数をあえて小さく取ったのは、閾値やseedを変えたときに結論がどう動くかを目に見える形で示すためです。掲載しているコードと出力は、すべて下記の検証環境で実際に実行した結果です。
まとめ
- 選択肢設問はpandasの
value_countsとcrosstabで完結し、複数回答はstr.get_dummies(sep=",")でダミー変数に展開できます。 - 自由記述は品詞フィルタが必須です。フィルタなしでは頻出語の上位3件が「。」「が」「も」で埋まり、分析対象になりません。
- 共起ネットワークはJaccard係数の閾値で結論が変わります。検証データでは閾値0.2から0.3へ上げただけで連結成分が1個から3個に割れ、中心性トップが「機能」(0.50)から「にくい」「分かる」「設定」の3語(各0.38)へ入れ替わりました。
- 単語と満足度スコアの相関は、順序尺度に合わせてスピアマン相関を使い、検定回数に応じたBonferroni補正をかけます。検証データでは11語のうち有意に残ったのは1語だけでした。
- LDAは回答数が少ないと不安定です。
random_stateを0から42へ変えただけでperplexityが25.4から21.7へ動き、トピックの構成語も入れ替わりました。 - 回答数が100件を下回る場合、共起ネットワークとLDAは判断材料になりません。頻出語の集計とクロス集計に絞るか、原文をそのまま読む方が確実です。
検証環境とアンケートデータの持ち方
実行環境とライブラリのバージョン
本記事のコードは、macOS上のPython 3.9.6に仮想環境を作って実行しました。
python3 -m venv venv
venv/bin/pip install pandas janome scikit-learn networkx scipy matplotlib
venv/bin/python -c "import sys, pandas, janome, sklearn, networkx, scipy, matplotlib; print(sys.version.split()[0], pandas.__version__, janome.__version__, sklearn.__version__, networkx.__version__, scipy.__version__, matplotlib.__version__)"
# 3.9.6 2.3.3 0.5.0 1.6.1 3.2.1 1.13.1 3.9.4
ここで注意が必要なのは、インストールされたバージョンがPyPIの最新版ではない点です。2026年8月時点のPyPI最新はpandasが3.0.5、scikit-learnが1.9.0、networkxが3.6.1、scipyが1.18.0ですが、前3者は requires_python が3.11以上、scipyは3.12以上に設定されているため、Python 3.9環境では条件を満たす旧版が選ばれます。エラーも警告も出ないので、バージョンを表示するまで気づきません。
Python 3.9は2025年10月31日にサポートが終了しています。新規に分析環境を組むならPython 3.11以降を選んでください。本記事のコードと掲載出力は、Python 3.13.14+pandas 3.0.5・scikit-learn 1.9.0・networkx 3.6.1・scipy 1.18.0・matplotlib 3.11.1の最新版構成でも同一の結果になることを確認済みです。pandas 3.0ではCopy-on-Writeが既定で有効になり、代入まわりの挙動が変わります。移行時の変更点はCopy-on-Write(CoW/コピーオンライト)とは?仕組みと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.0.5の両方で確認)。裏を返すと、無回答と「選択肢を一つも選ばなかった」が展開後は区別できなくなるため、どちらの意味かは読み込み時点で決めておく必要があります。
NaNが実害を出すのは自由記述列のほうです。pd.read_csv は空欄を既定でNaNとして読むため、そのまま形態素解析へ渡すと AttributeError: 'float' object has no attribute 'strip' で止まります。keep_default_na=False を付けて空文字のまま読むか、fillna("") を挟んでください。アンケートのデータクリーニングは、この欠損値の型を決める作業から始まります。
重複回答・表記ゆれ・直線回答の除去
集計に入る前に、結果を歪める3パターンを機械的に落とします。同一人物の二重送信、選択肢の表記ゆれ、全設問に同じ値を付けた直線回答です。
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つだけの回答者を直線回答として抽出できます。この例では0番と3番が該当します。ただし全問同じ評価が常に不正とは限らないため、自由記述が空か極端に短いといった条件と併せて判断してください。
選択肢設問の集計|単純集計・クロス集計・複数回答
単純集計とクロス集計のpandas実装
満足度のような順序尺度は、value_counts の結果をそのまま出すと出現順に並んでしまいます。reindex で選択肢の並びを固定してから構成比を計算します。
order = ["満足", "やや満足", "普通", "やや不満", "不満"]
tab = df["satisfaction"].value_counts().reindex(order)
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列に開きます。専用の関数があるにもかかわらず、文字列を分割してループで数える実装も見かけますが、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です。単一選択向けの pd.get_dummies はpandas 2.0以降boolを返すため、両者を混ぜて集計すると型が揃わない点に注意してください。また複数回答の構成比は合計が100%を超えます。分母は回答総数ではなく回答者数(len(df))で固定し、「回答者ベース」であることを表に明記します。pandas全体の使い方はPandasとは?できること・使い方とPandas 3.0の変更点を解説を参照してください。
自由記述の前処理と頻出語の抽出
janomeの形態素解析と品詞フィルタ
日本語の自由記述は単語に区切られていないため、まず形態素解析にかけます。janomeはmecab-ipadic-2.7.0-20070801を同梱した純Python実装で、MeCab本体のインストールが要りません。分析用途ではこの導入の軽さが効きます。
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語に割れ、しかも「レス」が名詞・サ変接続として解析されています。この状態で頻出語を数えると、実際には存在しない「レス」という語が集計に混ざります。製品名や業界用語が回答に出るアンケートでは、ユーザー辞書を用意してください。
# 列の並びは 表層形,左文脈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語のまとまりだけが閾値に依存しない構造で、報告に値するのはここだけだと分かります。
図を報告書に載せるときは、閾値と語の出現回数の下限を必ず併記してください。図の見た目だけで中心性を語ると、パラメータの選び方をそのまま結論として報告することになります。
ネットワーク図の描画と日本語フォントの落とし穴
数値で構造を確かめたら図にします。描画は 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.9.4の既定フォントDejaVu Sansに日本語グリフが無いためで、無指定で描くと Glyph 12363 (\N{HIRAGANA LETTER KA}) missing from font(s) DejaVu Sans. という警告が64件出ました。
ここで間違えやすいのが指定の仕方です。plt.rcParams["font.family"] = "Hiragino Sans" と書いても警告は64件のまま減りません。nx.draw_networkx がラベル描画時に font_family="sans-serif" を明示的に渡すため、font.family の設定が上書きされるからです。効くのは上のコードのように font.sans-serif の先頭を差し替えるか、nx.draw_networkx(..., font_family="Hiragino Sans") と引数で渡すかのどちらかで、この2つはいずれも警告0件になります(macOSでの実測。WindowsならMeiryo、Linuxならnoto-sans-cjk等に読み替えてください)。
spring_layout は乱数で初期配置を決めるため、seed を固定しないと実行のたびに図の形が変わります。閾値0.3のこの図は、不満・満足・料金の3クラスタが離れて並び、前節の連結成分3という数値と一致します。
単語出現と満足度スコアの相関分析
スピアマン相関の実装
「どの言及が満足度と結びついているか」は、単語の出現有無(0/1)と満足度スコアの相関で見ます。5段階評価は間隔が等しい保証のない順序尺度なので、ピアソンの積率相関ではなく順位相関のスピアマンを使います。
from scipy import stats
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 をそのまま回すと同順位の語の並びが実行ごとに入れ替わるためです。値は変わりませんが、出力を差分で比較したいときに邪魔になります。実際に両方の相関係数を計算すると値がずれます。「早い」はスピアマン0.768に対しピアソン0.711、「検索」は-0.142に対し-0.102でした。順位に置き換えた方が、評価尺度の目盛りの不均等に引きずられません。
多重比較による偽陽性の抑制
語ごとに検定を繰り返すほど、偶然に有意となる語が混ざりやすくなります。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
なお stats.spearmanr のp値はn=12では漸近近似で、ここでの有意・非有意も幅を持って読む必要があります。補正前でp<0.05を満たしたのは11語中1語(「早い」p=0.0036)で、補正後の閾値0.0045でも同じ1語が残りました。裏を返せば、他の10語はどれも偶然と区別がつきません。相関係数の絶対値が0.4を超えていた「サポート」(0.444)も、p値は0.1483にとどまります。「サポートへの言及が満足度と相関」と書きたくなる場面ですが、この検証データではその主張は成立しません。
トピックモデル(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文書以上に出る語だけを残す指定で、1回しか出ない語を落として推定を安定させます。
トピック数と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で解説しています。
手法の使い分けとPythonを選ぶべきでない場面
回答数別の適用可否
ここまでの手法は、必要な回答数がそれぞれ違います。検証データの12件では、頻出語の集計までは意味のある出力が得られましたが、共起ネットワークは閾値で結論が動き、LDAはseedで結論が動きました。次の表は統計的に導かれた下限ではなく、パラメータを変えても結論が揺れなくなるラインとして本記事が置いている運用基準です。クロス集計だけは、カイ二乗検定で期待度数5以上を確保するという慣行から逆算できます。
| 手法 | 目安回答数(本記事の運用基準) | 主な出力 | 不安定になる要因 |
|---|---|---|---|
| 単純集計 | 30以上 | 件数・構成比 | 選択肢ごとの度数不足 |
| クロス集計 | セル数×5(4×5表なら100) | 表側×表頭の度数 | セル度数の不足 |
| 頻出語カウント | 50以上 | 語と出現回数 | 辞書・ストップワード |
| 共起ネットワーク | 100以上 | 語のつながり | Jaccard閾値 |
| 相関分析 | 100以上 | 相関係数・p値 | 多重比較 |
| トピックモデル(LDA) | 300以上 | トピック別上位語 | seed・トピック数 |
回答が100件に満たないアンケートで共起ネットワークやLDAの図を作っても、報告書の見栄えが良くなるだけで判断材料は増えません。その規模なら自由記述を全件読んだ方が速く、しかも正確です。
KH CoderとExcelへ切り替える判断基準
分析を1回だけ実施して結果を共有すれば終わる案件では、環境構築とコード保守のコストが回収できません。GUIで共起ネットワークまで描けるKH Coderの方が早く済みます。KH Coder 3は共起の強さを測る指標としてJaccard係数のほかCosine係数とEuclid距離を選べ、描いたネットワークをGraphML形式やPajek形式で保存して、GephiやCytoScapeで見た目を詰められます。対応分析やクラスター分析までGUIで完結する点も強みです。ただし導入条件は確認してください。無償で使えるのはWindows版のStarting Editionで、分析できるデータと一部機能に制限があります。制限のない構成は有償で、macOSでは有償のセットアップソフトウェアが必要です。
集計軸が確定していて毎月同じ表を出すだけなら、Excelのピボットテーブルで十分です。逆にPythonが優位なのは、毎月届く回答を同じ前処理で処理し続ける場合、ユーザー辞書やストップワードをバージョン管理したい場合、そして集計結果を他システムへ渡す必要がある場合です。判断基準は分析の高度さではなく、同じ処理を繰り返すかどうかにあります。
よくある質問
アンケートの自由記述はどう分析すればよいですか?
形態素解析で単語に分割し、品詞フィルタとストップワードで内容語だけを残してから頻出語を数えるのが出発点です。そのうえで語のつながりを見たい場合に共起ネットワーク、話題ごとに束ねたい場合にトピックモデルへ進みます。回答数が100件に満たない場合は、共起ネットワークとLDAは見送り、頻出語の集計とクロス集計に絞るか、全件を読む方が確実です。
共起ネットワークの見方を教えてください
線でつながった語の集まり(連結成分)が話題のかたまり、多くの語とつながる語(次数中心性の高い語)が話題の中心です。ただし本記事の検証では、Jaccard閾値を0.2から0.3へ変えるだけで中心の語が入れ替わりました。図を読む前に、閾値と語の出現回数の下限を必ず確認してください。
形態素解析にMeCabのインストールは必要ですか?
janomeを使う場合は不要です。janomeはmecab-ipadic-2.7.0-20070801を同梱した純Python実装で、pipだけで導入できます。ただし辞書が2007年時点のものなので、新しい複合語は分割されます。用語の精度が要る場合はユーザー辞書を追加してください。
トピックモデルとは何ですか?
文書集合の背後にある話題を、単語の出現パターンから統計的に推定する手法です。LDAでは各トピックが単語の確率分布として表され、各文書が複数トピックの混合として表現されます。分類先のラベルを事前に用意しなくてよい反面、出てきたトピックが何を指すかは人が解釈する必要があります。
アンケートの分析に有意差の検定は必要ですか?
語ごとに相関や差を調べる場合は必要です。検定を繰り返すと偶然の有意が混ざるため、Bonferroni補正のように検定回数で有意水準を割る処理を併用してください。本記事の検証では、補正後の閾値0.0045を満たしたのは11語のうち「早い」1語だけでした。相関係数が0.444あった「サポート」はp=0.1483で残らず、係数の大きさだけでは根拠になりません。