NAISTの授業シラバスをDoc2VecとKmeansでクラスタリングしてWordCloudで可視化してみた


はじめに

みなさんこんにちは。
突然ですが、大学の授業って結構類似している授業が多いですよね。同じ教授の授業だったり、同じ学部学科の授業だったり...クラスタリングで良い感じに分かれそうなポテンシャルを感じさせます。
ということで、今回の記事の趣旨は大学(院)のシラバスに書いてある授業内容から授業をクラスタリングしてみようというものです。
具体的には、各授業シラバスに記載してある講義説明をスクレイピング
→janomeで分かち書き&諸々の前処理
→Doc2Vecで講義単位で分散表現を得る
→PCAしてKmeansでクラスタリング
→クラスタリング結果をパッと見て確かめるためにwordcloudで可視化(おわり)
という内容になっています。

さてどこの大学のシラバスでもいいのですが、今回はNAISTの公開シラバスを使用します。

サイトからシラバスをスクレイピングする

まず公開シラバスのサイトから授業内容を説明しているテキストをスクレイピングするところから始めます。
手順としてはまずBeautifulSoupでhtmlを取得&パースした後に、xpathで使いたい箇所を指定して持ってきます。

以下のコードでは次のようなことを行なっています。NAISTの公開シラバスには授業タイトルに個別のページへのリンクが埋め込まれているのでまずそれらの一覧を取得し、個別ページごとに授業紹介のテキスト('教育目的'と'授業概要')を持ってきてDataFrame型にする、という処理です。

import requests
import pandas as pd
import pickle
import re
import os
import urllib.request
from bs4 import BeautifulSoup
from lxml import html


url = 'https://syllabus.naist.jp/subjects/preview_list'
res = requests.get(url)
soup = BeautifulSoup(res.text, 'html.parser')

lxml = html.fromstring(str(soup))

title = []
url = []

#授業名(title)とシラバスの授業ページへのurl(url)
title.append(lxml.xpath('//td[@class="w20pr"]/a/text()'))
url.append(lxml.xpath('//td[@class="w20pr"]/a/@href'))

head = 'https://syllabus.naist.jp/'

#'教育目的'と'授業概要'があるのでそれぞれpurpose, conceptとして集める
purpose = []
concept = []
for i in range(len(title[0])):
    print(head + url[0][i])
    res2 = requests.get(head + url[0][i])
    soup2 = BeautifulSoup(res2.text, 'html.parser')
    lxml2 = html.fromstring(str(soup2))



    purpose.append(lxml2.xpath('//th[text()="教育目的/授業目標"]/following-sibling::td[1]/text()')[0])
    concept.append(lxml2.xpath('//th[text()="授業概要/指導方針"]/following-sibling::td[1]/text()')[0])

df = pd.DataFrame({'授業名': title[0], 'シラバスURL':url[0], '教育目的': purpose, '授業概要': concept})

df['uni'] = df['教育目的'] + df['授業概要']
#今回は日本語シラバスのみを対象にしたいので、英語で書かれているなどのシラバスは削除
df.drop(df.index[[0,4,10,11,12,13,14,16,67,110]], inplace=True)
df = df.reset_index(drop=True)

前処理

ストップワードの除去

こちらの記事を参考にさせていただきました。
ストップワードの他に','や'/'などの記号も除去しています。


def download_stopwords(path):
    url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    if os.path.exists(path):
        print('File already exists.')
    else:
        print('Downloading...')
        # Download the file from `url` and save it locally under `file_name`:
        urllib.request.urlretrieve(url, path)

def create_stopwords(file_path):
    stop_words = []
    for w in open(path, "r"):
        w = w.replace('\n','')
        if len(w) > 0:
          stop_words.append(w)
    return stop_words    

path = "stop_words.txt"
download_stopwords(path)
stop_words = create_stopwords(path)

#option:ストップワード以外の不要な文字
option = ['(', ')', '(', ')', ',', '、', '。', '/', '/', ':', ':', '[', ']', '・', '.', '(']
stop_words = stop_words + option





分かち書き

分かち書きのツールとしてmecabもありますが手軽に使用できるのでjanomeの方が好きです。


from janome.tokenizer import Tokenizer
t = Tokenizer()

def tokenizer(text):
    text = re.sub('(\d|[a-zA-Z])+', '', text)
    text = re.sub(' ', '', text)
    return [token for token in t.tokenize(text, wakati=True)]


#分かち書きしたテキストに対してストップワードを削除
def stopwords(wakati):
    return [t for t in wakati if t not in stop_words]


#前処理
df['wakati'] = df['uni'].apply(tokenizer)
df['wakati'] = df['wakati'].apply(stopwords)

分散表現と次元削減と

今回クラスタリングしたい対象、もとい分散表現を獲得したい対象は各講義説明でした。文章のまとまりを埋め込みたいのでDoc2Vecを使用します。
獲得した分散表現をもとにクラスタリングを行います。
分散表現をそのままkmeansに入れてもクラスタリングは出来るのですが、今回は次元削減を行ってからクラスタリングすることにします。
というのも今回は300次元に埋め込みをしているのですが、高次元になればなるほど距離関係はあまり意味をなさないものになってしまうようです。参考
Kmeansは各埋め込み表現からの距離をもとにクラスターを形成するアルゴリズムのためこれでは上手くいきそうにありません。
そこでここではDoc2Vecで得た分散表現をPCAで2次元に次元削減してからkmeansを行う、といったステップを踏むことで難点に対処します。

#分かち書きしたシラバスからDoc2Vecで分散表現を得る
#Kmeansでクラスタリングするときに次元数が多いと距離関係を有効に使えなさそうなので、PCAで次元削減する。
from gensim.models.doc2vec import Doc2Vec
from sklearn.decomposition import PCA
model = Doc2Vec.load("jawiki.doc2vec.dbow300d.model")

df['vec'] = df['wakati'].apply(model.infer_vector)
pca = PCA(n_components=2)

data_pca= pca.fit_transform(df['vec'].values.tolist())

Kmeans

data_pcaに格納した次元分散後の分散表現からクラスタリングを行うわけですが、kmeansを使用するにあたってクラスター数を決めなければいけません。
適当に決めてもクラスタリングは出来ますが、良さげなクラスター数を決める方法がいくつかあります。
その中で今回はエルボー法を使用してクラスター数を決めることにしましょう。
実装はこちらの記事を参考にさせていただきました。


from sklearn.cluster import KMeans
import matplotlib.pyplot as plt

inertia = []

for i  in range(1,30):               
    km = KMeans(n_clusters=i,![スクリーンショット 2021-01-30 19.50.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1066425/c310f5be-5165-1c22-ff14-f4f73775889c.png)

                init='k-means++', 
                n_init=10,
                max_iter=300,
                random_state=2020)
    km.fit(data_pca)
    inertia.append(km.inertia_)

plt.plot(range(1,30),inertia,marker='o')
plt.xlabel('Number of clusters')
plt.ylabel('inertia')
plt.show()

結果がこちら

!

縦軸のinertiaはそれぞれの点が属しているクラスターの重心からの二乗距離の合計を表します。つまりinertiaが小さければ小さいほど各データ点はそのクラスターグループの重心に集まっているので、よくクラスタリングされていると解釈できます。
上記のことを考えると当然かもしれませんが、グラフを見てもわかる通りクラスターの数を増やせば増やすほどinertiaが減少していますね。
しかしクラスターの数を増やしすぎても各クラスターごとの特徴がはっきりとしなく本末転倒かもしれないので、今回はクラスターの数を5に設定することにします。

n_clusters = 5
kmeans_model = KMeans(n_clusters=n_clusters, verbose=1, random_state=2020, n_jobs=-1, max_iter = 500)

kmeans_model.fit(data_pca)
label = kmeans_model.labels_
df['kmeans'] = label

WordCloudで可視化

先ほどまでで作業は一通り終わりました。ここでは結果の解釈のために各クラスターごとにWordCloudを適用してクラスターごとの特徴を眺めてみます。
分かち書きしたテキストをクラスターごとにWordCloudに入れてもいいのですが、意味がなるべくはっきり伝わるように名詞だけを取り出して可視化してみます。
結果はこちら↓

グループ1

グループ2

グループ3

グループ4

グループ5

解釈

結構よく分かれているのではないでしょうか?
グループ1は研究や議論や論文、ビジネスプラン、発表というワードが目立つことからアクティブな授業が多いようです。たくさん喋りそう(こなみ)
グループ2には大きく技術とありますね。ソフトウェア、情報、科学などといったワードもあります。情報系の講義が多いようですね。
グループ3はグループ1と似ています。ただグループ1と違うのは、グループ3には講義、授業、学習、考えといった言葉が多いことです。専門的な用語はあまり現れないので基礎レベルの座学系の講義が集まっているのかもしれません。
グループ4には物理や化学系の講義が集まっているようですね。門外漢なのでこれ以上はわかりませんが…。
グループ5は明らかにバイオ〜という感じですね。細胞の主張が激しいです。

ちなみにの補足知識なのですが、NAISTは情報科学領域、物質創成科学領域、バイオサイエンス領域という領域に分かれているようです。
そのことを踏まえると今回の結果はそれらの区分も反映した結果になっていそうです。

今回は以上です。お読みいただきありがとうございました。