19. テキストのベクトル化(spacy + α)

  • spacyに限定しない、一般的に共通した考え方。

    • まずベクトル化するテキストの単位を決める。決めた単位でまとめておく。

      • 複数文章?単一文章?複数文?単一文?複数単語?単一単語?

    • テキスト群をtokenに分割する。ここでは分かち書き+原形処理に留める。

    • token系列に対し、分布類似度仮説を踏まえた特徴量設計を考える。

  • 今回の例

  • 今回は扱わない別例

    • 名詞や形容詞など特定品詞のみ処理する。ストップワードで不要語を削除する。類語をまとめてしまう。n-gram、共起、係り受け情報の利用, etc.

19.1. 利用ライブラリの用意、データセット準備

事前に、load_r_assesment.ipynb でデータセットを作成し、pkl形式でファイル保存(r_assesment.pkl)しておく。今回は作成済みファイルをダウンロードして利用することにする。

r_assesment.pklは授業評価アンケートの自由記述欄をpd.DataFrame形式で保存したもので、授業名(title)、学年(grade)、必修か否か(required)、質問番号(q_id)、コメント(comment)で構成される。

!curl -O https://ie.u-ryukyu.ac.jp/~tnal/2022/dm/static/r_assesment.pkl
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 34834  100 34834    0     0   154k      0 --:--:-- --:--:-- --:--:--  157k
import numpy as np
import pandas as pd
import spacy

nlp = spacy.load("ja_ginza")

assesment_df = pd.read_pickle('r_assesment.pkl')
assesment_df.head()
title grade required q_id comment
0 工業数学Ⅰ 1 True Q21 (1) 特になし
1 工業数学Ⅰ 1 True Q21 (2) 正直わかりずらい。むだに間があるし。
2 工業数学Ⅰ 1 True Q21 (2) 例題を取り入れて理解しやすくしてほしい。
3 工業数学Ⅰ 1 True Q21 (2) 特になし
4 工業数学Ⅰ 1 True Q21 (2) スライドに書く文字をもう少しわかりやすくして欲しいです。

19.2. サンプルの単位(ベクトル化の対象)を決める

assesment_dfの comment はある科目(title)の質問(q_id)に対する回答内容(comment)が記録されている。この回答内容は受講者毎に別コメントとして記載されている。例えば上記出力内容においては、title = 工業数学Ⅰ and q_id = Q21(2) となっているコメントが4件表示されている。これらは異なる受講者が同一質問に対して回答したことを示している。また2行目の出力では「正直わかりづらい。むだに間があるし。」のように2つの文が記入されている。このようにコメントには複数文が含まれることもあることを想定しておこう。

今回はこの「1受講生の、ある科目のある質問に対するコメント」を1サンプルとして扱うことにしよう。つまり、このassesment_dfにおけるcommentをそのままベクトル化する単位とする。

19.3. 分かち書き処理

spacyを使って分かち書きしよう。この際、token.lemma_により原形処理するものとする。また分かち書き結果を「スペースを区切り文字としたtoken系列」として保存することとする。例えば「これはテストです」を「これ は テスト です」という文字列として保存する。

def text_to_sequence_of_words(text, sep=' '):
    '''テキストをtokenに分割し、sep区切りの文字列として結合した文字列を返す。
    args:
      text (str): 処理対象となるテキスト。
      sep (str): 処理結果を結合するための区切り文字。
    return
      str: sepで結合した分かち書き結果。
    '''
    doc = nlp(text)
    sequence = []
    for token in doc:
        sequence.append(token.lemma_)
    return sep.join(sequence)

def df_to_sequence_of_words(df, column, sep=' '):
    '''df[column]を対象として分かち書きする。
    args:
      df (pd.DataFrame): テキストを含むデータフレーム。
      column (str): dfにおける処理対象となる列名。
      sep (str): 分かち書き結果を結合する文字。
    return
      result ([str]): text_to_sequence_of_words()で分かち書き処理された文字列のリスト。
    '''
    result = []
    for comment in df[column]:
        result.append(text_to_sequence_of_words(comment, sep))
    return result

sequence_of_words = df_to_sequence_of_words(assesment_df, 'comment')
print(assesment_df['comment'][0])
print(sequence_of_words[0])
特になし
特に なし

19.3.1. Bag-of-Wordsによるベクトル化

import sklearn.feature_extraction.text as fe_text

def bow(docs, stop_words=[]):
    '''Bag-of-Wordsによるベクトルを生成。

    :param docs(list): 1文書1文字列で保存。複数文書をリストとして並べたもの。
    :return: 文書ベクトル。
    '''
    vectorizer = fe_text.CountVectorizer(stop_words=stop_words)
    vectors = vectorizer.fit_transform(docs)
    return vectors, vectorizer

stop_words = ['こと', '\r\n', 'ため', '思う', 'いる', 'ある', 'する', 'なる']
vectors_bow, vectorizer_bow = bow(sequence_of_words, stop_words)
print('# normal BoW')
print('shape = ', vectors_bow.shape)
print('feature_names[:10] = ', vectorizer_bow.get_feature_names_out()[:10])
print('vectors[0] = \n',vectors_bow[0])
print('type(vectors[0]) = ', type(vectors_bow[0]))
print(vectorizer_bow.get_feature_names_out()[594])
print(vectorizer_bow.get_feature_names_out()[121])
# normal BoW
shape =  (170, 788)
feature_names[:10] =  ['100' '19' '20' '30' '40' '80' 'cm' 'covid' 'css' 'denchu']
vectors[0] = 
   (0, 594)	1
  (0, 121)	1
type(vectors[0]) =  <class 'scipy.sparse._csr.csr_matrix'>
特に
なし

19.3.2. TF-IDFによる特徴量調整

def bow_tfidf(docs, stop_words=[]):
    '''Bag-of-WordsにTF-IDFで重み調整したベクトルを生成。

    :param docs(list): 1文書1文字列で保存。複数文書をリストとして並べたもの。
    :return: 重み調整したベクトル。
    '''
    vectorizer = fe_text.TfidfVectorizer(norm=None, stop_words=stop_words)
    vectors = vectorizer.fit_transform(docs)
    return vectors, vectorizer

vectors_tfidf, vectorizer_tfidf = bow_tfidf(sequence_of_words, stop_words)
print('# BoW + tfidf')
print(vectors_tfidf[0])
# BoW + tfidf
  (0, 121)	3.1972245773362196
  (0, 594)	3.00616934057351

19.3.3. ja_ginza(word2vec)によるベクトル化

def word2vec(df, column):
    vectors = []
    for text in df[column]:
        doc = nlp(text)
        vectors.append(doc.vector)
    return np.array(vectors)

vectors_w2v = word2vec(assesment_df, 'comment')
print('# word2vec')
print('vectors_w2v[0][:10] = ', vectors_w2v[0][:10])
# word2vec
vectors_w2v[0][:10] =  [ 0.03997449 -0.12051773 -0.04468929 -0.12576343 -0.11509937 -0.02549797
 -0.04673433 -0.12278005  0.06705444 -0.05726326]

19.4. 類似コメント抽出実験

テキストを3手法でベクトル化することが出来た。このベクトル空間を使って類似コメント(ベクトル空間内でのコサイン類似度が高いコメント)を検索してみよう。3つのベクトル空間はそれぞれ異なるため、例えばBoWで検索したい場合には検索クエリをBoWベクトル空間に写像し、近いベクトルを探すという手順を踏む必要がある。

19.4.1. BoWの場合

query = '授業が難しい'
sequence_of_words = text_to_sequence_of_words(query)
print(sequence_of_words)
target_vector_bow = vectorizer_bow.transform([sequence_of_words])
print(target_vector_bow)
授業 が 難しい
  (0, 487)	1
  (0, 773)	1
from sklearn.metrics.pairwise import cosine_similarity

def most_similar_comment_indices(vectors, query_vector, n=3):
    similarities = cosine_similarity(vectors, query_vector)
    similarities = similarities.reshape(len(similarities)) # 1行に整形
    most_similar_indicies = np.argsort(similarities)[::-1][:n]
    most_similarities = np.sort(similarities)[::-1][:n]
    return most_similar_indicies, most_similarities

def print_comment_with_similarity(df, column, indicies, similarities):
    for i in range(len(indicies)):
        comment = df[column][indicies[i]]
        similarity = similarities[i]
        print(f'similarity = {similarity:.3f} => {comment}')

indicies, similarities = most_similar_comment_indices(vectors_bow, target_vector_bow, 5)
print_comment_with_similarity(assesment_df, 'comment', indicies, similarities)
similarity = 0.500 => 難しかったです
similarity = 0.354 => コロナのせいで無くなったけど合宿授業したかった。でも、改めて授業として道徳を学ぶことができてためになったのでよかったです。
similarity = 0.345 => 試験内容に初めて見る問題があった.さらにその後の解説もあまりなかったので難易度が他の授業に比べて高すぎると感じました.それは,内容が難しいというよりも授業で試験に出る内容を網羅し切れていないことによるものと思いました.遠隔の試験であったことが大きいということは重々承知の上で,問題内容が伝わり辛かったので他の授業のようにどうにか対応して欲しかったです.また,平均点が他の授業と比べて低すぎるので追加で課題などが欲しかったです.
similarity = 0.289 => 元気がある先生で、授業も楽しく聞けました。
similarity = 0.289 => プログラミングを全く触ったことがなかったのでとても難しかった。

19.4.2. TF-IDFの場合

target_vector_tfidf = vectorizer_tfidf.transform([sequence_of_words])
print(target_vector_tfidf)

indicies, similarities = most_similar_comment_indices(vectors_tfidf, target_vector_tfidf, 5)
print_comment_with_similarity(assesment_df, 'comment', indicies, similarities)
  (0, 773)	3.502606226887401
  (0, 487)	2.55814461804655
similarity = 0.699 => 難しかったです
similarity = 0.287 => プログラミングを全く触ったことがなかったのでとても難しかった。
similarity = 0.279 => まだ1年次ということもあり、特に難しくなくてよかったです。
similarity = 0.236 => 試験内容に初めて見る問題があった.さらにその後の解説もあまりなかったので難易度が他の授業に比べて高すぎると感じました.それは,内容が難しいというよりも授業で試験に出る内容を網羅し切れていないことによるものと思いました.遠隔の試験であったことが大きいということは重々承知の上で,問題内容が伝わり辛かったので他の授業のようにどうにか対応して欲しかったです.また,平均点が他の授業と比べて低すぎるので追加で課題などが欲しかったです.
similarity = 0.228 => 課題は学びはじめにとっては難しかったけど達成感はGOOD

19.4.3. word2vecの場合

target_vector_w2v = [nlp(query).vector]
print(target_vector_w2v[0][:5])

indicies, similarities = most_similar_comment_indices(vectors_w2v, target_vector_w2v, 5)
print_comment_with_similarity(assesment_df, 'comment', indicies, similarities)
[-0.09240351 -0.11036714 -0.05784035 -0.07640907 -0.04625978]
similarity = 0.839 => 教科書が必要ない講義ということで最初は不安でしたが、講義内の説明もわかりやすく、授業資料もとても丁寧に書かれていたため、しっかりと学習することができました。
similarity = 0.821 => 生徒が自主学習できる環境を作ってくれていたため、とても勉強しやすかったです。
similarity = 0.820 => 元気がある先生で、授業も楽しく聞けました。
similarity = 0.820 => 受講前から噂は聞いていたので心してかかったつもりですが、それでも思わずクソゲーと叫びたくなるような難易度でした。これの恐ろしいところは、課題や授業で出された問題は解けるのですが、中間・期末テストで急激に難易度が跳ね上がるところですね。

せめて過去問を配布してくれたりすると、生徒側としてはテストに向けての勉強が捗るだけでなく、授業でも要点をしっかり押さえて勉強できたりすると思います。
similarity = 0.817 => ・授業の方法自体は普通であったと思います(板書が若干見づらかったが、オンラインのために板書が汚かったりすることなどはしょうがないと思いました)。