メカとら / tidyverseで学ぶ人事データ分析 記事一覧 運営者について お問い合わせ

第22章: 自由記述の分析 — サーベイのコメントを読む

日本語テキストを数える手順と、数えても使えなかった話

📝 日本語形態素解析 📊 エンゲージメントサーベイ ⚠️ 個人特定の回避

自由記述は、たいてい期待したほど書かれていない

エンゲージメントサーベイの自由記述を分析したことがあります。結論から書くと、集計も可視化もできたのに、最後まで使えるものになりませんでした。 原因は手法ではなく、その手前にありました。

ひとつは、記述そのものが雑だったこと。設問に対して一行だけ、あるいは「特になし」。読める分量が書かれている回答は、思っていたより少ない。 もうひとつは、頻出語を並べて図にしたところで「それで、何をすればいいのか」に答えられなかったことです。 会議に出したときの空気で分かりました。きれいな図は出せる。でも次の一手に繋がらない。

この章では、その失敗をなぞる形で日本語テキスト分析の手順を書きます。 tidytextの標準的な解説は英語の小説を題材にすることが多いのですが、社内アンケートの日本語はまったく別物です。 途中で何度もつまずくので、つまずく場所を先に示しておきます。

この章で扱う範囲

R(tidyverse)と gibasatidytext を使って、日本語の自由記述を数えられる形にするところまで。 そのうえで「数えた結果が使えないとき、何を変えるか」を書きます。感情分析ライブラリの紹介で終わらせません。

分析する前に、分析できる量があるかを数える

最初にやるべきは形態素解析ではありません。どれだけの人が、どれだけの分量を書いたかを数えることです。 サーベイの回答率が高くても、自由記述の記入率はまったく別の数字になります。ここを見ないまま分析に入ると、 「全社の声」のつもりで一部の饒舌な人の意見を見ることになります。

01_measure_volume.R
# 想定するデータ: id, department, tenure_years, score_overall, comment library(tidyverse) survey <- read_csv("engagement_survey.csv") # 「書かれていない」にはいくつか種類がある blank_words <- c("特になし", "なし", "特に無し", "とくになし", "ー", "―", "・") survey_flagged <- survey |> mutate( comment = str_squish(replace_na(comment, "")), n_char = str_length(comment), is_blank = n_char == 0 | comment %in% blank_words ) survey_flagged |> summarise( 回答数 = n(), 記入あり = sum(!is_blank), 記入率 = mean(!is_blank), 文字数中央値 = median(n_char[!is_blank]), 文字数p90 = quantile(n_char[!is_blank], 0.9) )

中央値と90パーセンタイルを両方見るのが要点です。平均だけだと、長文を書いた数人に引っ張られて実態より豊かに見えます。 中央値が20〜30字なら、それは「文章」ではなく「一言」です。一言の集合から意味を取り出す手法と、 段落の集合から取り出す手法は別なので、ここで方針が決まります。

出力例(形式のイメージ)
# A tibble: 1 × 5 回答数 記入あり 記入率 文字数中央値 文字数p90 <int> <int> <dbl> <dbl> <dbl> 1 ... ... ... ... ...

部署別・在籍年数別に記入率を出すと、それ自体が示唆になることがあります。 書かない層が偏っているなら、そのあとどんな分析をしても、その層の声は最後まで入ってきません。 テキスト分析の結果を読むときに、この偏りを覚えておく必要があります。

英語の道具では日本語は切れない

tidytextの解説でよく出てくる unnest_tokens() は、空白で区切って単語にします。英語ならそれで足ります。 日本語には空白がないので、そのまま通すと一文がまるごと1トークンになります。形態素解析が要ります。

同じ理由で、AFINN・Bing・NRCといった感情分析辞書も日本語には使えません。あれは英単語の一覧です。 日本語の自由記述に対して感情スコアを出したいなら、日本語の極性辞書を別途用意するか、そもそも感情分析という枠を疑う必要があります。 このあたりを飛ばして「感情分析ができます」と書いてある解説は、日本語データを触っていないと思ったほうが安全です。

RでMeCabを使う方法はいくつかありますが、いま新規に組むなら gibasa が扱いやすいです。 CRANから入り、v0.5.0以降はパッケージ内にMeCabのソースを同梱しているため、MeCab本体を別途インストールしなくてもビルドできます。 返ってくるのが「1行1トークン」の整然データなので、そのまま dplyr と tidytext に繋がります。

02_tokenize_ja.R
install.packages("gibasa") library(gibasa) library(tidytext) tokens <- survey_flagged |> filter(!is_blank) |> select(id, department, score_overall, comment) |> tokenize(text_field = "comment", docid_field = "id") |> prettify(col_select = c("POS1", "POS2", "Original")) # 品詞で絞る。名詞・形容詞・動詞だけ残すのが出発点 # Original は原形。活用があるので表層形のままだと「思っ」「思い」が別語になる words <- tokens |> filter(POS1 %in% c("名詞", "形容詞", "動詞")) |> filter(!POS2 %in% c("数", "non-independent", "非自立")) |> mutate(word = coalesce(Original, token))

prettify() が展開する列名は使う辞書によって変わります。IPA辞書とUniDicでは品詞体系そのものが違うので、 最初に tokens |> count(POS1, POS2, sort = TRUE) で何が返っているかを見てから絞り込んでください。 ここを確認せずに品詞名を決め打ちすると、フィルタが空振りして全部残る、あるいは全部消えます。

社内用語が、単語として切れない

ここが実務で最初に効いてきます。一般の辞書には、あなたの会社の部署名も、社内システムの名前も、 社内でだけ通じる略語も入っていません。結果として不自然な位置で切られ、意味のない断片が頻出語の上位に並びます。

対処はユーザー辞書の追加です。手間はかかりますが、追加すべき語はそれほど多くありません。 部署名、システム名、制度名、頻出する略語。この4種類を洗い出すだけで、出力の見え方がかなり変わります。 洗い出しは総務や情シスに聞くより、切られた結果を眺めて拾うほうが早いことが多いです。

辞書に入れる前に確認すること

自分で辞書を編む前に、まず既存の辞書を替えるだけで解決しないかを見てください。 新語に強い辞書に差し替えると、追加が必要な語がかなり減ります。それでも残るのが本当の社内語です。

頻出語を数えると「仕事」「会社」「業務」が並ぶ

切れるようになったら数えます。ここまでは順調に進みます。そして、上位に並ぶのは 「仕事」「会社」「業務」「人」「思う」といった語です。当たり前で、何の情報もありません。

03_frequency.R
stopwords_ja <- c("こと", "もの", "ため", "よう", "する", "ある", "いる", "なる", "思う") freq <- words |> filter(!word %in% stopwords_ja, str_length(word) > 1) |> count(word, sort = TRUE) freq |> slice_head(n = 20)

ストップワードを足していけば少しはマシになります。でも、いくら足しても 「その職場を説明する語」は出てきません。全員が同じような言葉を使うからです。 頻度は、共通点を見つける道具であって、違いを見つける道具ではない。ここが構造的な限界です。

違いを見たいなら TF-IDF に切り替えます。「この設問でだけよく出る語」「この部署でだけよく出る語」を出す考え方です。

04_tfidf_by_group.R
# department を「文書」とみなして、部署ごとの特徴語を出す tfidf <- words |> filter(!word %in% stopwords_ja, str_length(word) > 1) |> count(department, word) |> bind_tf_idf(word, department, n) |> arrange(desc(tf_idf)) tfidf |> group_by(department) |> slice_max(tf_idf, n = 8) |> ungroup()

注意点がひとつ。TF-IDFは珍しい語を高く評価するので、一人しか使っていない語が上位に来ます。 部署の人数が少ないほどこれが起きます。そのまま部署別の一覧にすると、実質的に個人の発言を晒すことになりかねません。 最低出現人数のしきい値を入れてから見てください。

可視化はできた。それでも使えなかった

ここまでで、頻出語のランキングも、部署別の特徴語も、ワードクラウドも出せます。図としては成立します。 私が止まったのはこの先でした。その図を見て、次に何をするかが決まらない。

理由を後から整理すると、こうなります。

1
語には方向がない

「評価」という語が多く出たとして、評価制度に不満なのか、評価されて嬉しいのか、区別がつきません。 単語を数えた時点で、肯定と否定が同じ袋に入ります。

2
誰の声かが消える

全体で集計すると、辞めそうな人の一言も、満足している人の一言も、同じ1カウントになります。 人事が知りたいのは「誰が何に困っているか」なのに、そこが平されます。

3
施策の単位と粒度が合わない

打てる手は「制度を変える」「マネージャーに介入する」「配置を見直す」といった単位です。 単語の頻度は、その単位に翻訳できません。ここが一番大きい。

ワードクラウドについて

見栄えがするので報告資料に入れたくなりますが、面積が頻度に対応しているだけで、順位も差も正確には読めません。 人に見せる図としては、上位語の棒グラフのほうが誠実です。ワードクラウドを出すなら、 それは分析結果ではなく雰囲気の共有だと割り切ったほうがいいと思います。

数える対象を変える

手法を高度にしても解決しませんでした。変えたのは、何を数えるかのほうです。

スコアで割ってから比べる

全体で数えるのをやめて、エンゲージメントスコアの高い層と低い層に分け、両者で出現の偏る語を見る。 「両方でよく出る語」は捨てて、「片方にだけ寄っている語」だけを見ます。 これだけで、当たり前の語が自動的に落ちます。

05_contrast_by_score.R
contrast <- words |> filter(!word %in% stopwords_ja, str_length(word) > 1) |> mutate(group = if_else(score_overall >= 4, "高スコア", "低スコア")) |> count(group, word) |> pivot_wider(names_from = group, values_from = n, values_fill = 0) |> filter(高スコア + 低スコア >= 10) |> # 少なすぎる語は落とす mutate( # +1 はゼロ割り回避。単純だが、まずはこれで傾向が見える log_ratio = log2((低スコア + 1) / (高スコア + 1)) ) |> arrange(desc(log_ratio)) # 低スコア層に偏る語 / 高スコア層に偏る語 contrast |> slice_head(n = 15) contrast |> slice_tail(n = 15)

出現数の少ない語で比率が暴れるので、頻度のしきい値は必ず入れてください。 より丁寧にやるなら、頻度で重みづけした対数オッズを使う方法があります(tidylo パッケージなど)。 ただ、最初の当たりを付けるだけならこの単純な比で十分でした。

語を「読む対象を絞る道具」として使う

いちばん効いたのは、実はこれです。テキスト分析を結論を出す道具として使うのをやめました。 代わりに、全部は読めない量の自由記述から、読むべき数十件を選ぶための道具として使いました。

低スコア層に偏っていた語を含むコメントだけを抽出して、原文を人間が読む。 そうすると、単語の集計では絶対に出てこなかった具体的な状況が書かれています。 数十件なら読めます。読んだ内容は、そのまま施策の議論に持っていける形をしています。

06_pick_to_read.R
# 低スコア層に強く偏っていた上位語 signal_words <- contrast |> slice_head(n = 10) |> pull(word) to_read <- survey_flagged |> filter(!is_blank, score_overall < 4) |> filter(str_detect(comment, str_c(signal_words, collapse = "|"))) |> arrange(desc(n_char)) |> select(department, tenure_years, score_overall, comment) # ここから先は人間が読む。集計では出てこない話が書いてある to_read |> slice_head(n = 40)

テキストマイニングの解説としては地味な着地です。けれど、実務で報告に耐えたのはこの形でした。 「機械が要約した結果」ではなく「機械が選んだ40件を人が読んだ結果」のほうが、聞く側も判断しやすい。

個人が特定される粒度で出さない

人事データ全般に言えることですが、自由記述はとくに危険です。書いた本人の言い回しがそのまま残るので、 部署と組み合わせただけで誰の発言か分かってしまうことがあります。 匿名で回収したはずのサーベイが、分析の過程で実質的に記名になる。これが起きると、次回から誰も書かなくなります。

集計と共有のときに守っていること

  • 人数のしきい値を決めておく:一定人数を下回る部署は、部署別の内訳を出さない。上位カテゴリにまとめる
  • 原文をそのまま資料に貼らない:引用したい場合も、文体と固有名詞を落として要旨だけにする
  • 特徴語の一覧にも人数条件をかける:TF-IDFは珍しい語を持ち上げるので、そのまま出すと一人の発言が見出しになる
  • 誰が見られるかを先に決める:ローデータにアクセスできる範囲を最小にする。図と表だけを共有する

サーベイの案内文で「個人が特定される形では利用しません」と伝えているなら、それは分析側の制約でもあります。 技術的にできることと、やっていいことは別だという当たり前の話ですが、コードを書いていると忘れがちです。

この章のまとめ

手順

  • 分量を先に測る:記入率と文字数の中央値。ここで分析方針が決まる
  • 日本語は形態素解析が必須gibasa でトークン化し、品詞と原形で絞る。英語の感情辞書は使えない
  • 社内用語をユーザー辞書に入れる:部署名・システム名・制度名・略語の4種類から
  • 頻度ではなく差分を見る:全体集計では当たり前の語しか出ない。スコアや設問で層別して比べる
  • 結論ではなく絞り込みに使う:機械が選んだ数十件を人が読む。この形がいちばん実務で通った
  • 人数のしきい値を必ず入れる:自由記述は個人が特定されやすい

次の章へ

テキストを特徴量として予測モデルに入れる話は、モデルの評価とセットで考える必要があります。 とくに退職予測のような不均衡データでは、精度の見方を間違えると「何も予測していないモデル」が高精度に見えます。 そのあたりは第18章で扱います。

📚 学習におすすめの書籍

📊

Rユーザのためのtidymodels[実践]入門

〜モダンな統計・機械学習モデリングの世界

松村 優哉 ほか

🛒 Amazonで見る
📈

改訂2版 RユーザのためのRStudio[実践]入門

〜tidyverseによるモダンな分析フローの世界

松村 優哉 ほか

🛒 Amazonで見る

※ 当サイトはAmazonアソシエイトプログラムに参加しています