LDAvisの日本語文字化け

LDAvisの文字化けの原因


LDAvisは以下の5つのファイルを作成する。

  1. index.html
  2. lda.css
  3. d3.v3.js
  4. ldavis.js
  5. lda.json

この中で、lda.jsonにLDAモデルから作成されたデータが入っている。
LDAvisパッケージの関数createJSONのvocabに日本語を設定すると文字化けが起きて画面に何も表示されない。原因は文字コード体系にある。
json <- createJSON(
 phi = posterior(model)$terms
 theta = posterior(model)$topics
 vocab = colnames(posterior(model)$terms)
 doc.length = rowSums(as.matrix(dpc_dtm))
 term.frequency = colSums(as.matrix(dpc_dtm))
 mds.method = svd_tsne
)
リスト1.createJSON

LDAvisはcharset="utf-8"でindex.htmlを作成する。したがってlda.jsonもutf-8で作成されなければならない。しかし、Windows版のR Studioの内部コード (option()$encoding) はShift_JIS (CP932)なので、lda.json中の日本語部分はShift_JISで書き出され、index.htmlとの間に齟齬が生じて動かなくなるようだ。

対策

調べた限り、LDAvisにはエンコードを指定するオプションはないようなので、Windowsの内部コードを一時的にUTF-8に変更して処理を行い、終わったら元に戻す(参考サイト)。
tmp.enc <- options()$encoding
options(encoding = "UTF-8") 

dpc_df <- read.table("input.csv", header=T, sep=",", fileEncoding = "CP932")

・・・LDAvisのメイン処理・・・

options(encoding = tmp.enc) 
リスト2.LDAvisの処理

R Studioの内部エンコードはoptions()$encodingに格納されているので一時的にこの値をテンポラリ変数tmp.encに退避したのちにoptions(encoding = "UTF-8")でUTF-8を設定する。
読み込むCSVファイルがShift-JISで作成されている場合はread.tableにオプションfileEncoding = "CP932"を付けて読み込む。ファイルのエンコードが"UTF-8"であればこれは不要である。
その後、LDAvisのメイン処理を行って、処理が終わったら退避していた元のエンコードに戻す。

トピックモデルの可視化 - LDAvis

概要


LDAの結果を可視化するツールLDAvisを使ってモデルを俯瞰する。

モデルの構築


モデルを構築するRスクリプトをリスト1に示す。

################################################
#
# モデルの生成
#
################################################
# log likelihood の最大値を与えるトピック数=350
topic_num = 350
burnin = 500
iter = 1000
keep = 10
model <- LDA(dpc_dtm, k = topic_num, method = "Gibbs", control = list(burnin = burnin, iter = iter, keep = keep) )
model

LL <- data.frame(
  topic_nums = seq(10, 1500, 10),
  logLiks = model@logLiks
)

################################################
#
# 対数尤度の変化
#
################################################
ggplot(LL, aes(x=topic_nums, y=logLiks)) + geom_line() + labs(title="Evolution of log likelihood along with iterations", x="iterations",y="log likelihood") + theme(plot.title=element_text(vjust=0.5, hjust=0.5)) 
リスト1.モデルの構築

モデルの構築にあたってGibbsサンプリングを用い、トピック数は350とした。これはブログ記事「topicmodelsパッケージでLDA(パーツ作りました)」の図4で、トピック数が350のとき対数尤度の調和平均が最大になったことに基づいている。つまり、モデル選択の基準として尤度を最大にするという基準を用いている。
また、LDAのオプションであるburninやiterの妥当性を確かめるためにiterationが10, 20, ..., 1500に対する対数尤度を求めてプロットしたのが図1である。

図1.対数尤度のiteration依存性

図1から、対数尤度はiterationsが200あたりで急激な増加から緩やかな増加に転じており、500を超えたあたりではほぼ飽和しているとみなせるのでburnin=500, iter=1000という設定は妥当なものと考えられる。

LDAvisの結果

作成したモデルをLDAvisを使って可視化するスクリプトをリスト2に示す。

################################################
#
# LDAvisで使うデータを作成する関数
#  fit: LDAモデル
#  doc_term: DocumentTermMatrix
#
################################################
buildVisdata1 <- function(fit, doc_term) {
  phi <- posterior(fit)$terms %>% as.matrix
  theta <- posterior(fit)$topics %>% as.matrix
  vocab <- colnames(phi)
  doc.length <- rowSums(as.matrix(doc_term))
  term.frequency <- colSums(as.matrix(doc_term))
  params <- list(phi = phi,
                 theta = theta,
                 doc.length = doc.length,
                 vocab = vocab,
                 term.frequency = term.frequency)
  return(params)
}

install.packages("tsne")
library("tsne")
svd_tsne <- function(x) tsne(svd(x)$u)

install.packages('servr') 
library(servr)

# LDAvis
install.packages("LDAvis")
library(LDAvis)
VisualizeModel <- function(fit, doc_term, dir) {
  params <- buildVisdata1(fit, doc_term)
  json <- createJSON(phi = params$phi,
                     theta = params$theta,
                     doc.length = params$doc.length,
                     vocab = params$vocab,
                     term.frequency = params$term.frequency,
                     mds.method = svd_tsne
  )
  serVis(json, out.dir = dir, open.browser = TRUE)
}

VisualizeModel(model, dpc_dtm, './')
リスト2.

リスト2を実行した結果を図2に示す。

図2.LDAvisの実行結果(トピック数=350)

左図はトピックの全体像を示す。各円がトピックを表し、その面積はコーパス中におけるそのトピックの相対的な割合(prevalence)に比例して描かれている。各円に付けられた数字は割合の大きい順につけられた番号である。

右図は選択されたトピックに関連のある単語を横棒グラフで示したものである。灰色の棒はコーパス全体における各単語の出現頻度を表しており、赤色の棒は選択されているトピックに固有な出現頻度を表している。

図2で問題なのは

  1. 右図で単語(疾患)がコードで表されており、疾患名がわからない
  2. トピックに関連する疾患がたった1つしかないように見える

点である。
まず1については、buildVisdata1でvocabへcolnames(phi)、すなわちposterior(fit)$termsのカラム名を代入しているので、疾患名ではなくDPCの疾患コードが設定され、当然の結果になっている。
ここに疾患名を出すのであれば、buildVisdata1でvocabへ疾患名リストを設定する必要がある。疾患名リストが疾患コード(DPCコード)と対応付けられていればそれを使えばよい。
まず、現在のvocabには
colnames(posterior(model)$terms)
が設定されている。
次に、疾患名と疾患コードの関係は、dpc_tibbleから(DPC2, 疾患名)を抽出してDPC2で圧縮すればよい。最後にDPC2をキーとして結合し、疾患名だけ抜けば疾患名リストが完成する。以上の処理をリスト3に示す。

disease_dic_tibble <- dpc_tibble %>%
  tidyr::unite(疾患手術名, c('疾患名', '手術')) %>%
  dplyr::select(DPC2, 疾患手術名) %>%
  dplyr::distinct(DPC2,.keep_all=TRUE) %>%
  dplyr::arrange(DPC2)
  
terms_tibble <- tbl_df(colnames(posterior(model)$terms))
colnames(terms_tibble) <- c("DPC2")
vocab_tibble <- dplyr::left_join(terms_tibble, disease_dic_tibble, by="DPC2")
disease_names <- vocab_tibble$疾患手術名
リスト3.疾患名リストの作成

こうして求めた疾患名リスト「disease_names」をリスト2のbuildVisdata1内でvocabへ設定して実行してみた。すると何も表示されない。そこで、VisualizeModelが作成したJSONデータ「lda.json」を見てみると、文字コードがShift_JISになっている。
そこで、index.htmlのcharsetをutf-8からShift_JISに変更してみた。すると今度は”d3がない”といった関係のないエラーが出る。どうも、Shift_JISではd3(グラフィックスライブラリ)はだめなようである。ということは、CSVファイルから作り直さなければならないということらしい。
WindowsのR-StudioはデフォルトでShift-JISになっているので、このあたりから設定しなおす必要がある(ああ大変だ・・・Pythonではこんな面倒はなかったのに・・・日本語が絡むと絶対に何か起きる)。

そこで、その場しのぎで「lda.json」の文字コードを手作業でShift-JISからutf-8に変換してみた。その結果を図2bに示す。

図2b.疾患名を表示したLDAvis
これは下記のURLから操作できる。

http://hinfokumw.html.xdomain.jp/lda/LDAvis/350/

トピックに含まれる疾患の数


まず、トピックごとの疾患の出現確率φを抽出する。それを行うのがposterior(model)$termsである。リスト4は各トピックがどのくらいの疾患を含んでいるか調べるスクリプトである。
phi <- posterior(model)$terms
threshold <- 1 / model@wordassignments$ncol # 1/V
terms_per_topic <- unlist(
  lapply(1:model@k, function(k) {
    return(sum(phi[k,] > threshold))
  })
)
df <- data.frame(value = terms_per_topic)
ggplot(df, aes(x = value)) + 
  geom_histogram(binwidth = 1) + 
  annotate("text", x=25,   y=40, label=paste("k =", model@k), hjust=0) +
  annotate("text", x=25,   y=38, label=paste("トピックに含まれる最大単語数 =", round(max(terms_per_topic), digit=1)), hjust=0) +
  annotate("text", x=25,   y=36, label=paste("トピックに含まれる最低単語数 =", round(min(terms_per_topic), digit=1)), hjust=0) +
  annotate("text", x=25,   y=34, label=paste("トピックに含まれる平均単語数 =", round(mean(terms_per_topic), digit=1)), hjust=0) +
  annotate("text", x=25,   y=32, label=paste("threshold = 1 / ", model@wordassignments$ncol), hjust=0) +
  labs(title = "トピックを構成する単語(疾患)の数", x = "単語の数", y = "頻度")
リスト4.トピックに含まれる疾患数の可視化スクリプト

posterior(model)$termsはトピック数×疾患数のmatrixで、(k, v)要素にはトピックkに含まれる疾患vの割合(φkv)が格納されている。
φkvの閾値thresholdとして1 / model@wordassignments$ncolを設定する。ここで、model@wordassignments$ncolには疾患数として801という値が入っているので、閾値はthreshold=1/801=0.00125になる。
上記のトピック×疾患matrixの要素がこの閾値を超えるものだけをそのトピックの構成疾患とみなして、各トピックがいくつの疾患で構成されているかを調べる。それが、terms_per_topicで、添え字がトピック番号、要素の値がそのトピックを構成する疾患数になっている。こうして求めたterms_per_topicをもとにヒストグラムを描いたのが図3である。
図3.トピックに含まれる疾患数
トピックに含まれる平均疾患数は12疾患で(中央値:7、最頻値:5)図に示すようにロングテールの分布を示す。

病院のトピック分布


同様にして各病院がいくつのトピックから構成されているかを求めてヒストグラムにするスクリプトをリスト5に、その結果を図4に示す。
theta <- posterior(model)$topics
threshold <- 1 / model@k
topics_per_document <- unlist(
  lapply(1:model@wordassignments$nrow, function(d) {
    return(sum(theta[d,] > threshold))
  })
)
df <- data.frame(value = topics_per_document)
ggplot(df, aes(x = value)) + 
  geom_histogram(binwidth = 1) + 
  annotate("text", x=25,   y=40, label=paste("k =", model@k), hjust=0) +
  annotate("text", x=25,   y=38, label=paste("病院が含む最小トピック数 =", round(min(topics_per_document), digit=1)), hjust=0) +
  annotate("text", x=25,   y=36, label=paste("病院が含む最大トピック数 =", round(max(topics_per_document), digit=1)), hjust=0) +
  annotate("text", x=25,   y=34, label=paste("病院が含む平均トピック数 =", round(mean(topics_per_document), digit=1)), hjust=0) +
  annotate("text", x=25,   y=32, label=paste("threshold = 1 / ", model@k), hjust=0) +
  labs(title = "病院に含まれるトピックの数", x = "トピックの数", y = "病院の数")
リスト5.病院のトピック分布を求めてヒストグラムにするスクリプト

閾値は1 / model@kとした。ここで、model@kはトピック数(350)である。

図4.病院のトピック分布
図4に示すように、病院が含むトピック数の最大は115で最小は3、平均は57である(中央値も57)。
図4から次のことがわかる。

  1. 図4はほぼ左右対称である
  2. 最大トピック数は350であるが、最も多いトピックを含む病院でも115トピックしか含んでいない

トピックを特徴づける疾患

各トピックに含まれる疾患の割合が閾値(1 / model@wordassignments$nrow=1/1664)を超えるものについて、その割合の降順に並べたリストを作成するスクリプトをリスト6に示す。
install.packages("radiant.data")
library(radiant.data)

phi_tibble <- dplyr::as_tibble(posterior(model)$terms, rownames=NA) %>%
  radiant.data::rownames_to_column('topic') %>%
  tidyr::gather(key = DPC2, value = phi, -topic)

phi_tibble$topic <- as.integer(phi_tibble$topic)

phi_with_disease_name <- phi_tibble %>%
  dplyr::filter(phi > 1 / model@wordassignments$nrow) %>%
  dplyr::arrange(topic, desc(phi)) %>%
  dplyr::left_join(disease_dic_tibble, by = "DPC2")

write.csv(phi_with_disease_name, "./phi_with_disease_name.csv")
リスト6.トピックに含まれる疾患をリストアップするスクリプト

トピック×疾患マトリックスposterior(model)$termsをtibble型に変換するためにas_tibbleを使っている。これは、tbl_dfだと行名が失われるからである。as_tibbleを使うと、rownames=NAとすることにより、行名を残すことができる(rownamesを省略すると行名は除去される)。
tibble型に変換したらrownames_to_columnによって行名を列に変換し(列名は引数に指定した'topic')、gatherによってデータフレームの構造を"列型"から"行型"に変換する。ここで"列型"というのは、データが列方向に伸びているデータ構造のことで、列をバラバラに切り離して行方向にデータを並べたものを"行型"と呼んでいる。ちょうどリレーショナルデータベースにおいて繰り返しを取り除く第一正規化に似た処理である。
gatherの第一引数keyには”列型”の列名を値とする項目に付ける名前を指定する。ここではDPC2としている。第2引数valueには列名に対応する値に付ける名前を指定する。ここではphiとしている。最後の引数-topicは”列型”のデータフレームの列topic以外すべての列をバラバラにすることを意味している(図5)。

図5.dplyr::gather()関数のイメージ


リスト7にはリスト6の実行結果を示す。
> phi_with_disease_name
# A tibble: 5,173 x 4
   topic DPC2          phi 疾患手術名                       
   <int> <chr>       <dbl> <chr>                            
 1     1 05017002 0.631    閉塞性動脈疾患_02                
 2     1 05013099 0.323    心不全_99                        
 3     1 04008099 0.0150   肺炎等_99                        
 4     1 06003501 0.00226  結腸(虫垂を含む。)の悪性腫瘍_01
 5     1 06016001 0.00192  鼠径ヘルニア_01                  
 6     1 05005099 0.00140  狭心症、慢性虚血性心疾患_99      
 7     1 06002097 0.000881 胃の悪性腫瘍_97                  
 8     2 09001002 0.553    乳房の悪性腫瘍_02                
 9     2 09001005 0.333    乳房の悪性腫瘍_05                
10     2 06003501 0.0559   結腸(虫垂を含む。)の悪性腫瘍_01
# ... with 5,163 more rows
> 
リスト7.各トピックを特徴づける疾患


医療分野でトピックモデルを利用した研究

はじめに


医療分野に限定してトピックモデル・・・なかでもLDA、およびその派生・・・を利用した文献を漁ってみた。
最初はCiNiiやPubMedで検索していたが、フリーで手に入るものは圧倒的にGoogleの検索結果が充実している。
トピックモデルと言えばテキストマイニングが多いが、医療分野ではカルテをマイニングするだけでなく、問診票や電子レセプトをLDAやその派生形を使って解析する例も見られ、大変興味深い。
今後少しずつ読んでまとめていきたい。


[1] 問診データに対する潜在トピックモデルに基づく健診データ解析


問診票をドキュメント、問診項目を単語、被験者の生活習慣を潜在トピックとしてLDAを用いてトピック分析を行ったという研究。
被験者を出現率が最大となるトピックで分類し、各被験者群の間で検査値データに違いがあるかどうかでモデルの評価を行っている。
一般に、トピックモデルは教師なし学習であるため評価が難しいとされている。さらにトピックを人間が解釈するのも難しいとされている。したがって何をもってモデルが妥当か評価するのは難しい。そこでよく行われるのが外部基準を使って適切に分類されているかを検証するという手法である。この研究では外部基準として検査値を使用している。つまり、トピックモデルで分類された被験者群の間で検査値に有意な違いがあるかどうかでモデルの妥当性を検証している。
しかし、そもそも「トピックモデルで分類された被験者」とは一体何だろう?トピックモデルは潜在トピックを抽出する手法であり、対象(この場合は被験者)を分類するものではない(べつに分類してもいいけど・・・)。しかし、潜在トピックに意味付けができない以上、それらがどのくらい含まれているかを議論しても意味がない。そこで、最も含まれる割合が高いトピックで被験者を分類している。また、そのようにしてもよいことをネットワークグラフでクラスタリングされた群と比較して検証している。ここで、ネットワークグラフを構築する上で被験者間の類似性をJensen-Shannon divergenceによって計算し、それがある閾値(ここでは0.05としている)より小さい場合にノード間(被験者間)にエッジを設けてネットワークグラフを作成している。クラスタリングはNewmannアルゴリズムを使って被験者を群に分け、それが最大トピック成分で分類したものと同等であることを示している。
この文献には様々な手法(Jensen-Shannon divergence, ネットワークグラフ理論, Newmannアルゴリズム, concordance)が用いられており、非常に示唆に富む。何より問診票の問診項目を単語に見立ててトピックモデルを適用しているのが秀逸である。考えてみれば、Recommendation分析も商品アイテムを単語とみなして解析を行っているのだから、それと同じと言えば同じであるが。

畠山 豊, 宮野 伊知郎, 片岡 浩巳, 中島 典昭, 渡部 輝明, 奥原 義保: 問診データに対する潜在トピックモデルに基づく健診データ解析. 医療情報学, 33(5), 2013.
電子レセプトをドキュメント、診療行為を単語、疾患を潜在トピックとみなして、最も医療資源を使った疾患を推定するといった内容の研究。ただし、ここではただのLDAではなく"Labeled LDA"なるものを使っている。
Labeled LDAというものをよく知らないが、この論文によると「ラベル付きの文書コレクションを生成するためのプロセスを記述する確率的トピックモデル」と書いてある。
図1が論文中に示されたグラフィカルモデルである。


通常のLDAに比べてΦλθが付け加わっている。λはトピックの有無を示すインディケータで、Φはその事前分布ということである。この図でλwにハッチが付けられているので、これら2つが観測されるデータなのであろう。
どのようにしてモデルを生成するのかこの論文を読んでもよくわからなかった。特にλの正体がわからない。ノーテーションも λ と思われる箇所が になっていたりして、間違っているのか、それとも自分の理解不足なのかもわからない。
検証は、モデルがはじき出したトピック(すなわち疾患)が電子レセプトに人手でつけられた疾患(DPC病名と思われる)に一致するかどうかで行っている。ここで、「モデルがはじき出したトピック」は、単に構成比率(つまりθ_{d,k})が最大の疾患というのではなく、料金(点数)が最大の疾患としている点がポイントである。「最も医療資源を投入した」というのを「最もコストのかかった」と解釈してのことだろう。
しかし、この「コスト(点数)」はどのようにしてモデルに取り込んだのであろうか?通常の文書を対象としたLDAでは単語の出現頻度が重みの役割を果たす。この研究では医療行為の点数を頻度の代わりに使っているのだろうか?そのあたりが読み取れない。
また、盛んに疾患(つまりλ?)が分かっていなくとも問題ないといった記述がみられるが、これがどういうことなのかわからない。ラベル付きLDAなんだからラベル(つまり疾患)がデータとして与えられなかったら機能しないだろうと思うのだが。
モデルの評価方法は文献[1]と同じで「正解」(人手で付けたDPC病名)とモデルが分類した結果を比較している。
評価指標はRecall(再現率)、Precision(精度)、F-value(F値)で、SVMとナイーブベイズと比較し、かなり良い結果が得られたと述べている。
ここでもトピックモデルを分類に使っているが、文献[1]と違って構成比率(つまりθ_{d,k})が最大のトピックには「最も医療資源を使った疾患」という大義名分があるのでトピックモデルの使い方としては間違っていないと感じた。

Yasutaka Hatakeyama, Takahiro Ogawa, Hironori IKEDA, Miki Haseyama: A Most Resource-Consuming Disease Estimation Method from Electronic Claim Data Based on Labeled LDA. IEICE Transactions on Information and Systems E99.D(3):763-768, 2016.

【補足】

もう一度読み返してみた。前読んだ時分からなかったことがいくらか分かるようになった。通常のLDAではドキュメントがすべてのトピックを含みうるが,Labeled LDAではドキュメントごとに含みうるトピックが異なり,それが「ラベル」ということらしい。つまり,DPCデータ(請求データ)にはいくつかの疾患が割り当てられており(併存症)それが当該DPCデータにおける「ラベル」になる。Labeld LDAは,これらの疾患の構成比率(含有率)θ_{d,k}と疾患ごとの診療行為の割合β_{k,w}を求め,(8)式によって与えられた請求データにおける疾患kが診療行為w_{d,i}を含む確率を求める。さらに,当該請求データの診療行為が疾患kに投入されたものであるか否かのインディケータを(7)式から計算し,それを用いて(6)式から疾患kに費やしたコスト(点数)を計算している。これが最大になる疾患kが最も医療資源を投入した疾患というわけである。
(6)式の右辺は内積であることに注意する。point(w_{d})は請求dの診療行為に対応する点数を要素に持つ行ベクトルである。一方,I(k,w_{d})は,請求dの診療行為が当該疾患kに投入されたものかどうかを示すインディケータを要素とする列ベクトルである。これらの内積を計算することで当該請求において疾患kに投入された医療資源の点数が計算できる。

[3]Adverse Drug Reaction Prediction with Symbolic Latent Dirichlet Allocation


医薬品文書をドキュメント、薬物副作用用語(ADR terms)を単語、医薬品文書にまたがって頻繁に現れる薬物副作用用語の集合をトピックとみなして3種類のLDA類似モデルを構築し、副作用(ADR)を予測するといった内容の研究である。
副作用の予測を行うために、まず対象とする医薬品を特徴づけるADRトピック分布を求め、次に薬物構造の特徴(features)をADRトピック分布と関連付けるために予測モデルを構築する。その後、その医薬品に関連する副作用を、そのトピック分布を通じて予測することができる。
この論文では3つのモデルが検討されている。まず、基本となるモデルのグラフィカルモデルを図1に示す。

図1.ベースモデル(The base model)

ベースモデルはLDAをそのまま利用したものである。

次にドメイン知識を採り入れた正規化モデル(The regularized rodel)のグラフィカルモデルを図2に示す。

図2.正規化モデル(The regularized rodel)
ドメイン知識として階層ADRオントロジーシステムを利用する。これは上位のADRが下位ADRの抽象化バージョンになるように階層化されたオントロジーで、これに違反した場合にペナルティーを与えることによってドメイン知識を反映させるものである。
具体的には同じトピックを持つ子ADRが異なる親ADRを持つ場合にペナルティーを科す。

最後に混合入力モデル(The mixed input model)のグラフィカルモデルを図3に示す。

図3.混合入力モデル(The mixed input model)

これは、入力となるADR用語と薬剤構造の特徴(feature)を別々に学習する代わりにADR用語と薬剤構造の特徴(図3のx)の特徴の両方を医薬品文書の単語として扱う。これによって学習速度が大幅に向上する。

構築したモデルはADReCSという副作用データベースを用いて評価を行っている。そして他の手法(lasso、CC: canonical correlation analysis)とROCのAUC(Area Under Curve)やPR(Precision-Recallのことだろうか?)のAUCを使って比較している。
ということは、やはり外的な基準(教師データ)を用いて分類問題に帰着させているのだろうか・・・。このあたりが読み取れなかった。


Cao Xiao, Ping Zhang, W. Art Chaowalitwongse, Jianying Hu, Fei Wang: Adverse Drug Reaction Prediction with Symbolic Latent Dirichlet Allocation. Proceedings of the Thirty-First AAAI Conference on Artificial Intelligence (AAAI-17), 2017.



topicmodelsパッケージでLDA(パーツ作りました)

topicmodelsパッケージでLDAを行う際のパーツを作ってみた。

DTM

分析対象のDTMです。DPCデータです。

> dpc_dtm
<<DocumentTermMatrix (documents: 1664, terms: 801)>>
Non-/sparse entries: 167793/1165071
Sparsity           : 87%
Maximal term length: 8
Weighting          : term frequency (tf)
> dpc_dtm$nrow
[1] 1664
> dpc_dtm$ncol
[1] 801
リスト1.分析対象のDPCデータのDTM

モデルの作成


トピック数を10から100まで10ずつ増やしながらモデルを作ってみました。リスト2はそのスクリプトです。
topic_nums = c(10, 20, 30, 40, 50, 60, 70, 80, 90, 100)

burnin = 500
iter = 1000
keep = 100

models <- lapply(topic_nums, function(topic_num){
  model <- LDA(dpc_dtm, k = topic_num, method = "Gibbs", control = list(burnin = burnin, iter = iter, keep = keep) )
  return(model)
})
リスト2.モデルの作成

モデルの保存


リスト3は作成したモデルをファイルに保存するスクリプトです。
save(models, file = "models-10-100.rda")
リスト3.モデルをファイルに保存

保存したモデルはloadで読み込むことができます。
load("models-10-100.rda")
リスト4.モデルの読み込み

対数尤度のトピック数依存性


対数尤度の調和平均を計算してトピック数依存性をグラフ表示するスクリプトです。
logLiks_harmonicMean <- lapply(models, function(model){
  logLiks <- model@logLiks[-c(1:(burnin/keep))]
  return(harmonicMean(logLiks))
})

LL <- data.frame(
  topic_nums = topic_nums,
  logLiks = unlist(logLiks_harmonicMean)
)
ggplot(LL, aes(x=topic_nums, y=logLiks)) + geom_line()
リスト5.対数尤度のトピック数依存性をグラフ表示

実行結果を図1に示します。対数尤度はトピック数の増加とともに増大します。とても収束しそうにありません。過適合しているのでしょうか。

図1.対数尤度の調和平均のトピック数依存性

対数尤度の変化


モデル作成時に対数尤度がどのように変化するかをトピック数ごとにグラフにするスクリプトです。これを見ればburninはどのくらい必要かがわかります。

logLiks_array <- lapply(models, function(model){
  logLiks <- model@logLiks
  return(logLiks)
})

iter_array <- lapply(topic_nums, function(topic){
  return(seq(keep,burnin+iter,keep))
})

topic_array <- lapply(topic_nums, function(topic){
  return(rep(topic, length(seq(keep,burnin+iter,keep))))
})

LL <- data.frame(
  topic = factor(unlist(topic_array)),
  iter = unlist(iter_array),
  logLiks = unlist(logLiks_array)
)

ggplot(data=LL, aes(x=iter, y=logLiks, colour=topic)) +
  geom_line()
リスト6.対数尤度の変化(トピック数別)

リスト6の実行結果を図2に示します。トピック数の増大に伴い対数尤度は大きくなっていきますが、その勢いは小さくなっているようです。また、収束に必要なburninもトピック数に応じて増えているように見えます。

図2.対数尤度の変化


トピックモデルで最適なトピック数を決定する方法

トピックモデルで最適なトピック数を決定する方法について調べた。

optimal_k


トピック数を変えながらtopicmodelsを使ってモデルを作成し、データから対数尤度の調和平均を求め、最適なトピック数を求める。
この方法を使ったサンプルがTopic Models Learning and R Resourcesにある。
#Control List
control <- list(burnin = 500, iter = 1000, keep = 100, seed = 2500)
(k <- optimal_k(dpc_dtm, 40, control = control))
リスト1. optimai_k

関数optimal_kが最適なkを求める関数である。ここで、dpc_dtmはDocumentTermMatrixである。これを実行すると最適なトピック数 k とともに、対数尤度の調和平均のトピック数依存性のフラフが表示される。
dpc_dtmのプロフィールを以下に示す。
<<DocumentTermMatrix (documents: 1664, terms: 801)>>
Non-/sparse entries: 167793/1165071
Sparsity           : 87%
Maximal term length: 8
Weighting          : term frequency (tf)
リスト2. DocumentTermMatrixの概要

これはDPC参加病院のみ(1664病院、疾患別手術801件)を対象として作成したDocumentTermMatrixである。
これに対してリスト1を実行したところ、全部で10時間25分かかった。
結果を図1に示す。

図1.optimal_kの実行結果
図から、対数尤度の調和平均は飽和していないことがわかる。もう少しトピック数を増やして調べる必要がある。
しかしながら、optimal_kは、トピック数を2~最大トピック数まで1ずつ増やしながら対数尤度の調和平均を計算しているため、非常に時間がかかる。せめてトピック数を間引きながら増やすことができればよいのだが、関数仕様にそのようなパラメータはない。

そこで、リスト1のcontrolにおけるburninとiterを調整できないか検討してみる。具体的には、LDAをいくつかのトピック数で実行して、対数尤度の推移をプロットし、どのくらいの繰り返し (iter) で飽和するか調べ、最適なburninとiterを決定する。リスト1では、burnin=500、iter=1000になっているが、これを少しでも小さくできれば、optimal_kをもっと高速にできるかもしれない。
これを行うために以下のRスクリプトを作成した。
fitted <- LDA(dpc_dtm, k = 40, method = "Gibbs", control = list(burnin = 500, iter = 1000, keep = 50) )
LL <- data.frame(
  topic_nums = seq(1, 1500, 50),
  logLiks = fitted@logLiks
)
ggplot(LL, aes(x=topic_nums, y=logLiks)) + geom_line()
リスト3. 対数尤度の推移
実行結果を図2に示す。
図2.対数尤度の推移(トピック数=40)
図から分かるように最低でもburnin=250, iter=500は必要で、burnin=500, iter=1000くらいはあった方が良いと考えられる。

したがって、burninとiterの最適化は諦めて、トピック数の間引きを考える。そのために、リスト4のようなRスクリプトを作成した。
topic_nums = seq(5, 100, 5)

burnin = 500
iter = 1000
keep = 100

models <- lapply(topic_nums, function(topic_num){
  model <- LDA(dpc_dtm, k = topic_num, method = "Gibbs", control = list(burnin = burnin, iter = iter, keep = keep) )
  return(model)
})
# モデルを保存
save(models, file = "models-b500-i1000-5-100-5.rda")

# 対数尤度の調和平均のトピック数依存性
logLiks_harmonicMean <- lapply(models, function(model){
  logLiks <- model@logLiks[-c(1:(burnin/keep))]
  return(harmonicMean(logLiks))
})

LL <- data.frame(
  topic_nums = topic_nums,
  logLiks = unlist(logLiks_harmonicMean)
)
ggplot(LL, aes(x=topic_nums, y=logLiks)) + geom_line()
リスト4.対数尤度のトピック数依存性(k=5,10,15,...,100)

リスト4はトピック数を5から始めて5ずつ増やしながら100まで対数尤度の調和平均を計算し、対数尤度の調和平均のトピック数依存性をグラフ化している。結果を図3に示す。
図3.対数尤度の調和平均のトピック数依存性(k=5~100)
図3を見るとトピック数が増えるにつれて対数尤度の調和平均は単調に増加しており、飽和しそうにない。
そこで、今度はトピック数を50から始めて50ずつ増やしながら500まで変化させて対数尤度の調和平均を求めてグラフにした。結果を図4に示す。

図4.対数尤度の調和平均のトピック数依存性(k=50~500)
図からトピック数が350のとき対数尤度の調和平均が最大になっていることがわかる。

図5は繰り返し数(iteration)が進むにつれて対数尤度がどのように変化するかをトピック数ごとに示したものである。

図5.対数尤度の変化
トピック数が大きくなるにつれて対数尤度が飽和するのに要するステップ数が増加する傾向が見られるものの、いずれも収束傾向にある。

Perplexity


これらの結果からトピック数を増やせば増やすほど対数尤度の調和平均は増加することが分かった。原因としてモデルがデータに過適合(オーバーフィッティング)していることが考えられる。

そこで、別の指標で最適なトピック数を求めてみる。ここでは、指標としてPerplexityを考える。ここでは、Topic models: cross validation with loglikelihood or perplexityに従ってPerplexityを計算する。

その前に、Perplexityとは何かについて説明する。
エントロピーとパープレキシティ」によれば、Perplexityとは「単語の平均分岐数を表しており・・・大きいほど,単語の特定が難しく,言語として複雑になる」というものである。1単語あたりのエントロピーをHとした場合、Perplexityは2Hになる。
そこで、さまざまなトピック数kに対してPerplexityを計算し、最小のPerplexityを与えるトピック数 k を求めてみる。
先述したTopic models: cross validation with loglikelihood or perplexity
には"Perplexity is a measure of how well a probability model fits a new set of data."という記述がある。つまり、Perplexityを計算するにはモデルを構築したデータ(すなわち訓練データ)の他に検証データが必要となる。
そこで、DTM形式のDPCデータの75%をランダムに抽出して訓練データとし、残りを検証データとする。

full_data  <- dpc_dtm
n <- nrow(full_data)

splitter <- sample(1:n, round(n * 0.75))
train_set <- full_data[splitter, ]
valid_set <- full_data[-splitter, ]
リスト5.全データを訓練データと検証データに分割(3:1)

次に、訓練データを用いてあるトピック数(ここでは10としている)に対してモデルを構築する。
topic_num = 10

burnin = 500
iter = 1000
keep = 100

model <- LDA(train_set, k = topic_num, method = "Gibbs", control = list(burnin = burnin, iter = iter, keep = keep) )
リスト6.モデルの構築
最後に訓練データと検証データに対してPerplexityを計算する。
PPL_train <- perplexity(model, newdata = train_set)
PPL_valid <- perplexity(model, newdata = valid_set)
リスト7. Perplexityの計算
これをあるトピック数から始めて少しずつ増やしながらPerplexityを計算し、最小を与えるトピック数が最適なトピック数と判断するという考え方である。
リスト8は、これを行うための"Using perplexity and cross-validation to determine a good number of topics"にあるスクリプトである。
install.packages("doParallel")
library(doParallel)
cluster <- makeCluster(detectCores(logical = TRUE) - 1) # leave one CPU spare...
registerDoParallel(cluster)

# load up the needed R package on all the parallel sessions
clusterEvalQ(cluster, {
  library(topicmodels)
})

folds <- 5
splitfolds <- sample(1:folds, n, replace = TRUE)
candidate_k <- c(10, 20, 30, 40, 50, 100, 200, 300, 400, 500) # candidates for how many topics

# export all the needed R objects to the parallel sessions
clusterExport(cluster, c("full_data", "burnin", "iter", "keep", "splitfolds", "folds", "candidate_k"))

# we parallelize by the different number of topics.  A processor is allocated a value
# of k, and does the cross-validation serially.  This is because it is assumed there
# are more candidate values of k than there are cross-validation folds, hence it
# will be more efficient to parallelise
system.time({
  results <- foreach(j = 1:length(candidate_k), .combine = rbind) %dopar%{
    k <- candidate_k[j]
    results_1k <- matrix(0, nrow = folds, ncol = 2)
    colnames(results_1k) <- c("k", "perplexity")
    for(i in 1:folds){
      train_set <- full_data[splitfolds != i , ]
      valid_set <- full_data[splitfolds == i, ]
      
      fitted <- LDA(train_set, k = k, method = "Gibbs",
                    control = list(burnin = burnin, iter = iter, keep = keep) )
      results_1k[i,] <- c(k, perplexity(fitted, newdata = valid_set))
    }
    return(results_1k)
  }
})
stopCluster(cluster)

results_df <- as.data.frame(results)

ggplot(results_df, aes(x = k, y = perplexity)) +
  geom_point() +
  geom_smooth(se = FALSE) +
  ggtitle("5-fold cross-validation of topic modelling with the 'DPC' dataset",
          "(ie five different models fit for each candidate number of topics)") +
  labs(x = "Candidate number of topics", y = "Perplexity when fitting the trained model to the hold-out set")
リスト8.Perplexityから最適なトピック数を決定する

昨日から動かしているけどなかなか終わらない。やっと終わったところで経過時間を見るとなんと所用時間は241541.88(秒)・・・日にち換算で約2.8日。とてもintensiveな計算だ。結果を図6に示す。

図6.Perplexity(リスト8の結果)
トピック数が100あたりまでは急激に減少し、100を超えたあたりからはなだらかに減少し、500に到達しても最小には達せず、引き続き減少傾向にある。
これを図4(対数尤度の調和平均)と比較すると、必ずしも極値は一致しないように思われる。

調べているとこんな記事があった。なんでもldatuningというパッケージがあって、モデルからトピック数を選択するいくつかの指標をはじき出してくれるらしい。その中に「Griffiths2004」というのがあって「要するに、トピック数をT、コーパス全体の単語をwとして、対数尤度logP(w|T)が最大になるTにすればいいじゃん、というアイデアである。」ということらしい。これは、もしかしてリスト4でやっていることなんだろうか?グラフを見ていると似ているなぁと思う。
リスト9にトピック数を50から50ずつ増やしながら500まで変化させたときの各種指標を計算するldatuningのスクリプトを示す。
install.packages("ldatuning")
library("ldatuning")

result <- FindTopicsNumber(
  dpc_dtm,
  topics = seq(from = 50, to = 500, by = 50),
  metrics = c("Griffiths2004", "CaoJuan2009", "Arun2010", "Deveaud2014"),
  method = "Gibbs",
  control = list(seed = 77),
  mc.cores = 2L,
  verbose = TRUE
)

# 可視化
FindTopicsNumber_plot(result)
リスト9.ldatuningによる4つの指標の計算と可視化(k=50~500)

図7にリスト9の実行結果を示す。

図9.4つの指標のトピック数依存性
図9を見ると、Arun2010とCaoJuan2009はトピック数が250~350あたりが最適で、Griffiths2004はトピック数が450でピーク、そしてDeveaud2014はトピック数が100でピークになっている。

LDAの場合、HDP(Hierarchical Dirichlet Process)というのがあって、これを使うとトピック数の自動決定が可能だそうである。しかし、RのHDP実装は無いみたいで、その代わり、GensimがPythonで実装している。加えてRからGensimを呼び出して利用できるようである。

※その後調べているとRにも"R pkg for Hierarchical Dirichlet Process"というのがあるようだ。しかし、"Works on MacOS and Linux, but may not install on Windows."と書かれている。

【補足】

関数optimal_kが何をしているかを知りたいならばソースがGitHubに公開されている。
また、出典となった論文はFindibg Scientific Topicsである。
トピック数の決定については"The input parameters for using latent Dirichlet allocation"や"Topic models: cross validation with loglikelihood or perplexity"で議論されている。

ChatGPT は、米国の医師免許試験に太刀打ちできるか?

A Gilson et al.: How Does ChatGPT Perform on the United States Medical Licensing Examination? The Implications of Large Language Models for ...