PHP

これぞMS版Watson!? いますぐ使える感情分析APIで評判分析アプリ作ってみた

2016/11/05

Wern Ancheta

14

Articles in this issue reproduced from SitePoint
Copyright © 2016, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2016, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

音声や画像、テキストを認知、分析するシステムといえば、IBMのWatsonが有名ですが、マイクロソフトもCognitive Services APIというWeb APIを提供しています。PHPでAmazonのレビューを分析するアプリを作ってみました。

近年の機械学習サービスの急増で、開発者による「スマートアプリ」の作成がこれまでより簡単になりました。この記事では、機械学習機能を提供するマイクロソフトのAPIを紹介します。特に、Text Analytics APIについて説明し、EC事業者が自社の顧客に関する理解を深めるためのアプリを構築します。

マイクロソフトのText Analytics API

Text Analytics APIは、アプリに人間的側面を与えることを目的とするAPI、マイクロソフトのCognitive Services APIの一部です。Text Analytics APIには次の機能が含まれています。

  • 評判分析:指定されたテキストについて主観的な意見を識別し抽出するために0と1の間のスコアを返す。0にもっとも近い数字は負の感情、1にもっとも近い数字は正の感情を示す
  • 重要なフレーズを抽出(用語抽出):入力テキストの要点を示す文字列のリストを抽出
  • トピック検出:テキストグループ全体の要点を検出する。トピック検出には100件以上のテキストが必要
  • 言語検出:エンジンが検出した言語がどの程度正確か示す明確なスコアとともに、検出された言語を返す

APIキーを取得するには

マイクロソフトCognitive Services APIはMicrosoft Azureの一部として提供されているので、アカウントを持っていない場合はAzureのWebサイトでサインアップします。Azureのサインアップには、Microsoft Liveアカウントが必要なので、先にMicrosoft Liveのサインアップします。次の2つのサイトを参考にしてください。

必要な情報のほかにクレジットカード情報を提供してください。登録後1か月まで使用可能な無料のクレジットを取得します。またText Analytics APIは毎月5000トランザクションまで無料です。テストを目的とするならこれで十分です。

アカウントが作成されたらAzureポータルにリダイレクトします。そこから検索バーへ移動し、cognitive services(認知サービス)と入力します。Cognitive Services(認知サービス)アカウントの表示をクリックしてください(プレビュー)。

search for cognitive services

次のようなインターフェイスが表示されます。

cognitive services accounts

add(追加)ボタンをクリックし、次のフィールドを入力します。

  • Account name(アカウント名):任意の名前を入力
  • API type(APIタイプ):Text Analytics(テキスト解析)を選択
  • Pricing tier(価格層):月々5000回コール無料のコースを選択
  • Subscription(サブスクリプション):無料トライアル
  • Resource group(リソースグループ):すでにアカウントを持っている場合は既存のアカウントを選択し、そうでない場合は新規オプションを選択してから希望する名前を入力して新しいリソースグループを作成する
  • Location(所在地):West US(西米国)を選択

法的条項に同意したあと、Create(作成)ボタンをクリックします。デプロイに2~3秒かかります。そのあと更新ボタンを1回クリックするとサービスがデプロイされたと通知が表示されます。新しいサービスのリストが表示されます。クリックしてメニューを表示し、Keys(キー)をクリックして利用できるAPIキーを表示します。

api keys

APIを使用する

APIキーを取得したので、以下の各ページからAPIを利用できます。

次に例を示します。

key phrases api sample call

Ocp-Apim-Subscription-Keyヘッダーの値としてAPIキーを供給するだけです。また、エンドポイントが求める追加のヘッダーが存在する場合も、Add headerをクリックします。そのあとリクエストボディに次の行を追加します。

{
  "documents": [
    {
      "id": 1,
      "text": "this is so very nice for getting good quality sleep"
    }
  ]
}

これはリクエストボディの一般的な構造です。documentsを呼び出すプロパティを含むオブジェクトを供給し、その値としてのオブジェクトの配列を取得します。オブジェクトidtextの2つのプロパティのみを含みます。 指定する各text値が一意に識別されるように、idは一意である必要があります。

リクエストを送信するためにSend(送信)ボタンをクリックします。すると次の応答があります。

key phrases response

リクエストボディで指定したものと同じ構造であることが分かります。今回だけはtextの代わりにkeyPhrasesの配列があります。

また、APIに送信したリクエストに保留中の操作がある場合にのみ使用できるオペレーションステータスAPIもあります。操作は、リクエストのレスポンスボディへデータを取得していない場合、保留中と見なされます。その場合は、APIはステータスコードに202 Acceptedを返します。ここにオペレーションステータスのエンドポイントが入ります。このエンドポイントは次のURLへリクエストするGETに応答します。

https://westus.api.cognitive.microsoft.com/text/analytics/v2.0/operations/{operationId}

operationIdは202ステータスコードを返す、リクエストのIDです。x-aml-ta-request-idのレスポンスヘッダーにあります。

プロジェクトの設定

冒頭で述べたように、EC事業者が自社の顧客に関する理解を深めるために、アプリを構築します。顧客が自社の製品についてどう思っているのか売り手が洞察できる、オンラインストアのバックエンドのごく一部を構築します。ここでテキスト分析APIの出番です。Amazonの製品カスタマーレビューを取得し、APIに送信して解析します。そのあとWebサイトのフロントエンドで結果をレンダリングします。アプリのスクリーンショットは以下です。

センチメント(感情)タブは、顧客が特定の製品に対して持っている平均的な感情を示します。

sentiments

キーフレーズタブは、特定のレビュー文のキーワードやフレーズをハイライト表示します。

key phrases

トピックタブは、顧客が話題にしている上位10のトピックを表示します。

topics

依存オブジェクトのインストール

このプロジェクトにはSlimphpのスケルトンを使用します。次のコマンドでインストールできます。

composer create-project -n -s dev akrabat/slim3-skeleton sp_store

これでsp_storeフォルダーが作成されます。以下のライブラリーをインストールするには、フォルダー内をナビゲートしコンポーザーを使います。

  • slim/pdo:SlimフレームワークのPDOデータベースライブラリー
  • vlucas/phpdotenv:アプリの環境変数を読み込む
  • guzzlehttp/guzzle:APIにリクエストを実行するために使用
  • maximebf/consolekit:APIから結果を取得するコマンドを作成するために使用

データベース

アプリはデータベースも使用します。この項ではデータベーススキーマのSQLダンプが分かります。各テーブルがなにをするかは次のとおりです。

      • requests:応答をまだ返していない操作を格納
      • review:各製品のレビューを格納
      • review_key_phrases:各レビューテキストに見つかったキーフレーズを格納
      • review_sentiments:各レビューのスコアを格納
      • topics:レビューのグループから確定したトピックを格納
      • review_topics:各レビューの確定したトピックと、対応距離を格納

APIに送信する製品レビューの取得に手間をかけたくないという人には、私がテストに使用したレビュー表のデータダンプを使ってください。権利はAmazonおよび本製品のレビューを投稿したすべてのカスタマーに帰属します。

プロジェクトを設定する

プロジェクトディレクトリのルートで.envファイルを作成し、次の行を追加します。

APP_NAME="SP Store"
APP_BASE_URL=http://spstore.dev
DB_HOST=localhost
DB_NAME=sp_store
DB_USER=user
DB_PASS=secret
CS_KEY="YOUR API KEY"

APP_BASE_URLをアプリに割り当てたURLへ、DB_設定をデータベース認証情報へ、CS_KEYをMicrosoft Azure Portalから取得したAPIへ必ず置き換えください。

appディレクトリへ移動してsettings.phpファイルを編集しdisplayErrorDetailstrueに設定すると、うまくいかなかった場合になにが起こっているのか正確に分かります。

'displayErrorDetails' => true,

cacheフォルダーとlogフォルダーのパーミッションを755に変更します。これで、Slimがこれらのディレクトリに書き込めます。

sudo chmod -R 755 cache log

プロジェクトの構築

プロジェクトを構築する準備が整いました。APIにリクエストされている部分で作業をします。

ユーティリティクラス

app/src/Libディレクトリ内に次のファイルを作成します。

  • HttpRequest.php:GuzzleでのHTTPリクエストを簡単に実行するためのヘルパークラス
  • Reviews.php:データベースとのやりとりに使用
  • TextAnalyzer.php:APIへのリクエスト作成に使用

HttpRequest.phpファイルを開き、次のコードを追加します。

<?php
namespace App\Lib;

class HttpRequest 
{
    private $headers;
    private $client;

    public function __construct()
    {
        $this->headers = [
            'Ocp-Apim-Subscription-Key' => getenv('CS_KEY'),
            'Content-Type' => 'application/json',
            'Accept' => 'application/json'
        ];
        $this->client = new \GuzzleHttp\Client(
            ['base_uri' => 'https://westus.api.cognitive.microsoft.com']
        );
    }

    public function make($type, $endpoint, $body)
    {
        try{
            $response = $this->client->request(
                $type, $endpoint, 
                [
                    'headers' => $this->headers, 
                    'body' => $body
                ]
            );
            $response_body = json_decode($response->getBody()->getContents(), true);

            if($response->getStatusCode() == 202){
                $operation_id = $response->getHeaderLine('x-aml-ta-request-id');
                return [
                    'operation_id' => $operation_id
                ];
            }

            return $response_body;

        } catch (RequestException $e) {
            if($e->hasReponse()){
                $error_data = json_decode($e->getResponse()->getBody()->getContents(), true);
                return ['error' => $error_data];
            }
        }
    }
}

上のコードを分解すると、コンストラクターの内部ではヘッダーのAPIからリクエストされたデータを提供します。これには手持ちのAPIキーであるOcp-Apim-Subscription-Keyが含まれています。Content-TypeAcceptのヘッダーは、リクエストボディがJSON形式であることを意味するjsonに設定されています。

$this->headers = [
    'Ocp-Apim-Subscription-Key' => getenv('CS_KEY'),
    'Content-Type' => 'application/json',
    'Accept' => 'application/json'
];
$this->client = new \GuzzleHttp\Client(
    ['base_uri' => 'https://westus.api.cognitive.microsoft.com']
);

makeメソッドは、httpリクエストメソッド($type)、リクエストを実行するAPIのエンドポイント($endpoint)、送信したいデータ($body)を受け取ります。

public function make($type, $endpoint, $body)
{
  ...
}

それらをリクエストに設定します。

$response = $this->client->request(
    $type, $endpoint, 
    [
        'headers' => $this->headers, 
        'body' => $body
    ]
);

応答が返ってきたら、必要なデータを取得するために$responseオブジェクトからメソッドをいくつか呼び出します。APIはjson文字列を返すので、配列に変換するためにjson_decodeを使います。

$response_body = json_decode($response->getBody()->getContents(), true);

「Accepted(承認)」ステータスコード(202)を確認してください。これが応答のステータスコードである場合はリクエストした操作が完了していないことを意味します。したがって$response_bodyを返す代わりにヘッダーからx-aml-ta-request-idを抽出します。これはリクエストされた操作のIDです。取得オペレーションステータスのエンドポイントを呼び出すことにより、あとでこのIDを使用してデータを取得できます。

if ($response->getStatusCode() == 202) {
    $operation_id = $response->getHeaderLine('x-aml-ta-request-id');
    return [
        'operation_id' => $operation_id
    ];
}

return $response_body;

次にTextAnalyzer.phpファイルを開き、以下のコードを追加します。

<?php
namespace App\Lib;

class TextAnalyzer 
{
    private $HttpRequest;

    public function __construct()
    {
        $this->HttpRequest = new HttpRequest();
    }

    public function formatDocs($docs)
    {
        $body = [
          'documents' => $docs
        ];
        return json_encode($body);
    }

    public function requestSentiments($docs)
    {
        $body = $this->formatDocs($docs);
        return $this->HttpRequest->make('POST', '/text/analytics/v2.0/sentiment', $body);
    }

    public function requestKeyPhrases($docs)
    {
        $body = $this->formatDocs($docs);
        return $this->HttpRequest->make('POST', '/text/analytics/v2.0/keyPhrases', $body);
    }

    public function requestTopics($docs)
    {
        $body = $this->formatDocs($docs);
        return $this->HttpRequest->make('POST', '/text/analytics/v2.0/topics', $body);
    }

    public function getAnalysis($request_id)
    {
        return $this->HttpRequest->make('GET', "/text/analytics/v2.0/operations/{$request_id}");
    }
}

上のコードは一目瞭然なので、詳しい解説はしません。各メソッドは、先ほど作成したHttpRequestクラスAPIの別のエンドポイントにリクエストするということは理解しておいてください。formatDocsメソッドは、APIが求める方法でテキストドキュメントを書式設定しなければなりません。リクエストボディにはなにも必要としないため、このメソッドはgetAnalysisメソッド以外の各メソッドで呼び出されます。

Reviews.phpファイルを開き、次の行を追加します。

<?php
namespace App\Lib;

class Reviews 
{
    private $db;

    public function __construct()
    {
        $db_host = getenv('DB_HOST');
        $db_name = getenv('DB_NAME');
        $dsn = "mysql:host={$db_host};dbname={$db_name};charset=utf8";
        $pdo = new \Slim\PDO\Database($dsn, getenv('DB_USER'), getenv('DB_PASS'));

        $this->db = $pdo;
    }

    public function getReviews()
    {
        $select_statement = $this->db->select(['id', 'review AS text'])
                   ->from('reviews')
                   ->where('analyzed', '=', 0)
                   ->limit(100);

        $stmt = $select_statement->execute();
        $data = $stmt->fetchAll();
        return $data;
    }

    public function getSentiments()
    {
        //gets sentiments from DB
        $select_statement = $this->db->select()
            ->from('review_sentiments');

        $stmt = $select_statement->execute();
        $data = $stmt->fetchAll();
        return $data;       
    }

    public function getTopics()
    {
        $select_statement = $this->db->select(['topic', 'score'])
            ->from('topics')
            ->orderBy('score', 'DESC')
            ->limit(10);

        $stmt = $select_statement->execute();
        $data = $stmt->fetchAll();
        return $data;       
    }

    public function getKeyPhrases()
    {
        $select_statement = $this->db->select(['review', 'key_phrases'])
            ->from('review_key_phrases')
            ->join('reviews', 'review_key_phrases.review_id', '=', 'reviews.id')
            ->where('analyzed', '=', 1)
            ->limit(10);

        $stmt = $select_statement->execute();
        $data = $stmt->fetchAll();
        return $data;   
    }

    public function saveSentiments($sentiments)
    {   
        foreach ($sentiments as $row) {
            $review_id = $row['id'];
            $score = $row['score'];
            $insert_statement = $this->db->insert(['review_id', 'score'])
                ->into('review_sentiments')
                ->values([$review_id, $score]);
            $insert_statement->execute();
        }
    }

    public function saveRequest($request_id, $request_type)
    {
        $insert_statement = $this->db->insert(['request_id', 'request_type', 'done'])
                ->into('requests')
                ->values([$request_id, $request_type, 0]);
        $insert_statement->execute();
    }

    public function updateRequest($request_id)
    {
        $update_statement = $this->db->update(['done' => 1])
                ->table('requests')
                ->where('request_id', '=', $request_id);
        $update_statement->execute();
    }

    public function saveTopics($topics_data)
    {
        $topics = $topics_data['topics'];
        foreach ($topics as $row) {
            $topic_id = $row['id'];
            $topic = $row['keyPhrase'];
            $score = $row['score'];
            $insert_statement = $this->db->insert(['topic_id', 'topic', 'score'])
                ->into('topics')
                ->values([$topic_id, $topic, $score]);
            $insert_statement->execute();
        }

        $review_topics = $topics_data['review_topics'];
        foreach ($review_topics as $row) {
            $review_id = $row['documentId'];
            $topic_id = $row['topicId'];
            $distance = $row['distance'];
            $insert_statement = $this->db->insert(['review_id', 'topic_id', 'distance'])
                ->into('review_topics')
                ->values([$review_id, $topic_id, $distance]);
            $insert_statement->execute();
        }
    }

    public function saveKeyPhrases($key_phrases)
    {
        foreach ($key_phrases as $row) {
            $review_id = $row['id'];
            $phrases = json_encode($row['keyPhrases']);
            $insert_statement = $this->db->insert(['review_id', 'key_phrases'])
                ->into('review_key_phrases')
                ->values([$review_id, $phrases]);
            $insert_statement->execute();
        }
    }

    public function getPendingRequests()
    {
        $select_statement = $this->db->select()
            ->from('requests')
            ->where('done', '=', 0);

        $stmt = $select_statement->execute();
        $data = $stmt->fetchAll();
        return $data;   
    }

    public function setDone($from_id, $to_id)
    {
        $update_statement = $this->db->update(['analyzed' => 1])
            ->table('reviews')
            ->whereBetween('id', [$from_id, $to_id]);
        $update_statement->execute();
    }

    public function getAverageSentiment()
    {
        $select_statement = $this->db->select()
           ->from('review_sentiments')
           ->avg('score', 'avg_sentiment');
        $stmt = $select_statement->execute();
        $data = $stmt->fetch();
        return $data['avg_sentiment'];
    }
}

繰り返しますが、このコードは一目瞭然です。コンストラクター内でデータベースに接続します。クラスの各メソッドは、データベースの特定のテーブルに選択、更新、または挿入クエリのいずれかを実行します。

コマンドクラス

この項では、Console Kitライブラリーを拡張するクラスを作成します。これによって、cronを使用して特定の時間にAPIへのリクエストを実行できます。

app/srcディレクトリ内にCommands/Analyze.phpファイルを作成し、次のコードを追加します。

<?php
require 'vendor/autoload.php';

use \App\Lib\TextAnalyzer;
use \App\Lib\Reviews;

class AnalyzeCommand extends ConsoleKit\Command 
{
    public function execute(array $args, array $options = array())
    {
        $dotenv = new \Dotenv\Dotenv(__DIR__ . '/../../..');
        $dotenv->load();

        $reviews = new Reviews();
        $text_analyzer = new TextAnalyzer();

        //check if there are pending requests
        $pending_requests = $reviews->getPendingRequests();
        foreach ($pending_requests as $request) {

            $request_id = $request['request_id'];
            $from_id = $request['from_review'];
            $to_id = $request['to_review'];

            $response = $text_analyzer->getAnalysis($request_id);
            if (strtolower($response['status']) == 'succeeded') {
                $result = $response['operationProcessingResult'];
                $topics = $result['topics'];
                $review_topics = $result['topicAssignments'];

                $reviews->saveTopics([
                    'topics' => $topics,
                    'review_topics' => $review_topics
                ]);

                $reviews->setDone($from_id, $to_id);
                $reviews->updateRequest($request_id);
            }
        }

        $docs = $reviews->getReviews();
        $total_docs = count($docs);

        if ($total_docs == 100) { 
            $from_review = $docs[0]['id'];
            $to_review = $docs[$total_docs - 1]['id'];

            $sentiments_response = $text_analyzer->requestSentiments($docs);    
            $reviews->saveSentiments($sentiments_response['documents']);
            $this->writeln('saved sentiments!');

            $key_phrases_response = $text_analyzer->requestKeyPhrases($docs);
            $reviews->saveKeyPhrases($key_phrases_response['documents']);   
            $this->writeln('saved key phrases!');

            $topics_request_id = $text_analyzer->requestTopics($docs);
            $reviews->saveRequest($topics_request_id, 'topics', $from_review, $to_review);  
            $this->writeln('topics requested! request ID: ' . $topics_request_id);
        }

        $this->writeln('Done!', ConsoleKit\Colors::GREEN);
    }
}

$console = new ConsoleKit\Console();
$console->addCommand('AnalyzeCommand');
$console->run();

上記コードを分解します。vendor/autoload.phpファイルが必要となるので、すべてのライブラリーだけでなく以前に作成したユーティリティクラスを使用します。

require 'vendor/autoload.php';
use \App\Lib\TextAnalyzer;
use \App\Lib\Reviews;

executeメソッドの内部でdotenvライブラリーを初期化するので、設定変数を取得します。

$dotenv = new \Dotenv\Dotenv(__DIR__ . '/../../..');
$dotenv->load();

2つのユーティリティクラスを初期化します。

$reviews = new Reviews();
$text_analyzer = new TextAnalyzer();

完了していない操作をすべて取得します。データベースにおいては、これらはリクエストテーブルに格納されています。done列の0の値を持つすべての行が返されます。

$pending_requests = $reviews->getPendingRequests();

$text_analyzerオブジェクトからgetAnalysisメソッドを呼び出すことで、すべての保留中のリクエストと解析のリクエストをループします。$request_idは、特定のAPIエンドポイントへのリクエストをしたときにAPIによって返されたオペレーションIDであることに注意してください。ステータスが成功した場合の結果を保存しなければ続行できません。これはリクエストが処理され、解析データをフェッチする準備ができていることを意味します。

以下のコードは、トピックの検出エンドポイントの結果のみを考慮しています。これは、センチメントやキーフレーズのエンドポイントが、リクエストされるとすぐにデータを返すためです。必要なデータはtopicstopicAssignmentsキーの下に埋もれているので、$reviewsオブジェクトからsaveTopicsメソッドを呼び出すことで抽出しデータベースに保存します。そのあと、分析したすべてのレビューの完了スイッチを入れるためにsetDoneメソッドを呼び出すので、次回コマンドを実行するときの解析では選択しません。

操作についても同様で、updateRequestメソッドはあとで同じ操作へのリクエストをしないよう、done(完了)に対する操作を設定します。

foreach ($pending_requests as $request) {

    $request_id = $request['request_id'];
    $from_id = $request['from_review'];
    $to_id = $request['to_review'];

    $response = $text_analyzer->getAnalysis($request_id);
    if (strtolower($response['status']) == 'succeeded') {
        $result = $response['operationProcessingResult'];
        $topics = $result['topics'];
        $review_topics = $result['topicAssignments'];

        $reviews->saveTopics([
            'topics' => $topics,
            'review_topics' => $review_topics
        ]);

        $reviews->setDone($from_id, $to_id);
        $reviews->updateRequest($request_id);
    }
}

データベースから製品レビューを取得します。getReviewsメソッドは、結果を100行に制限します。これは、トピックのエンドポイントが動作するには100件以上のレコードを必要とするからです。そのため、続行する前に、返されたドキュメントの総数が100であるか確認します。条件がtrueだった場合、最初と最後の行のIDを決定します。saveRequestメソッドを呼び出すことでリクエストテーブルにこの情報を保存します。これらは保留の操作を処理するために、先ほど使用したコードと同じIDのものです。

次に、requestSentimentsメソッドを呼び出すことでセンチメントエンドポイントのデータをリクエストします。先に述べたとおり、このエンドポイントはすぐに解析データを返すので、saveSentimentsメソッドを呼び出してreview_sentimentsテーブルに保存できます。また、キーフレーズのエンドポイントと同じように保存します。トピックのエンドポイントに関しては、requestTopicsメソッドを呼び出すときにオペレーションIDのみ取得したいので、$topics_request_id変数に格納してデータベースに操作を保存します。この方法は、次回コマンド実行を処理するときに選択されます。

$docs = $reviews->getReviews();
$total_docs = count($docs);

if ($total_docs == 100) { 
    $from_review = $docs[0]['id'];
    $to_review = $docs[$total_docs - 1]['id'];

    $sentiments_response = $text_analyzer->requestSentiments($docs);    
    $reviews->saveSentiments($sentiments_response['documents']);
    $this->writeln('saved sentiments!');

    $key_phrases_response = $text_analyzer->requestKeyPhrases($docs);
    $reviews->saveKeyPhrases($key_phrases_response['documents']);   
    $this->writeln('saved key phrases!');

    $topics_request_id = $text_analyzer->requestTopics($docs);
    $reviews->saveRequest($topics_request_id, 'topics', $from_review, $to_review);  
    $this->writeln('topics requested! request ID: ' . $topics_request_id);
}

それが終わったらファイルを保存し、プロジェクトのルートディレクトリから次のコマンドを実行します。

php app/src/Commands/Analyze.php analyze

実行するときは、レビューテーブル上に少なくとも100件のレコードがあり、.envファイルに有効なAPIキーを供給していることを確認してください。

ルート

public/index.phpファイルを開き、$app->runを呼び出す直前にdotenvライブラリーを初期化します。

$dotenv = new Dotenv\Dotenv('../');
$dotenv->load();

// Run!
$app->run();

app/routes.phpファイルを開きます。そこには次のコードが含まれているはずです。

<?php
// Routes

$app->get('/', App\Action\HomeAction::class)
    ->setName('homepage');

デフォルトルートはapp/src/Actionディレクトリ内のHomeAction.phpファイルを使用します。HomeAction.phpを開き、次のコードを追加します。

<?php
namespace App\Action;

use Slim\Views\Twig;
use Psr\Log\LoggerInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use \App\Lib\Reviews;
use \App\Lib\TextAnalyzer;
use \App\Lib\TextFormatter;

final class HomeAction
{
    private $view;
    private $logger;

    public function __construct(Twig $view, LoggerInterface $logger)
    {
        $this->view = $view;
        $this->logger = $logger;

        $filter = new \Twig_SimpleFilter('highlight', function ($item) {
           $key_phrases = json_decode($item['key_phrases'], true);

           $highlighted_key_phrases = array_map(function($value){
             return "<span class='highlight'>{$value}</span>";
           }, $key_phrases);

           return str_replace($key_phrases, $highlighted_key_phrases, $item['review']);

        });

        $this->view->getEnvironment()->addFilter($filter);
    }

    public function __invoke(Request $request, Response $response, $args)
    {
        $reviews = new Reviews();
        $text_analyzer = new TextAnalyzer();

        $avg_sentiment = $reviews->getAverageSentiment();
        $key_phrases = $reviews->getKeyPhrases();
        $topics = $reviews->getTopics();

        $labels = ['Good', 'Bad'];
        $colors = ['#46BFBD', '#F7464A'];
        $highlights = ['#5AD3D1', '#FF5A5E'];

        $first_value = $avg_sentiment;
        $second_value = 1 - $avg_sentiment;

        if($second_value > $first_value){
            $labels = array_reverse($labels);
            $colors = array_reverse($colors);
            $highlights = array_reverse($highlights);
        }

        $sentiments_data = [
            [
                'value' => $first_value,
                'label' => $labels[0],
                'color' => $colors[0],
                'highlight' => $highlights[0]
            ],
            [
                'value' => $second_value,
                'label' => $labels[1],
                'color' => $colors[1],
                'highlight' => $colors[1]
            ]
        ];

        $page_data = [
            'app_name' => getenv('APP_NAME'),
            'sentiments_data' => json_encode($sentiments_data),
            'key_phrases' => $key_phrases,
            'topics' => $topics
        ];
        $this->view->render($response, 'home.twig', $page_data);

    }
}

上のコードを分解すると、必要なすべてのライブラリーを要求しています。

use Slim\Views\Twig;
use Psr\Log\LoggerInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use \App\Lib\Reviews;
use \App\Lib\TextAnalyzer;
use \App\Lib\TextFormatter;

コンストラクター内部では、多量のテキストにある特定の単語や語句のハイライトを可能にするカスタムtwigフィルターを追加します。このカスタムフィルタは、キーフレーズとレビューテキストを含む各$itemを受け取ります。

データベースのreview_key_phrasesテーブルから得られる$key_phrasesの値は、JSON文字列です。json_decodeを使用している配列に戻って変換しなければならないのは、それが理由です。次に、ハイライトのクラスがあるspanで配列内の項目をラップするためにarray_mapを使用します。そしてテキストをハイライトするCSSを使用して、あとでこれをターゲットにします。最後に$key_phrasesのすべての発現をレビューテキストの$highlighted_key_phrasesに置き換えるためにstr_replaceを使用します。

$filter = new \Twig_SimpleFilter('highlight', function ($item) {
   $key_phrases = json_decode($item['key_phrases'], true);

   $highlighted_key_phrases = array_map(function($value){
     return "<span class='highlight'>{$value}</span>";
   }, $key_phrases);

   return str_replace($key_phrases, $highlighted_key_phrases, $item['review']);

});

__invokeメソッド内は、ホームページにアクセスがあったときに実行したいコードです。ページに必要なすべてのデータを取得しフォーマットする場所です。

$reviews = new Reviews();
$text_analyzer = new TextAnalyzer();

$avg_sentiment = $reviews->getAverageSentiment();
$key_phrases = $reviews->getKeyPhrases();
$topics = $reviews->getTopics();

$labels = ['Good', 'Bad'];
$colors = ['#46BFBD', '#F7464A'];
$highlights = ['#5AD3D1', '#FF5A5E'];

$first_value = $avg_sentiment;
$second_value = 1 - $avg_sentiment;

if ($second_value > $first_value) {
    $labels = array_reverse($labels);
    $colors = array_reverse($colors);
    $highlights = array_reverse($highlights);
}

$sentiments_data = [
    [
        'value' => $first_value,
        'label' => $labels[0],
        'color' => $colors[0],
        'highlight' => $highlights[0]
    ],
    [
        'value' => $second_value,
        'label' => $labels[1],
        'color' => $colors[1],
        'highlight' => $colors[1]
    ]
];

$page_data = [
    'app_name' => getenv('APP_NAME'),
    'sentiments_data' => json_encode($sentiments_data),
    'key_phrases' => $key_phrases,
    'topics' => $topics
];
$this->view->render($response, 'home.twig', $page_data);

上のコードを分解すると、現在データベースに格納されている平均センチメント、キーフレーズ、トピックをリクエストします。

$avg_sentiment = $reviews->getAverageSentiment();
$key_phrases = $reviews->getKeyPhrases();
$topics = $reviews->getTopics(); 

ページのグラフで使用するためにデータを宣言します。レビューにある購入者のセンチメントを表現するために円グラフを使います。以下には、それぞれに2つの項目がある3つの配列があります。これは、1つの製品には良いか悪いかのどちらかの2つのセンチメントと決まっているからです。データベースから得た平均センチメントが良い面を示していることを前提としています。

$labels = ['Good', 'Bad'];
$colors = ['#46BFBD', '#F7464A'];
$highlights = ['#5AD3D1', '#FF5A5E'];

取得した平均センチメントと1との間の差を計算します。これは円グラフの残りの半分の割合(悪い側)になります。

$first_value = $avg_sentiment;
$second_value = 1 - $avg_sentiment;

円グラフの残りの半分が平均センチメントより大きい場合は、先に宣言していた各配列を逆転させます。これはデフォルトデータでは平均センチメントが良い面であるとみなされるためです。

if ($second_value > $first_value) {
  $labels = array_reverse($labels);
  $colors = array_reverse($colors);
  $highlights = array_reverse($highlights);
}

クライアント側のスクリプトですぐに使える方法に、データをフォーマットします。

$sentiments_data = [
    [
        'value' => $first_value,
        'label' => $labels[0],
        'color' => $colors[0],
        'highlight' => $highlights[0]
    ],
    [
        'value' => $second_value,
        'label' => $labels[1],
        'color' => $colors[1],
        'highlight' => $colors[1]
    ]
];

ページに供給されるデータを構築し、ページをレンダリングします。ページでJavaScript変数の値としてレンダリングできるように$sentiments_dataをjsonに変換していることに注意してください。

$page_data = [
    'app_name' => getenv('APP_NAME'),
    'sentiments_data' => json_encode($sentiments_data),
    'key_phrases' => $key_phrases,
    'topics' => $topics
];

$this->view->render($response, 'home.twig', $page_data);

フロントエンド

app/templates/home.twigファイルを開き、以下を追加します。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{ app_name }}</title>
  <link rel="stylesheet" href="/lib/mui/packages/cdn/css/mui.min.css">
  <link rel="stylesheet" href="/css/style.css">
  <script src="/lib/mui/packages/cdn/js/mui.min.js"></script>
  <script src="/lib/Chart.min.js"></script>
  <script>

  var sentiments_data = {{ sentiments_data|raw }}

  </script>
</head>
<body>
  <header class="mui-appbar mui--z1">
    <strong id="app-name">{{ app_name }}</strong>
  </header>

  <div id="content-wrapper" class="mui--text-center">
    <ul class="mui-tabs__bar">
      <li class="mui--is-active">
        <a data-mui-toggle="tab" data-mui-controls="pane-default-1">Sentiments</a>
      </li>
      <li>
        <a data-mui-toggle="tab" data-mui-controls="pane-default-2">Key Phrases</a>
      </li>
      <li>
        <a data-mui-toggle="tab" data-mui-controls="pane-default-3">Topics</a>
      </li>
    </ul>

    <div class="mui-tabs__pane mui--is-active" id="pane-default-1">
      <canvas id="sentiments_chart" width="400" height="400"></canvas>
    </div>

    <div class="mui-tabs__pane" id="pane-default-2">
      <ul class="align-left">
      {% for row in key_phrases %}
        <li>{{ row | highlight|raw }}</li>
      {% endfor %}
      </ul>
    </div>

    <div class="mui-tabs__pane" id="pane-default-3">
      <table class="mui-table mui-table--bordered">
        <thead>
          <tr>
            <th>Topic</th>
            <th>Score</th>
          </tr>
        </thead>
        <tbody>
          {% for row in topics %}
          <tr>
            <td>{{ row.topic }}</td>
            <td>{{ row.score }}</td>
          </tr>
          {% endfor %}  
        </tbody>
      </table>   
    </div>
  </div>

  <script src="/js/main.js"></script>
</body>
</html>

Material UIはアプリのスタイリングに使用されます。

<link rel="stylesheet" href="/lib/mui/packages/cdn/css/mui.min.css">

Chart.jsは円グラフに使われます。CloudFlareからChart.jsをダウンロードできます。bowerまたはnpm経由でChart.jsも取得できますが、本チュートリアルで使用するバージョンが1.1.1であることに注意してください。また、本記事執筆時点ではベータ版である新しいバージョンで一部API変更があることにも注意してください。それを使用したい場合は、main.jsファイルのコードを更新する必要があります。

<script src="/lib/Chart.min.js"></script>

これらはフロントエンドの唯一の依存オブジェクトです。

内部スクリプトでは、sentiments_data変数の値をコントローラーから渡されたjson文字列へ代入します。Twigによるrawフィルタの取り扱いに注意してください。これによってJSON文字列を現状通りでレンダリングできます。

<script>

var sentiments_data = {{ sentiments_data|raw }}

</script>

ページの主な内容として、センチメントの円グラフ、キーフレーズ、トピックの、3つのタブがあります。

センチメントの円グラフには、事前定義された幅と高さのカンバスがあります。

<div class="mui-tabs__pane mui--is-active" id="pane-default-1">
  <canvas id="sentiments_chart" width="400" height="400"></canvas>
</div>

キーフレーズタブの内部では、データベースが返した結果をループします。そしてループ内では、highlightrawフィルターを適用します。highlightフィルターがどのように動作するかについては、多くの人が知っていることなので説明しません。rawフィルターに関して言えば、highlightフィルターがHTMLを出力し、HTMLの終了を防ぐためにその出力を使用するので必要です。

<div class="mui-tabs__pane" id="pane-default-2">
  <ul class="align-left">
  {% for row in key_phrases %}
    <li>{{ row | highlight|raw }}</li>
  {% endfor %}
  </ul>
</div>

トピックタブについては、各スコアとともにトピックの上位10件を表示するためのテーブルを使用します。

<div class="mui-tabs__pane" id="pane-default-3">
  <table class="mui-table mui-table--bordered">
    <thead>
      <tr>
        <th>Topic</th>
        <th>Score</th>
      </tr>
    </thead>
    <tbody>
      {% for row in topics %}
      <tr>
        <td>{{ row.topic }}</td>
        <td>{{ row.score }}</td>
      </tr>
      {% endfor %}  
    </tbody>
  </table>   
</div>

public/js/main.jsファイルを作成し、以下を追加します。

var sentiments_ctx = document.getElementById('sentiments_chart').getContext("2d");
var sentiments_chart = new Chart(sentiments_ctx).Pie(sentiments_data);

sentiments_data変数に格納されたデータに基づいて円グラフを作成するためのコードです。

最後にpublic/css/main.cssファイルを作成します。次のコードが含まれています。

#content-wrapper {
  width: 500px;
  margin: 0 auto;
}

li {
  margin-bottom: 20px;
}

.mui-table {
  text-align: left;
}

#app-name {
  font-size: 30px;
}

header {
  padding: 10px;
}

.mui-tabs__pane {
  padding-top: 40px;
}

.align-left {
  text-align: left;
}

span.highlight {
  background-color: #FAFA22;
  padding: 5px;
}

最後に

これで終了です。本記事ではEC業者が自社製品の状態を調査するためのマイクロソフトText Analytics APIを利用する方法を学びました。具体的には、APIの検出機能であるセンチメント、キーフレーズ、トピックを使用しています。

Github repoのレポートにプロジェクトのソースコードがあります。

機械学習の良さにはほかにどのようなものがあるか参照するために、マイクロソフトのCognitive Services APIWebサイトを確認することをおすすめします。

※本記事はHaydar KÜLEKCİが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Picking the Brains of Your Customers with Microsoft’s Text Analytics

[翻訳:柴田理恵/編集:Livit

Copyright © 2016, Wern Ancheta All Rights Reserved.

Wern Ancheta

Wern Ancheta

フィリピン出身のWeb開発者です。Web構築に情熱を注ぎ、ノウハウをブログで公開しています。アニメとビデオゲームも大好き。

Loading...