この記事はエイチーム引越し侍 / エイチームコネクト Advent Calendar 20193日目の記事です。
本日はエイチームコネクト入社4ヶ月目に突入した@ikuma_hayashiが担当します。
SIerから転職して4ヶ月目、まだまだ日は浅く勉強中ですが、Qiita初投稿です!気になる点ありましたらぜひコメントください
エイチームコネクトって何やってるの?
当社グループのサイトをご利用いただいたユーザー様へ、生活に紐づく必要なサービスのご案内をお電話にて行っております。主に引越しされたお客様に対し、居住場所の変化に伴って生活が豊かになるインターネット回線や電力サービスをご紹介しています。
生活が豊かになるお客様・各種サービスを運営されている会社様・エイチームグループの三方よしを実現するべく、日々追求しております。
なんでやるの?
お客様とオペレーターでされた会話を自然言語処理で解析し、より良いユーザービリティを追求するために、Dockerで言語解析+Webアプリの超基本的な基盤を構築します。
対象の読者
- Laravelで言語解析したい(文字列から単語を抜き取って重要度を計算したい)
- Laravel, Dockerは使ったことある
MeCabってなに?
MeCabは 京都大学情報学研究科−日本電信電話株式会社コミュニケーション科学基礎研究所 共同研究ユニットプロジェクトを通じて開発されたオープンソース 形態素解析エンジンです。
中略
ちなみに和布蕪(めかぶ)は, 作者の好物です。
引用元:MeCab: Yet Another Part-of-Speech and Morphological Analyzer
形態素解析とは、文章を形態素という最小単位に分解する技術です。
コマンドラインベースで下記のように形態素解析してくれます。
$ mecab
$ みんなで幸せになれる会社にすること
みんな 名詞,代名詞,一般,*,*,*,みんな,ミンナ,ミンナ
で 助詞,格助詞,一般,*,*,*,で,デ,デ
幸せ 名詞,形容動詞語幹,*,*,*,*,幸せ,シアワセ,シアワセ
に 助詞,副詞化,*,*,*,*,に,ニ,ニ
なれる 動詞,自立,*,*,一段,基本形,なれる,ナレル,ナレル
会社 名詞,一般,*,*,*,*,会社,カイシャ,カイシャ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
する 動詞,自立,*,*,サ変・スル,基本形,する,スル,スル
こと 名詞,非自立,一般,*,*,*,こと,コト,コト
MeCabのみではPHPから直接利用できないため、php-mecabも併せて導入し、Laravelから利用できるようにしています。
今回は、これらを用いてLaravel環境で形態素解析を行えるようにします。
環境
- ホストOS
- Windows10 Pro
- Docker for Windows
- Dockerコンテナ
- Laravel 6.0
- php-fpm7.3
- MySQL 8.0
- nginx 1.17
ディレクトリ・ファイル構造(一部省略)
laramecab
│ ├─docker-compose.yml
│ └─README.md
├─docker
│ ├─mysql
│ │ └─my.cnf
│ ├─nginx
│ │ └─default.conf
│ └─php
│ ├─Dockerfile
│ └─php.ini
├─logs : 各種ログ・ファイル
└─projects
└─myapp : Laravelのプロジェクト
(laradockにちなんでlaramecabとかいう大それた名前をつけてみた)
環境構築
コンテナを起動
# 適当なフォルダにてgit clone
PS C:\> git clone https://github.com/IkumaHayashi/laramecab.git
Cloning into 'laramecab'...
remote: Enumerating objects: 18, done.
remote: Counting objects: 100% (18/18), done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 18 (delta 1), reused 14 (delta 0), pack-reused 0
Unpacking objects: 100% (18/18), done.
# 出来たフォルダにてdockerイメージの構築と起動(5分くらいかかります)
PS C:\> cd .\laramecab\
PS C:\laramecab> docker-compose up -d
Creating laramecab_app_1 ... done
Creating laramecab_db_1 ... done
Creating laramecab_web_1 ... done
# 正常に起動しているか確認
PS C:\laramecab> docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ad8e3d23fbc5 mysql:8.0 "docker-entrypoint.s…" 7 seconds ago Up 4 seconds 33060/tcp, 0.0.0.0:13306->3306/tcp laramecab_db_1
c6bce5731a4e nginx:1.17-alpine "nginx -g 'daemon of…" About a minute ago Up 4 seconds 0.0.0.0:8000->80/tcp laramecab_web_1
b58a85b0aa2f laramecab_app "docker-php-entrypoi…" About a minute ago Up 5 seconds 9000/tcp, 0.0.0.0:18000->8000/tcp laramecab_app_1
MeCabがappコンテナで使えるかどうか確認
# appコンテナのshを起動
PS C:\laramecab> docker-compose exec app sh
# MeCabを起動
$ mecab
今から100年続く会社にすること
今 名詞,副詞可能,*,*,*,*,今,イマ,イマ
から 助詞,格助詞,一般,*,*,*,から,カラ,カラ
100 名詞,数,*,*,*,*,*
年 名詞,接尾,助数詞,*,*,*,年,ネン,ネン
続く 動詞,自立,*,*,五段・カ行イ音便,基本形,続く,ツヅク,ツズク
会社 名詞,一般,*,*,*,*,会社,カイシャ,カイシャ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
する 動詞,自立,*,*,サ変・スル,基本形,する,スル,スル
こと 名詞,非自立,一般,*,*,*,こと,コト,コト
EOS
# 終了するときはCtrl + Cで抜けられます
^C
Laravelのプロジェクトを作成
# Laravelプロジェクトの作成
PS C:\laramecab> docker-compose exec app composer create-project --prefer-dist laravel/laravel myapp
# いったんdockerコンテナを終了する
PS C:\laramecab> docker-compose stop
Stopping laramecab_db_1 ... done
Stopping laramecab_web_1 ... done
Stopping laramecab_app_1 ... done
nginxの公開フォルダを変更する必要があります。
laramecab\docker\nginx\default.conf
を開き、下記の通り書き換えてください。
server {
listen 80;
#root /work/public;
root /work/myapp/public;
index index.php;
charset utf-8;
location / {
#root /work/public;
root /work/myapp/public;
try_files $uri $uri/ /index.php$is_args$args;
}
#省略
ふたたびdockerコンテナを起動していきます。
PS C:\laramecab> docker-compose up -d
Starting laramecab_app_1 ... done
Starting laramecab_db_1 ... done
Starting laramecab_web_1 ... done
あとは下記URLを起動すれば、例のLaravelの画面が見えてきます!
http://localhost:8000/
php-mecabが使えるか確認
laravelにはtinkerというコマンドラインでコードを実行できる機能がありますので、tinkerで確認していきましょう。
# ホストOSからappコンテナでmyappに移動してtinkerを起動する
PS C:\laramecab> docker-compose exec app sh -c "cd myapp/ && php artisan tinker"
Psy Shell v0.9.11 (PHP 7.3.11 — cli) by Justin Hileman
# MeCabのインスタンスを生成
>>> $mecab = new \Mecab\Tagger();
=> MeCab\Tagger {#3006}
# 文字列を渡し解析
>>> $nodes = $mecab->parseToNode('今から100年続く会社にすること');
# 解析結果を表示
>>> foreach ($nodes as $n) {
print_r($n->getFeature().PHP_EOL);
}
BOS/EOS,*,*,*,*,*,*,*,*
名詞,副詞可能,*,*,*,*,今,イマ,イマ
助詞,格助詞,一般,*,*,*,から,カラ,カラ
名詞,数,*,*,*,*,*
名詞,接尾,助数詞,*,*,*,年,ネン,ネン
動詞,自立,*,*,五段・カ行イ音便,基本形,続く,ツヅク,ツズク
名詞,一般,*,*,*,*,会社,カイシャ,カイシャ
助詞,格助詞,一般,*,*,*,に,ニ,ニ
動詞,自立,*,*,サ変・スル,基本形,する,スル,スル
名詞,非自立,一般,*,*,*,こと,コト,コト
BOS/EOS,*,*,*,*,*,*,*,*
以上で、appコンテナ内でlaravel上でMeCabによる解析ができたことを確認できました!
どうでもいい話ですが、powershellはcdやlsなど、linuxライクなコマンドの利用が可能なので結構おすすめです
せっかくなんでコード書く
ここまでで終わろうと思ってましたがせっかくなんでコードも書いていきます
下記の流れでアプリを作成していきます。
- 解析対象の文字列(Text)、抽出された重要後と重要度(ImportanceTerm)のテーブル、モデル作成
- 文字列を渡すとの結果と出現回数をDBに格納するcommandを作成
- 重要度を計算し、レコードをupdateするcommandを作成
今回は重要度の計算にtfidf法というものを使っていきます。
tfidf法については、こちらにて概要が解説されていますのでご参照ください。
めちゃくちゃ乱暴に言ってしまうと、
- その文書内で重要な単語って何回も出るくね?(出現回数tf:Term Frequency)
- でも一般的な言葉って重要度低いから、レア度が高いほうが重要度高くね?(レア度、固く言うと逆文書頻度idf:Inverse Document Frequency)
- この2つをかけたら重要度じゃね?(出現回数×レア度=重要度)
というものです
1. 解析対象の文字列(Text)、抽出された重要後と重要度(ImportanceTerm)のテーブル、モデル作成
今回はTextsテーブル、ImportanceTermsの2つのテーブルを作成していきます。ER図にすると下記のようにTextsとImportanceTermsは1対多の関係になります。
マイグレーションファイルの作成
$ php artisan make:migration create_texts_table --create=texts
Created Migration: 2019_12_02_140747_create_texts_table
$ php artisan make:migration create_importance_terms_table --create=importance_terms
Created Migration: 2019_12_02_140908_create_importance_terms_table
マイグレーションファイルの編集
必要なカラムの追加とリレーションを張っていきます。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTextsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('texts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->longText('text');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('texts');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateImportanceTermsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('importance_terms', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('text_id')->unsigned();
$table->foreign('text_id')->references('id')->on('texts');
$table->string('term', 50);
$table->integer('frequency')->default(0);
$table->double('tf', 8, 4)->default(0.0);
$table->double('idf', 8, 4)->default(0.0);
$table->double('tfidf', 8, 4)->virtualAs('tf * ( idf + 1 )');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('importance_terms');
}
}
migrateの実行
$ artisan migrate
Migration table created successfully.
Migrating: 2019_12_02_140747_create_texts_table
Migrated: 2019_12_02_140747_create_texts_table (0.19 seconds)
Migrating: 2019_12_02_140908_create_importance_terms_table
Migrated: 2019_12_02_140908_create_importance_terms_table (0.48 seconds)
DBコンテナでテーブルの存在を確認していきます。
$ # mysql -u root -p
Enter password:
mysql> use default;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
mysql> show tables;
+-------------------+
| Tables_in_default |
+-------------------+
| migrations |
| importance_terms |
| texts |
+-------------------+
3 rows in set (0.00 sec)
モデルの作成
下記の通りコマンドを実行します。
$ php artisan make:model Text
Model created successfully.
$ php artisan make:model ImportanceTerm
Model created successfully.
モデルファイルが下記の通り作成されますので、Modelsというディレクトリを作成して移動させ、それぞれ編集していきます。
- myapp\app\Text.php → myapp\app\Models\Text.php
- myapp\app\ImportanceTerm.php → myapp\app\Models\ImportanceTerm.php
ImportanceTermモデルの編集
先にImportanceTermのほうから。ImportanceTermから見ると、Textは1つしかないので、belogsToを使ってリレーションを張ります。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ImportanceTerm extends Model
{
public function text(){
return $this->belongsTo('App\Models\Text');
}
}
Textモデルの編集
今度はTextモデルです。Textから見るとImportanceTermsは多なので、hasManyを使ってリレーションを張ります。
ついでに、重要度の降順で並び替えて取得するようにします。
また、textフィールドに設定された文字列からmecabによる解析および単語の出現回数(tf)の計算を行い、一緒に保存するようにします。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Text extends Model
{
public function importanceTerms()
{
return $this->hasMany('App\Models\ImportanceTerm')->orderByDesc('tfidf');
}
public function setImportanceTerms()
{
//名詞の抽出と重要度の算出
$frequencyAndTfs = $this->generateFrequencyAndTfs();
//DBに保存
foreach ($frequencyAndTfs as $key => $frequencyAndTf) {
$importanceTerm = new \App\Models\ImportanceTerm();
$importanceTerm->term = $key;
$importanceTerm->tf = $frequencyAndTf['tf'];
$importanceTerm->frequency = $frequencyAndTf['frequency'];
$this->importanceTerms()->save($importanceTerm);
}
}
private function generateFrequencyAndTfs() : array
{
//文字列を解析
$mecab = new \Mecab\Tagger();
$nodes = $mecab->parseToNode($this->text);
//形態素ごとに名詞かどうか、重要度はいくつかを算出
$allTerms = array();
$terms = array();
$compoundNoun = '';
foreach ($nodes as $n) {
$result = explode(',', $n->getFeature());
//空白は無視
if($n->getSurface() == '')
continue;
//全単語の頻出回数を記録
$this->incrementFrequency($allTerms, $n->getSurface());
//名詞ではない かつ 前も名詞ではない場合はスキップ
if($compoundNoun == '' && $result[0] != '名詞'){
continue;
//名詞ではない かつ 複合名詞が空でない場合は、複合名詞としてカウント
}else if($compoundNoun != '' && $result[0] != '名詞'){
//ひらがな1文字は除外する
if(preg_match('/^[ぁ-ん]$/u', $compoundNoun)){
$compoundNoun = '';
continue;
}
//複合名詞がまだ単名詞の場合は除外する
if($compoundNoun == $n->getSurface()){
$compoundNoun = '';
continue;
}
//複合名詞を格納
$this->incrementFrequency($terms, $compoundNoun);
$compoundNoun = '';
//名詞 かつ 前の形態素も名詞の場合
}else if($compoundNoun != '' && $result[0] == '名詞'){
//前の名詞と複合名詞が一致する場合、前の名詞を単名詞としてカウント
if($compoundNoun == $n->getPrev()->getSurface()){
$this->incrementFrequency($terms, $n->getPrev()->getSurface());
}
$this->incrementFrequency($terms, $n->getSurface());
$compoundNoun .= $n->getSurface();
//名詞 かつ 最初の出現の場合
}else{
$compoundNoun .= $n->getSurface();
}
}
$frequencyAndTfs = array();
$sumFrequency = array_sum($allTerms);
foreach ($terms as $term => $value) {
$frequencyAndTfs[$term] = ['frequency'=> $value
, 'tf' => $value / $sumFrequency];
}
return $frequencyAndTfs;
}
private function incrementFrequency(&$terms, $term)
{
isset($terms[$term]) ? $terms[$term]++ : $terms[$term] = 1;
}
}
2. 文字列を渡すとの結果と出現回数をDBに格納するcommandを作成
appコンテナでコマンドを作成していきます。詳しい解説は下記を参照してください。
Laravelでコマンドラインアプリケーションを作成する
今回は下記のようにコマンドを打つことで、MeCabによる解析結果をDBに登録し、重要度の計算・更新を行っていきます。
#解析対象の文字列を登録
php artisan TermImportance:store みんなで幸せになれる会社にすること
php artisan TermImportance:store 今から100年続く会社にすること
#idfの計算および重要度の算出
php artisan TermImportance:calc
コマンドの作成
実際にコマンドを作成していきます。
$ php artisan make:command StoreTextCommand
$ php artisan make:command CalcCommand
そうすると下記の2ファイルができるのでコードを編集していきます。
myapp\app\Console\Commands\StoreTextCommand.php
myapp\app\Console\Commands\CalcCommand.php
コマンドの編集
StoreTextCommandの編集
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class StoreTextCommand extends Command
{
protected $signature = 'TermImportance:store {text}';
protected $description = '計算対象の文字列と各単語の重要度をDBに保管します。';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$text = new \App\Models\Text();
$text->text = $this->argument("text");
$text->save();
$text->setImportanceTerms();
}
}
StoreTextCommandの動作確認
$ php artisan TermImportance:store みんなで幸せになれる会社にすること
$ mysql -u root -p
$ mysql> select * from texts;
+----+-----------------------------------------------------+---------------------+---------------------+
| id | text | created_at | updated_at |
+----+-----------------------------------------------------+---------------------+---------------------+
| 1 | みんなで幸せになれる会社にすること | 2019-12-02 15:29:16 | 2019-12-02 15:29:16 |
+----+-----------------------------------------------------+---------------------+---------------------+
1 row in set (0.00 sec)
mysql> select * from importance_terms;
+----+---------+-----------+-----------+--------+--------+--------+---------------------+---------------------+
| id | text_id | term | frequency | tf | idf | tfidf | created_at | updated_at |
+----+---------+-----------+-----------+--------+--------+--------+---------------------+---------------------+
| 1 | 1 | みんな | 1 | 0.1111 | 0.0000 | 0.1111 | 2019-12-02 15:29:16 | 2019-12-02 15:29:16 |
| 2 | 1 | 幸せ | 1 | 0.1111 | 0.0000 | 0.1111 | 2019-12-02 15:29:16 | 2019-12-02 15:29:16 |
| 3 | 1 | 会社 | 1 | 0.1111 | 0.0000 | 0.1111 | 2019-12-02 15:29:16 | 2019-12-02 15:29:16 |
+----+---------+-----------+-----------+--------+--------+--------+---------------------+---------------------+
3 rows in set (0.01 sec)
よき!当然ですがまだ文章が短くtfは1回だし、idfは計算を実装していないので、デフォルト値0.0になります。
CalcCommandの編集
最後に、idfを計算するロジックを書いていきます。
idfは、全文書のうち該当の単語が存在する文書数を元に計算されます。
(厳密には idf = log10(該当の単語がある文書数/全文書数) )
videoモデルに、重要語有無をチェックするメソッドを実装
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CalcCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'TermImportance:calc';
/**
* The console command description.
*
* @var string
*/
protected $description = '各単語のidfを計算します。';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$texts = \App\Models\Text::all();
foreach ($texts as $text) {
$importanceTerms = $text->importanceTerms()->get();
foreach ($importanceTerms as $importanceTerm) {
$hasTermCount = \App\Models\ImportanceTerm::where('term', $importanceTerm->term)
->distinct()
->count();
$idf = $hasTermCount > 0 ? log10(count($texts) / $hasTermCount) : log10(0);
$importanceTerm->idf = $idf;
$importanceTerm->save();
}
}
}
}
CalcCommandの動作確認
さきほど、「みんなで幸せになれる会社にすること」という言葉を登録しましたが、テストのために下記のように追加でTextの登録と計算を行います。
$ php artisan TermImportance:store みんなが幸せになれる会社にすることという経営理念では、主体性が生まれないので、みんな”で”に変わった
$ php artisan TermImportance:store 今から100年続く会社にすること
$ php artisan TermImportance:calc
続いて結果の確認を行っていきます。
$ mysql -u root -p
mysql> select it.term, it.frequency, it.tf, it.idf, it.tfidf
-> from importance_terms it
-> order by it.tfidf desc;
+--------------+-----------+--------+--------+--------+
| term | frequency | tf | idf | tfidf |
+--------------+-----------+--------+--------+--------+
| 今 | 1 | 0.1111 | 0.4771 | 0.1641 |
| 100 | 1 | 0.1111 | 0.4771 | 0.1641 |
| 年 | 1 | 0.1111 | 0.4771 | 0.1641 |
| 100年 | 1 | 0.1111 | 0.4771 | 0.1641 |
| みんな | 1 | 0.1111 | 0.1761 | 0.1307 |
| 幸せ | 1 | 0.1111 | 0.1761 | 0.1307 |
| 会社 | 1 | 0.1111 | 0.0000 | 0.1111 |
| 会社 | 1 | 0.1111 | 0.0000 | 0.1111 |
| みんな | 2 | 0.0714 | 0.1761 | 0.0840 |
| こと | 1 | 0.0357 | 0.4771 | 0.0527 |
| 経営 | 1 | 0.0357 | 0.4771 | 0.0527 |
| 理念 | 1 | 0.0357 | 0.4771 | 0.0527 |
| 経営理念 | 1 | 0.0357 | 0.4771 | 0.0527 |
| 主体性 | 1 | 0.0357 | 0.4771 | 0.0527 |
| 幸せ | 1 | 0.0357 | 0.1761 | 0.0420 |
| 会社 | 1 | 0.0357 | 0.0000 | 0.0357 |
+--------------+-----------+--------+--------+--------+
母数が少ないのでわかりにくいのですが、
- 「今」や「100年」という言葉は全体で1回しか出ていない→レア度が高い
- 「会社」という言葉はすべての文書で出ているためレア度が低い
という結果となりました。
まとめ
- Dockerfileでphp-fpmのコンテナにMeCab、ipadic(辞書)、php-mecabをインストールしました
- DBと連携してtfidf法を用いてテキストから抽出した単語の重要度を計算しました
留意点
- 本記事ではLaravelとしてどこに処理を記述するか、について主眼を置いてません。
- 実際はMeCabを使うのであればpythonなどライブラリが充実している言語がおすすめです。PHPと連携したいときは参考になるかと思います。
- Amazon Lexといった音声から解析できるサービスも出てきており、自然言語処理を実装する必要性がなくなっています。
ソースコード
ソースコードは下記にて公開しております。(個人のGithubアカウントのため会社の活動とは全く関係ありませんのでご了承ください。)
参考記事
お知らせ
エイチームグループでは一緒に活躍してくれる優秀な人材を募集中です。
興味のある方はぜひともエイチームグループ採用ページよりご応募ください!
Qiita Jobsのエイチーム引越し侍社内システム企画 / 開発チーム、社内システム開発エンジニアを募集!からチャットでご質問いただくことも可能です!
明日
明日はいつも助けてくれる @ex_SOULさんの記事です!
魂のこもった記事をお楽しみに!