この投稿では、ReactとScala(PlayFramework)を利用してWordCloudを出力してみたことについて書きたいと思います。ソースはGitHubにあります。
WordCloudとは
文章中で出現頻度が高い単語を複数選び出し、その頻度に応じた大きさで図示する手法。
(コトバンクより)
です。
目的
今回は邦楽ロックバンド「BUMP OF CHICKEN」の歌詞を形態素解析した結果をWordCloudで表現したいと思います。つまり、藤原基央の好きな単語を分析しようということです。あとはReactとScalaについての自己学習も兼ねています。
環境
- PlayFramework2.5
- Scala2.11
- Java1.8
ファイル構成
WordCloud
├── play
│ ├── app
│ │ ├── controllers
│ │ │ └── IndexController.scala
│ │ └── views
│ │ └── main.scala.html
│ ├── build.sbt
│ ├── conf
│ │ ├── application.conf
│ │ └── routes
│ ├── public
│ │ ├── javascripts
│ │ │ └── bundle.js
│ │ └── stylesheets
│ │ └── main.css
│ └── resources
│ └── text
│ └── bumpofchicken
└── react
├── node_modules
├── package.json
├── src
│ ├── app.js
│ └── index.js
└── webpack.config.js
歌詞の取得
まずは何と言っても解析対象となる歌詞データが必要なので、無料歌詞検索サイトからJsoapを使っていただきました。著作権的な問題で歌詞の取得部分は今回は載せていませんが、取得した歌詞は"bump_曲名.txt"で/play/resources/text/bumpofchickenに置いてあります。
Scala(PlayFramework)
- 歌詞読み込み
- 形態素解析(Kuromoji)
- Map登録
- JSON返却
上記の順に処理を行います。全てIndexController.scala内で処理を行うようにしました。
package controllers
import javax.inject._
import play.api.libs.json._
import play.api.mvc._
import com.atilika.kuromoji.ipadic.Token
import com.atilika.kuromoji.ipadic.Tokenizer
import scala.collection.JavaConversions._
import scala.collection.mutable.{Map => MutableMap}
import java.nio.file.{Path, Paths, Files}
case class WordCloud(text: String, value: String)
@Singleton
class IndexController @Inject() extends Controller {
implicit val wcWrites = new Writes[WordCloud] {
def writes(wc: WordCloud) = Json.obj(
"text" -> wc.text,
"value" -> wc.value
)
}
/**
* GET処理(HTML返却)
*/
def get = Action {
Ok(views.html.main())
}
/**
* POST処理(JSON返却)
*/
def post = Action(parse.json) { request =>
(request.body \ "event").asOpt[String].map { event =>
event match {
case "cloud" => Ok(cloud)
case _ => BadRequest("No Event")
}
}.getOrElse {
BadRequest("No Event")
}
}
/**
* WordCloud処理
*/
def cloud: JsValue = {
// 解析結果登録マップ
val map = MutableMap[String, Int]()
// 解析
val analysis = kuromoji(new Tokenizer)_
// 登録
val entry = entryMap(map)_
/*
* 解析実行
* 歌詞ディレクトリからファイル名一覧を取得し、
* 歌詞読み込み -> 解析 -> マップ登録
* の順で実行する。
*/
Files.newDirectoryStream(Paths.get("resources/text/bumpofchicken"), "*.txt").toList
.map(readLyric).map(analysis).foreach(w => w.foreach(entry))
/*
* JSON変換し返却
* マップを昇順ソートし、先頭から500件までに絞り込む。
*/
Json.toJson(
map.toSeq.sortBy(_._2).reverse.slice(0, 500)
.map(e => WordCloud(e._1, e._2.toString))
)
}
/**
* 歌詞読み込み
* 歌詞ファイルを読み込み、文字列として返却する。
*/
def readLyric(path: Path):String = {
Files.readAllLines(path).toList.reduceLeft(_+_)
}
/**
* 形態素解析
* 歌詞を解析し単語を基本形で抽出、
* 名詞、動詞、形容詞以外の品詞は除く。
*/
def kuromoji(tokenizer: Tokenizer)(lyric: String):List[String] = {
tokenizer.tokenize(lyric).toList
.filter(t => List("名詞", "動詞", "形容詞").contains(t.getPartOfSpeechLevel1))
.map(t => if(t.getBaseForm == "*") t.getSurface else t.getBaseForm)
.distinct
}
/**
* マップ登録
* ミュータブルマップに単語を登録する。
* すでに登録されている単語の場合、カウントを1増やし更新する。
*/
def entryMap(cloudMap: MutableMap[String, Int])(text: String) = {
cloudMap(text) = cloudMap.getOrElse(text, 0) + 1
}
}
歌詞読み込み
歌詞ファイルの読み込みにはjava.nio.fileパッケージを使用します。まずFiles.newDirectoryStreamでディレクトリ内のテキストファイル名を全て読み取ります。その後、読み取ったファイル名のファイルを読み込み、歌詞を取得します。
形態素解析(Kuromoji)
続いて、Kuromojiを使用し歌詞を解析します。Kuromojiを使うにはbuild.sbtに下記を追記し、sbtのreloadとupdateを行ってください。
libraryDependencies ++= Seq(
"com.atilika.kuromoji" % "kuromoji-ipadic" % "0.9.0"
)
kuromojiについてはこちらに詳しく書いてあるので参考にしました。
Kuromojiで解析した結果にフィルターをかけ、名詞、動詞、形容詞のみを基本形で抽出します。distinctで重複を無くしているのは、同じ曲の中で何度も同じ単語が連呼されている場合があるためです。今回は一曲の中で同じ単語は二回以上カウントしないことにしています。
Map登録
抽出した単語は、ミュータブルMapに登録します。Map(単語 -> 出現回数)の形式で登録し、すでにMapに単語がある場合は出現回数を1増やしています。
JSON返却
最後は解析した結果を下記形式のJSONにして返却したいので、Mapを返却用の型(Seq[WordCloud])に変換してJSONにパースします。ちなみに一度動作させてみたところ、マップに登録された単語は3000語を超えていました。全て返却すると画面描画に時間を使うので、出現回数で上位500件だけを返却するようにしました。
[
{ text: 単語A, value: 出現回数 },
{ text: 単語B, value: 出現回数 },
.
.
.
]
Scalaはこんなところです。
React
続いて、画面部分についてですが、WordCloudを描画するにはreact-d3-cloud というライブラリを使用します。
画面にはサーバから解析結果を取得するためのボタンのみを設置し、そのボタンを押下するとデータを取得し画面に描画するようにします。
とりあえず必要なライブラリはnpmでinstallしておきます。
- react
- react-dom
- react-d3-cloud
- whatwg-fetch <- サーバと通信するため
- babel関連 <- ES2015でコーディングするため
- webpack <- バンドルするため
今回Reactで画面を作りますが、細かい作法などは無視してapp.jsに全て書きました。Reactについては別の投稿で書いていきたいと思っています。
作成したjsはwebpackでbundleし、/play/public/javascripts/bundls.jsに出力します。
import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'
ReactDOM.render(
<App/>
,
document.getElementById('root')
)
import React from 'react'
import WordCloud from 'react-d3-cloud'
import 'whatwg-fetch'
const fontSizeMapper = word => Math.log2(word.value) * word.value * 0.3
export default class App extends React.Component {
constructor (props) {
super(props)
this.state = {
data: []
}
}
fetchData = (path, event) => {
return fetch ('http://localhost:9000' + path, {
method: 'POST',
mode: 'cors',
credentials: 'include',
headers: {'Content-Type': 'application/json; charset=UTF-8'},
body: JSON.stringify({event: event})
})
.then(response => response.json())
.then(json => this.setState({data: json}))
.catch(ex => this.setState({data: []}))
}
render() {
return (
<div>
<button type="button" onClick={this.fetchData.bind(this, '/', 'cloud')} >解析</button>
<WordCloud
data={this.state.data}
fontSizeMapper={fontSizeMapper}
width={1200}
height={620}
/>
</div>
)
}
}
ボタンを押下すると、サーバとの通信を開始します。ここでは特にデータを送信しませんが、どのボタンが押下されたかわかるようにするためボタンのEventKeyは送信しています。
その後、Scalaで解析を行いJSONレスポンスが返ってきます。そのJSONをWordCloudのdata属性に設定し再描画をするとWordCloudを表示してくれます。fontSizeMapperには、出現頻度の高い単語と低い単語のサイズの差を大きくするため、出現回数の二乗した数字に0.3を乗じた数値にしています。
動作確認
ここまで準備したら、Playサーバを動かしてアクセスし、解析ボタンを押下してみます。
"する"、"いる"の主張が激しいですね。あと"僕"や"事"はたしかに曲を聞いていて出現頻度が多い気がします。うーん、それくらいの感想しか持てませんね。
最後に
出現率の高い単語を見ることができましたが、ただそれだけでした。曲ごとのリリース日などわかれば、また違った表現ができそうだと思ったので、いつか挑戦したいと思います。あとScalaについてですが、とても気持ちよくコーディングすることができました。特にリスト操作はJavaと比較するとかなり軽く感じました。まだ知らないことが多い言語ですが、Scala学習はこれからも積極的に進めていきたいと思います。
GitHub