blogスタッフブログ
HOME > スタッフブログ > CMS >PowerCMS Xプラグインで機械学習(ベイジアンフィルタ)を実装する(3)

PowerCMS Xプラグインで機械学習(ベイジアンフィルタ)を実装する(3)

PowerCMS Xプラグインで機械学習(ベイジアンフィルタ)を実装する(2)の続編です。今回はRESTful APIのエンドポイント追加(非同期でデータ分類の取得)と、複数ジャンルの学習・データ分類に対応します。

RESTful APIのエンドポイント追加

プラグインでRESTful APIのエンドポイントを追加することができます。config.jsonとプラグインのphp(NaiveBayes.php)ファイルに追記します。

config.json

"api_methods": {
    "v1": {
        "predict": {
            "component": "NaiveBayes",
            "method": "api_endpoint_predict",
            "requires_login": false,
            "allowed": [
                "GET",
                "POST"
            ]
        }
    }
}

/api/ でAPIを呼び出せるよう設定している場合、/api/APIバージョン/ワークスペースID/predict がエンドポイントになります。requires_loginはログイン要/不要、allowedでは許可するHTTPメソッドを指定します。

NaiveBayes.php

/**
 * RESTful API エンドポイント predict
 * API経由でパラメータの値から確率の高いカテゴリを推定
 * 
 * @param PTRESTfulAPIv1 $app アプリケーション
 * 
 * @return object 推定カテゴリ名
 */
function api_endpoint_predict($app) {
    $json = [];
    $category = $app->param('category');
    $text = $app->param('text');

    if ($category && $text) {
        $predictCategory = $this->predict($app, $category, $text, false);
        $json['status'] = 'success';
        $json['category'] = $predictCategory->name;
        $json['class'] = $predictCategory->class;
    } else {
        $json['status'] = 'error';
        $json['message'] = 'テキストが指定されていません。パラメータ(text)で指定してください。';
    }

    $app->print_json($json);
}

/**
 * 確率の高いカテゴリ推定
 *
 * @param Prototype $app アプリケーション
 * @param string $categoryName カテゴリ名
 * @param string $text 判別するテキストデータ
 * @param bool $isDebug スコアのデバッグログ出力
 * 
 * @return array{id: int, name: string} カテゴリレコードの連想配列
 */
public function predict($app, $categoryName, $text, $isDebug = false) {
    $workspaceId = (int) $app->param('workspace_id');

    // テキストを単語ベクトルに変換
    $vec = $this->text2vec($text);
    
    // カテゴリを全件取得
    $categories = $app->db->model('bayes_category')->load([
        'name' => $categoryName,
        'terms_count' => ['>' => 0],
        'workspace_id' => $workspaceId
    ]);
    
    // カテゴリごとにスコアを算出
    $scores = [];
    $bestScore = null;
    $bestScoreCategory = null;
    foreach ($categories as $category) {
        $categoryId = $category->id;
        $scores[$categoryId] = $this->calcScore($app, $vec, $categories, $categoryId, $isDebug);
        if ($bestScore === null || $scores[$categoryId] > $bestScore) {
            $bestScore = $scores[$categoryId];
            $bestScoreCategory = $category;
        }
    }
    
    return $bestScoreCategory;
}

追加したエンドポイント(GETリクエスト)は、/api/v1/1/predict?category=メール判定&text=PowerCMS Xの実装について...(省略) のようにパラメータをつけて利用します。エンドポイントの内部処理(データ分類の推定)は初回に実装したファンクションタグの関数と共通化すると良いでしょう。

複数ジャンルの学習・データ分類対応

当初の実装では1ワークスペース1ジャンルの学習しか対応していませんでした。例えば、問い合わせメールの内容を学習させてスパムメール判定に利用していると、そのワークスペースではスパムメール判定にしか利用できません。1ジャンルでは不便なので複数ジャンルに対応します。

カテゴリ指定のカラム追加

ベイズカテゴリモデルとベイズ文章モデルにカテゴリ指定のカラムを追加します。以前はカテゴリを省略して分類のみ指定していましたが、カテゴリをキーにして分類の保存・取得に対応します。

ベイズ文章にメール判定(問い合わせメール)を学習させている画面のキャプチャ

学習処理

/**
 * ベイズ文章から学習
 *
 * @param Prototype $app アプリケーション
 * @param object $termBlockObject 保存後のベイズ文章オブジェクト
 * @param object $originalTermBlockObject 保存前のベイズ文章オブジェクト
 */
public function train($app, $termBlockObject, $originalTermBlockObject) {
    // リレーションから学習有無を確認
    $isEdit = $app->db->model('relation')->count([
        'name' => 'bayes_category',
        'to_obj' => 'bayes_term_block',
        'to_id' => $termBlockObject->id
    ]);

    // 編集ならベイズ文章から不要なオブジェクトを削除
    if ($isEdit) {
        $this->removeObjectFromTermBlock($app, $originalTermBlockObject);
    }

    // 文章を単語ベクトルに変換
    $vec = $this->text2vec($termBlockObject->texts);

    // 文章に含まれる単語数を取得
    $termCount = 0;
    foreach ($vec as $word => $count) {
        $termCount += $count;
    }

    if (!$termCount) {
        return;
    }

    // カテゴリを保存
    $categoryId = $this->saveCategory($app, $termBlockObject, $termCount);

    // ベイズ単語とベイズ単語カテゴリを保存
    foreach ($vec as $word => $count) {
        $this->saveTerm($app, $word, $termBlockObject->id, $categoryId, $count);
    }

    // ベイズ文章オブジェクトを学習済みに更新
    $app->get_scheme_from_db('bayes_term_block');
    $app->set_default($termBlockObject);
    $termBlockObject->is_trained(1);
    $termBlockObject->save();
}

/**
 * ベイズカテゴリを登録
 * 
 * @param Prototype $app アプリケーション
 * @param object $termBlockObject ベイズ文章オブジェクト
 * @param int $termCount ベイズ文章に含まれる総単語数
 * 
 * @return int ベイズカテゴリID
 */
protected function saveCategory($app, $termBlockObject, $termCount) {
    $workspaceId = (int) $app->param('workspace_id');

    // ベイズカテゴリを取得
    $category = $app->db->model('bayes_category')->get_by_key([
        'name' => $termBlockObject->category,
        'class' => $termBlockObject->class,
        'workspace_id' => $workspaceId
    ]);
    $app->get_scheme_from_db('bayes_category');
    $app->set_default($category);
    // 総単語数を加算
    $category->terms_count($category->terms_count + $termCount);        
    // ベイズカテゴリの学習回数を+1
    $category->train_count($category->train_count + 1);
    $category->save();

    // リレーションに保存
    $relation = $app->db->model('relation')->get_by_key([
        'name' => 'bayes_category',
        'from_obj' => 'bayes_category',
        'from_id' => $category->id,
        'to_obj' => 'bayes_term_block',
        'to_id' => $termBlockObject->id,
        'order' => null
    ]);
    $relation->save();
    
    return $category->id;
}

/**
 * ベイズ単語とベイズ単語カテゴリを登録
 * 
 * @param Prototype $app アプリケーション
 * @param string $word 単語
 * @param int $termBlockID ベイズ文章ID
 * @param int $categoryId ベイズカテゴリID
 * @param int $count 単語の登場回数
 */
protected function saveTerm($app, $word, $termBlockID, $categoryId, $count) {
    $workspaceId = (int) $app->param('workspace_id');

    // ベイズ単語を取得
    $term = $app->db->model('bayes_term')->get_by_key([
        'word' => $word,
        'workspace_id' => $workspaceId
    ]);
    $app->get_scheme_from_db('bayes_term');
    $app->set_default($term);
    $term->save();

    // ベイズ単語カテゴリを取得
    $termCategory = $app->db->model('bayes_term_category')->get_by_key([
        'term_id' => $term->id,
        'category_id' => $categoryId,
        'workspace_id' => $workspaceId
    ]);
    $app->get_scheme_from_db('bayes_term_category');
    $app->set_default($termCategory);
    // ベイズ単語カテゴリの学習回数を単語の登場回数分加算
    $termCategory->train_count($termCategory->train_count + $count);
    $termCategory->save();

    // リレーションに保存
    $relation = $app->db->model('relation')->get_by_key([
        'name' => 'termblocks',
        'from_obj' => 'bayes_term',
        'from_id' => $term->id,
        'to_obj' => 'bayes_term_block',
        'to_id' => $termBlockID,
        'order' => null
    ]);
    $relation->save();
}

削除処理の改善

前回の記事で削除処理について触れていましたが、オブジェクトの一覧画面から一括削除した際にデータが期待通りに削除されないケースがありました。具体的には環境変数remove_asyncがtrueだと、削除処理が非同期遅延・分割処理となるため意図したタイミングで削除されなくなります(初期値はtrue)。

結論としては複数ジャンル対応と併せてロジック見直し、環境変数remove_asyncはfalseにしました。環境毎に環境変数を設定しても良いですが、プラグインのconfig.jsonに以下を追記して初期値をfalseとしています。

"config_overwrite": {
    "remove_async": false
}

まとめ

理想的なテーブルで(複数テーブルに)データを保存すると削除が大変になると良い学びになりました。とはいえ、一つのテーブルに全てのデータを入れるわけにもいかず、場合によってはコールバックではなくスケジュールタスク(worker.php)で掃除することを検討した方が良いかもしれません。

RESTful APIのエンドポイントはプラグインで容易に追加できますし、ドキュメントも充実していますので一度試してみることをおすすめします。

プラグイン実装でお悩みのことがあればお気軽にご相談ください。

最近の記事

カテゴリ

アーカイブ

スタッフ別