Istio で gRPC サービスの AB テスト基盤っぽいものを作ってみる

機械学習を用いたサービスを開発する際、推論用の API サーバを立ててリクエストを受ける構成が多いと思います。モデルを改良したときには、全てのリクエストを新しいモデルをサービングする API に渡すわけではなく、トラフィックを分散して AB テストを行い、何らかのメトリクスが改善するかを確認することが多々あります。また、ここ数年は gRPC でAPI サーバを実装することも多いと思います。

基盤に Kubernetes を利用している場合、Istio の Traffic Management により指定の割合でトラフィックを分散させて AB テストが可能です。今回は、Istio で簡易的な gRPC サービスの AB テスト基盤を作ってみたのでメモ代わりに記載します。

検証に使用した version 情報は以下です。

  • Kubernetes
    • minikube v1.20.0
  • Istio
    • 1.10.2

また、ソースコードは以下に置いてあります。

github.com

準備

今回はローカルの Kubernetes 環境として Minikube を使用します。

まず、 Minikube を起動します。

$ minikube start

また、後ほど Istio をインストールした際に作られる istio-ingressgateway という LoadBalancer の service のため、以下のコマンドを実行します。(別のターミナルで実行することをおすすめします)

$ minikube tunnel

次に、Istio をインストールします。方法は多くありますが、今回は istioctl を使ってインストールします。 ドキュメントの Download Istio に従ってダウンロードし、 istioctl コマンドが利用できるようにします。 そして、preset の demo profile を使用して Istio をインストールします。(その他の profile については こちら を参照ください)

$ istioctl install --set profile=demo

インストールが終わると、 istio-system という namespce に istio-ingressgateway という service がデプロイされています。これがクラスタ外部からのリクエストの受け口です。

$ kubectl get service -n istio-system
NAME                   TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                                                                      AGE
istio-egressgateway    ClusterIP      10.96.47.101    <none>          80/TCP,443/TCP                                                               4h51m
istio-ingressgateway   LoadBalancer   10.103.48.202   10.103.48.202   15021:30365/TCP,80:31206/TCP,443:32478/TCP,31400:32443/TCP,15443:32189/TCP   4h51m
istiod                 ClusterIP      10.98.134.165   <none>          15010/TCP,15012/TCP,443/TCP,15014/TCP                                        4h51m

また、アプリケーションをデプロイした際に pod に Envoy がサイドカーとして自動で挿入されるように label を付与します。(今回は default の namespace を使用します)

$ kubectl label namespace default istio-injection=enabled

アプリケーションのインストール

デモ用の gRPC サービスをデプロイします。今回デモ用として作成したのは、name を受け取りその name を利用した message を返すだけのアプリケーションです。 grpcurl によるリクエスト例は以下です。

$ grpcurl -d '{"name": "your name"}' -plaintext localhost:50051 sample.HelloService/SayHello
{
  "message": "Hello your name"
}

以下の manifest を apply します。v1 は 受け取った name をそのまま (Hello your name)、v2 は namesan を付けて (Hello your name san) 返します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-server-v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-server
  template:
    metadata:
      labels:
        app: hello-server
        version: v1
    spec:
      containers:
      - name: hello-server
        image: unpuytw/grpc-hello-server:v1
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 50051
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-server-v2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-server
  template:
    metadata:
      labels:
        app: hello-server
        version: v2
    spec:
      containers:
      - name: hello-server
        image: unpuytw/grpc-hello-server:v2
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 50051
---
apiVersion: v1
kind: Service
metadata:
  name: hello-server-service
spec:
  selector:
    app: hello-server
  ports:
  - name: grpc-api
    protocol: TCP
    port: 50051
    targetPort: 50051
  type: ClusterIP

ここで大事なのは、service の port の name を grpc-* にしなければいけないっぽいです。

An ordered list of route rules for HTTP traffic. HTTP routes will be applied to platform service ports named ‘http-’/‘http2-’/‘grpc-*’, gateway ports with protocol HTTP/HTTP2/GRPC/ TLS-terminated-HTTPS and service entry ports using HTTP/HTTP2/GRPC protocols. The first rule matching an incoming request is used.

Istio / Virtual Service

また、pod を確認すると 2 つのコンテナが起動しています。1 つが gRPC サーバのコンテナ、もう 1 つが自動で挿入されるように設定した Envoy のコンテナです。

$ kubectl get pod
NAME                               READY   STATUS    RESTARTS   AGE
hello-server-v1-6f59f85596-j4q89   2/2     Running   0          5h30m
hello-server-v2-6dc6684798-xbfm4   2/2     Running   0          5h30m

Traffic Management 用リソースのデプロイ

Istio の Traffic Management のため、Gateway, Virtual Service, Destination Rule をデプロイします。

以下の manifest を apply します。VirtualService リソースで指定していますが、今回は v1 に 90% 、v2 に 10% のトラフィックを割り当てます。

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: ab-gateway
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
      - "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: ab-virtualservice
spec:
  hosts:
  - "*"
  gateways:
  - ab-gateway
  http:
    - route:
      - destination:
          host: hello-server-service
          subset: v1
          port:
            number: 50051
        weight: 90
      - destination:
          host: hello-server-service
          subset: v2
          port:
            number: 50051
        weight: 10
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: grpc-api
spec:
  host: hello-server-service
  subsets:
    - name: v1
      labels:
        version: v1
    - name: v2
      labels:
        version: v2

動作確認

まず、クラスタ内から hello-server-service に対してリクエストを送ってみて正常に動作している確認します。そのために、gRPC のクライアントとして grpcurl が使用できるコンテナをデプロイして、そのコンテナからリクエストを送ってみます。使っているイメージは こちら で用意した golang のベースイメージに grpcurl をインストールしただけのものです。

以下のように、正常にリクエストが返ってくることが確認できました。

$ kubectl run grpc-client --rm -it --image=unpuytw/grpc-client sh
root@grpc-client:/go# grpcurl -d '{"name": "your name"}' -plaintext hello-server-service:50051 sample.HelloService/SayHello
{
  "message": "Hello your name"
}

次に、クラスタ外から istio-ingressgateway に対してリクエストを送ってみて、正常に Traffic Management が動作しているかを確認します。

こちらも、以下のように正常にリクエストが返ってくることが確認できました。

$ export INGRESS_HOST=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
$ grpcurl -d '{"name": "your name"}' -plaintext $INGRESS_HOST:80 sample.HelloService/SayHello
{
  "message": "Hello your name"
}

Traffic Management の検証用に複数リクエストを送るため、 ghz というツールを見つけたので使用してみます。

github.com

また、トラフィック分散の確認のため kiali を使ってみます。以下でインストールできます。(${ISTIO_HOME} は Istio をダウンロードしたパスです。)

$ kubectl apply -f ${ISTIO_HOME}/samples/addons

以下で起動できます

$ istioctl dashboard kiali

それでは、1000 件のリクエストを送ってみます。以下で可能です。

$ ghz --call sample.HelloService/SayHello \
    -d '{"name": "your name"}' \
    -n 1000 \
    --insecure \
    $INGRESS_HOST:80

kiali のコンソールを確認すると、v1 に約 90%、 v2 に約 10% とトラフィックを指定した割合で分散できていることが以下のように確認できました。

f:id:pompom168:20210702222624p:plain

今回はこれで終わりです。これをベースに Istio の機能を諸々確認したいと思います。

参考

ここ半年くらいを振り返る

現職に転職して半年くらいが経ちこれまで経験してないことを結構やった気がするので、備忘録として残しておきます。(誰かが記憶と記録が大事と言っていた気がするので)

11月〜1月

ここらへんの時期は社内の開発環境に慣れるためもあり、あるプロダクトで上手くいった事例を他の事例に移植するための開発などを主にやっていた気がします。

特にAWS、コンテナベースの開発、Digdagのワークフロー定義、GolangによるAPI開発あたりに慣れることができました。

2月〜4月上旬

ここらへんの時期は、外部企業が使うことを想定したサービス開発を行っていました。完全にゼロからの開発であったため、かなり多くのことを経験できました。

具体的には、以下のようなことです

  • 機械学習のロジック設計
  • AWS上のインフラ構築(S3, ECR, ECS, DynamoDB, IAM, SG, ELB, Fargate
    • Terraformによる自動構築
  • デプロイフロー整備
  • Digdagサーバ構築
  • Pythonによる機械学習バッチの開発
  • Golangによる配信APIの開発
  • Fluentdによるログ収集

以下の記事では、データに対する向き合い方について3つのパターンに分けていました。(データの生成・蓄積・活用)

今までのキャリアでは、データの活用のみをやっていましたが、転職後はデータの生成をメインでやることが出来ている気がします。

www.yasuhisay.info

4月中旬〜5月

現在は、既存のプロダクトの推薦システムの改善を行っています。基本は、ロジックの検証・バッチ・APIの改修あたりです。

また、せっかくAWSに慣れたので資格でも取ってみようと思い、AWS Solution Architect Associateを受験して合格することが出来ました。

まとめ

これまでのキャリアでは、基本的に機械学習の方法論と学習部分の実運用までがメインでした。 ここ半年は、APIを通した配信、AWS上のインフラ構築、ワークフロー開発、ログ収集など幅広く経験することができ、多少は機械学習エンジニアといえることを経験出来た気がします。

参考になった本など

最後にここ数ヶ月で参考になった本などを残しておきます。

コーディング

  • 現場で役立つシステム設計の原則
  • Clean Architecture
  • コーディングを支える技術

システム設計

AWS

  • みんなのAWS

その他

  • Pragmatic Terraform on AWS
  • データ分析基盤構築入門
  • 自走プログラマ

はてなブログのブックマーク数を予測するwebアプリを作った

タイトルのとおり、はてなブログの記事の内容からはてなブックマーク数(はてブ数)を予測するwebアプリを作りました。

※追記: コストが増加してきたので一旦サービスを止めています…

以下では背景や方法、システムの概要や今後の展望について説明します。

背景

近年、インフルエンサーという言葉に代表されるようにSNSなどで発信力を持つことが重要になっています。インフルエンサーが発信する情報がバズることで、情報を多くの人に伝えることが可能になります。

また、企業が技術ブログを運用して自社の技術を発信することも増えています。企業のブランディングという点でも、技術ブログをバズらせて多くの人に見てもらうことは重要です。しかし、バズる記事を作成することは簡単ではありません。

そこでバズる記事を書く支援をするシステム、バズり支援システム(Buzz Automation System; BAS)が求められます。例えば、下書きを作成してシステムに入力すると、文章の構成などを最もバズるように再構成するような利用例が考えられます。

今回はバズり支援システムの第一段階として、企業の技術ブログとして用いられていることが多い、はてなブログのはてブ数を予測するシステムを作成します。作成に至った一番の大きな理由は、私が機械学習を使ったwebアプリを実際に運用・改善したかったからです。

方法

学習データの記事とはてブ数の収集、学習処理について説明します。

記事とブクマ数の収集

ここに一番時間がかかりました。。。最終的に以下の手順で収集しました、

つまり、ある時点のホットエントリのページを起点に、

ページ -> そのページをブックマークしているユーザー -> そのユーザーが他にブックマークしているドメイン

のようにドメインを収集して、そのドメインを起点にクローリングしています。

ものすごく周りくどいですが、ブックマークされていないページを収集することが難しく、上のようなドメインを収集することに着目した手順になりました。

ある時点のホットエントリを利用して、959個のドメインを収集することができました。そこからランダムに100個のドメインを抽出してクローリングを行い、77176個のページを収集することが出来ました。

ドメインを取得できていることを前提とした、クローラーのソースコードは以下です。

github.com

はてブ数の学習

問題を以下の2つに切り分けました。

  • はてブされるかどうかの分類問題
  • はてブされる記事の中ではてブ数の回帰問題

1つでもブックマークされる記事とされない記事では、記事のクオリティにかなり差がある、もしくはブログの存在自体が全く認知されてないことが考えられます。 よって、まずはてブされるかどうかの分類を行い、はてブされると判定された記事に対してははてブ数の回帰を行うようにしました。

はてブ分類問題

正解ラベルとして、はてブされていない記事に0、1つでもはてブされている記事に1のラベルを与えます。

特徴量としては、記事の文章を分かち書きした単語のBoWにSVDで次元削減を行ったもの、単語数、文字数を用いました。今回は早くサービスとして公開したかったため、かなり適当に決めました。

バズるかどうかは、画像の存在もかなり大きいと思いますし、また先程述べたようにブログの存在自体の認知も影響すると思うので、過去の記事やはてブ数も効果的な特徴量な気がします。また、技術的な記事ではプログラムが記載されていることも多く、自然言語とは別な扱いをした方が良い気もします。これらは、今後の課題とします。

また分類器としては、CatBoostを用いました。これもハイパーパラメータチューニングを行いたくなく、デフォルトのパラメータでそこそこの性能が出ると言われているCatBoostを採用しました。

はてブ数回帰問題

はてブ数1以上の記事に対して、回帰モデルを学習します。ただ、はてブ数が少ない記事の方が圧倒的に多くなっており、数千のはてブ数の記事がぽつぽつとある分布になっています。

全てを使って学習すると、少数のはてブ数が多い記事に悪影響を受けそうだったので、はてブ数があるしきい値以上の記事は学習データから除去しました。はてブ数の95パーセンタイル値で74だったので、そこらへんの値をしきい値にしようと思いましたが、一旦雰囲気でしきい値を100に設定しました。

この時点でバズる記事の予測が出来なくなっていますが、これも今後の課題とさせていただきます。

特徴量や回帰モデルは分類問題と同じです。

システムの概要

紆余曲折があり、以下のようなアーキテクチャになりました。基本的にAWSで構築しています。

f:id:pompom168:20200121012006p:plain

後段のEC2では、任意のはてなブログのURLに対してコンテンツをスクレイピングしてはてブ数を予測するAPIが動いています。実装はfastapiで行いました。前段のEC2では、web/APサーバとしてのgunicornを使用してflaskのwebアプリを動かしており、APIにリクエストを送ります。ELBを挟んでいますが、これは冗長化のためではなくSSL化のためです。

この構成になっている理由は、最初は以下の図のようなSPA (Single Page Application) + APIの構成を考えていたからです。APIもGoで実装しようかと思っていたんですが、リリースの早さを優先しPythonで実装しました。fastapiも使ってみたかったこともあるので、とりあえず良しとしました。また、リリースの早さを優先した結果フロント部分をSPAにすることも諦めました。結局PythonでFlaskを使用することにしました。

f:id:pompom168:20200121004356p:plain

ただ、EC2が存在する構成だと割とお金がかかるので、以下のようなサーバレスな構成に移行したいと今は考えています。

f:id:pompom168:20200121004416p:plain

APIとwebアプリそれぞれのリポジトリは以下にあります。

github.com

github.com

まとめと今後の展望

バズり支援システムの第一弾として、はてなブログのブクマ数を予測するwebアプリを作りました。ただ、今後やらなければならない/やりたいことが山ほどあるので改善していこうと思っています。以下のようなことを考えています。

予測に関すること

  • 特徴量に画像を使う
  • はてブ数が100を超えるような記事についても予測を行えるようにする
  • コードの部分をよしなに処理する
  • 予測するページより過去の情報を組み込む
  • モデルの更新を行い最新の情報を使う
  • 外部のトレンド情報(Twitterなどで話題になっていること)を組み込む
  • はてなスターの情報も使用する

システムに関すること

  • モデルの更新を行うための、記事収集・学習ワークフロー
  • フロントのSPA
  • APIをGoで実装
  • サーバレスなアーキテクチャに
  • デプロイ周りを整える
  • CI/CD

ちなみにこの記事の予測ブクマ数は25でした。やはり、バズり支援システム(Buzz Automation System; BAS)の登場が待たれます。

人生のステージに応じた壁と井口祐未

これは SHIROBAKO Advent Calendar 2019 22 日目の記事です。

SHIROBAKO とは一体何なのか。この問いに対して 10日目の記事 では、 「辿り着きたい場所」に向かっていく物語 という一つの仮説にたどり着いていました。答えは無数にありそうですが、それを抽象化した結果が SHIROBAKO は人生 なのだと思います。

それは SHIROBAKO に登場するストーリー/キャラクターが、誰しもの過去/現在/未来の人生と重なる部分があるからです。今回は数ある SHIROBAKO と自分の人生の重なりの中でも、井口祐未のストーリーについて振り返ってみようと思います。

ちゃぶだい返し

『ちゃぶだい返し』は 16話のタイトルであり、ストーリーとしてはキャラクターデザインを担当している井口のデザインに対して原作者からダメ出しを受け、修正を繰り返していくうちに井口が追い詰められていく話です。これまで武蔵野アニメーションのアニメーターの中ではエース級の存在であった井口から、「もう、どう直していいか分かんなくなっちゃって…」や「見えない…」などかなり弱気な発言が出てきます。

f:id:pompom168:20191221214846p:plain
©「SHIROBAKO」製作委員会

ところで、 SHIROBAKO が放送されていた 2014年10月 - 2015年3月は、自分は大学4年でちょうど卒業研究を行っていた時期でした。そしてリアルタイムで『ちゃぶだい返し』を観たとき、「あ、これ完全に自分だ」と思いました。

原作者からのダメ出しは、「何かダメ」レベルの非常に抽象的なものです。これに対して、宮森をはじめ武蔵野アニメーションの面々は修正案を固めることが出来ず、井口も修正の方向性が定まっていないことから泥沼にはまっていきます。

f:id:pompom168:20191221220150p:plain
©「SHIROBAKO」製作委員会

自分も研究活動における指導教員との打ち合わせで、どう進んでいいか分からない抽象的な指摘を受けて、完全に方向性を見失っていました。(これは今思うとその時の知識と経験不足が大きいです)

同じ研究室で大学院への進学も決まっていましたが、本当に辞めることを考えるほど思い詰まっていました。

SHIROBAKO においては、井口にとって先輩アニメーターである小笠原のフォローにより、原作者との合意を得ることができるキャラクターデザインを成し遂げます。自分はそのような感動的なストーリーがあったわけではないですが、SHIROBAKO のこの話を心の拠り所にしてとりあえず続けることができました。そして、大学院では根本的に研究テーマを変更することになり、今ではその研究分野の専門性を活かした仕事をすることが出来ています。SHIROBAKO が無ければ、今自分は何をしていたのかを考えると、少し怖いものがあります。

ステージに応じた壁がある

そしてこの話で分かることは、SHIROBAKO ではステージに応じた乗り越えなければならない壁が描かれていることです。

井口はそれまで武蔵野アニメーションのエース級のアニメーターとして描かれていました。2話では監督の急な作画の変更に対応し、8話ではスピードとクオリティの狭間で悩む安原絵麻に対して的確なアドバイスをするなど絶対的な存在でした。

f:id:pompom168:20191221224400p:plain
©「SHIROBAKO」製作委員会

そんな井口でも、キャラクターデザインという新しいステージに入ると、自分を見失い一人では解決できない状況に陥ることが描かれています。主要キャラクター五人ではない、井口以外に対しても多種多様なエピソードが描かれていて、そこには各々の人生のステージにおける様々なエピソードがあります。だからこそ、誰しもの人生の一部分と重なるところがあるのです。

あなたはどの話と重なりますか?

これからも人生において、また SHIROBAKO と重なるような出来事があって、その度に僕は SHIROBAKO を観るのでしょう。(劇場版もありますね)

もう2019年も終わりですね。どんな一年だったでしょうか。どんな話と重なりがあったでしょうか。一年の振り返りも兼ねて SHIROBAKO を観てみるのはいかがでしょうか。また、新しい重なるストーリー/キャラクターを見つけることができるかもしれませんね。

WebDB Forum 2019で技術報告しました

9月8日〜9日で開催されたWebDB Forum 2019に、スポンサー企業の技術報告という形で登壇しました。

db-event.jpn.org

8日の夜から9日の早朝にかけて台風15号が直撃するというまさかの展開を迎えましたが、柔軟なスケジュール変更で予定されていた全てのセッションを実施した運営の方々は本当に素晴らしいと思いましたし、大変感謝しています。

f:id:pompom168:20190910000526j:plain

WebDB Forum

WebDB Forumはそのカンファレンス名のとおり、Webのデータを使用した解析や機械学習の応用とデータベース関連技術についての発表が行われる割と珍しい(個人的に思っている、機械学習系とデータベース系が同列に扱われているところが)カンファレンスです。

特徴的なのは、研究発表だけでなく企業の技術報告も多数行われるところでしょうか。そのため、学生(アカデミア)と企業の交流が盛んに行われることが他の学会や研究会との違いとなっています。

私自身、学生のときは参加したことが無かったのですが、今回参加してみて非常に良いカンファレンスだと感じました。もし学生のときに参加していれば、企業での機械学習活用事例を知ることができ、就活の選択肢が広がっていたなと思いました。(新卒で就活に失敗した身としては)

発表内容

自分が入社してから、アドテクの会社では肝である入札価格を決定するためのCTR(Click Through Rate)予測をどうやって改善してきたかを話しました。

実際の発表資料も公開しています。

speakerdeck.com

現職に転職してからは何となくWebDB Forumで発表することを1つの目標としていました。そのために、技術報告という形でも発表できるレベルを心がけて仕事をしてきたつもりですし、実際に発表の機会を作ってくれた会社にも感謝しています。

おわりに

研究発表や企業の技術報告を聞いて、全然知らなかった課題設定や割とすぐに業務でも使えそうな内容がありました。なので、また仕事へのモチベーションも上がってきました。また発表できるように、バリバリやっていきたいという気持ちです。

Wikipediaを活用した表記ゆれへの対応

自然言語処理のタスクにおいて、表記ゆれの問題が常につきまといます。単純なパターンであれば単純なルールで対処が可能です。例えばアルファベットの大文字・小文字の混在であれば全て小文字に変換すれば良いし、半角文字と全角文字の混在であれば全て全角文字に変換すれば良いでしょう。

しかし、略語はどうでしょうか。例えばPCはおそらくパソコンのことですね。ただ、かしこまった文書だとパーソナルコンピュータと表記されているかもしれません。こうなってくると、単純なルールでの対処はもう難しいでしょう。

そこで、単語の正規化(名寄せ)が必要です。単語の正規化の話は、Sansanの発表資料に良くまとまっています。大きく分けて以下の方法が考えられます。

  • 単語マスタを用意しておき、入力単語に対してレーベンシュタイン距離(編集距離)を計算して最も距離が小さい単語を採用する

    • 方法としては単純で扱いやすい
    • マスタの用意が必要
    • PCパソコンのような略語に対応できない
  • word2vecなどの単語埋め込みを用いて、入力単語に対して類似語を抽出する

    • こちらの記事が実験の例
    • モデルの学習が必要
    • 目的に応じたコーパスの用意が必要
  • Encoder-Decoderを用いて、入力単語に対する正規化後の単語を生成する

    • こちらにcookpadの実験の例
    • モデルの学習が必要
    • 正解データとして正規化前後の単語のペアの準備が必要

個人的な印象としては、マスタ+レーベンシュタイン距離はOCR結果の補正とかに良さそうです(プリッツの認識結果がフ・リッツとかになってしまうパターン)。ただ単語の正規化としては、前述の略語への対応の難しさから厳しいかなという感じです。単語埋め込みの方法は、単語の正規化を行いたいドメインが限られている場合は良さそうです。具体的には、とにかく化粧品の表記ゆれが解消できれば良いんだ!みたいなパターンです。ただ、正規化を行わなくて良い単語もあるはずで、そのような単語も何らかの変換がされてしまうことが問題です。Encoder-Decoderでは入力の単語をそのまま出力することも可能なので、最も良さそうです。ただ、学習データ作成のコストが非常に高く、そこの壁を解消するための解決策がもう1つ必要でしょうか。

今回は、マスタやコーパス、正解データの新規作成を行うことなく、また機械学習も行わず表記ゆれへの対応を行う1つの案を紹介します。具体的には、エンティティリンキングの結果を単語の正規化結果として利用します。

Wikipediaを活用した表記ゆれへの対応方法

以下のエンティティリンキングまでの考えは、以下の資料をがっつり参考にしています。

www.slideshare.net

エンティティ辞書

まず、エンティティ辞書という概念を導入します。

例えば、以下のWikipediaのiPhoneのページにおいて、リンクが張られているアップルという単語には、Wikipedia中のアップル(企業)のページを指しています。ここで、リンクが張られている単語アップルエンティティ名、リンク先のページのタイトルアップル(企業)エンティティと呼びます。

f:id:pompom168:20190809152746p:plain

wikipediaの記事に出現するエンティティ名とエンティティを抽出して記録することで、エンティティ名とエンティティの対応表を作成することができます。これをエンティティ辞書と呼びます。以下の図は、エンティティ辞書の具体例です。

f:id:pompom168:20190809153155p:plain
https://www.slideshare.net/ikuyamada/pythonwikipedia-120034699 より引用

エンティティ名をキー、エンティティをバリューと考えると、任意の単語をキーとして辞書に問い合わせることでエンティティを獲得可能です。よって、エンティティを正規化後の単語とした単語の正規化が可能です。文書に対して、辞書に含まれるエンティティ名を抜き出してエンティティを抽出するタスクをエンティティリンキングと呼ぶらしいですが、つまりはエンティティリンキングを行って単語の正規化を行っていることになります。

エンティティ辞書には2つの有用な指標が存在します。それはリンク確率とコモンネスです。

リンク確率

各エンティティ名に対して定義される値です。エンティティ名がリンクとしてWikipediaのページに表れる確率を表します。

例えば、Wikipediaの全ページ中にアップルが1000回出現し、うち300回リンクとして出現したらエンティティ名アップルのリンク確率は0.3となります。

コモンネス

エンティティ名とエンティティの組み合わせに対して定義される値です。エンティティ名が特定のエンティティを指し示す確率を表します。

例えば、Wikipediaの全ページ中にアップルが1000回出現し、うち200回アップル(企業)のエンティティを指していたら、エンティティ名アップルとエンティティアップル(企業名)のコモンネスは0.2となります。

wikipedia2vecを用いたエンティティ辞書の構築

wikipedia2vec自体は、skip-gramを拡張してWikipediaのデータから単語埋め込みを学習する方法論とその実装であるPythonのパッケージです。詳細は以前、以下の会社の技術ブログで紹介したのでよろしければご覧ください。

developers.microad.co.jp

パッケージとしてのwikipedia2vec(リポジトリ)に注目すると、実は単語埋め込みだけでなくエンティティ辞書構築の機能も存在します。

 

Wikipediaのダンプデータ

エンティティ辞書の構築のために、Wikipediaのダンプデータを使用します。以下のURL配下に存在します。

Index of /jawiki/latest/

こちらにデータの説明などがありますが、大体月に2回、最低でも月に1回は更新が行われているそうです(不定期)。ただ、少なくとも毎月更新されているので、エンティティ辞書の構築も毎月行うことでエンティティ辞書のメンテが可能です。

エンティティ辞書構築手順

手順は以下のとおりです。(ファイル名などは適宜変更ください)

最後のコマンドに--min-link-probオプションがありますが、これがリンク確率に関するところで、この値を大きくすればあまりリンクが張られていない単語がエンティティ名から除去されます。デフォルトだと0.2ですが、自分の感覚では0.1がちょうどよかったです。これはタスク依存です。

# ダンプデータの取得
$ wget https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2
 
# wikipedia2vecで扱える形に変換(jawiki.dbが出力)
$ wikipedia2vec build-dump-db jawiki-latest-pages-articles.xml.bz2 jawiki.db
 
# 辞書ファイルの作成(--min-link-probオプションでリンク確率のしきい値調整ができる)
$ wikipedia2vec build-dictionary jawiki.db jawiki_dic.pkl
$ wikipedia2vec build-mention-db jawiki.db jawiki_dic.pkl jawiki_mention.pkl --min-link-prob 0.1

使用法と表記ゆれへの対応例

以下のようにして使います。MentionDBインスタンスのqueryメソッドでエンティティを抽出できます。(この辺りは特にドキュメントとかはなかったので、ここらへんから読み解きました。何か間違いがあるかもしれません。)

In [1]: from wikipedia2vec.dictionary import Dictionary  
   ...: from wikipedia2vec.mention_db import MentionDB                                                   

In [2]: dic = Dictionary.load('jawiki_dic.pkl')                                                          

In [3]: db = MentionDB.load('jawiki_mention.pkl', dic)                                                   

In [4]: db.query('toyota')                                                                               
Out[4]: [<Mention toyota -> トヨタ自動車>]

# エンティティを抽出
In [6]: db.query('toyota')[0].entity.title                                                               
Out[6]: 'トヨタ自動車'

リストで返ってくることから分かるとおり、単語によっては複数のエンティティが抽出されます。例えばPCだと以下のように複数のエンティティが抽出されます。

この場合は、コモンネスが最も大きいエンティティ名とエンティティの組み合わせを抽出すれば良いはずです。なぜなら、コモンネスが最も大きいということは、そのエンティティ名に対してはそのエンティティが最も良く指し示されているからです。

In [8]: db.query('PC')                                                                                   
Out[8]: 
[<Mention pc -> 枢密院 (イギリス)>,
 <Mention pc -> パーソナルコンピュータ>,
 <Mention pc -> パソコンゲーム>,
 <Mention pc -> プレイヤーキャラクター>,
 <Mention pc -> プレストレスト・コンクリート>,
 <Mention pc -> Microsoft Windows>,
 <Mention pc -> パーセク>,
 <Mention pc -> PC>,
 <Mention pc -> PC/AT互換機>,
 <Mention pc -> プレストレスト・コンクリート橋>]

# コモンネスが最も大きいのはパーソナルコンピュータ
In [9]: for word_entity in db.query('PC'): 
   ...:     print(f'entity = {word_entity.entity.title}, コモンネス = {word_entity.commonness}') 
   ...:                                                                                                  
entity = 枢密院 (イギリス), コモンネス = 0.036575875486381325
entity = パーソナルコンピュータ, コモンネス = 0.5821011673151751
entity = パソコンゲーム, コモンネス = 0.014785992217898832
entity = プレイヤーキャラクター, コモンネス = 0.07937743190661478
entity = プレストレスト・コンクリート, コモンネス = 0.09961089494163425
entity = Microsoft Windows, コモンネス = 0.017120622568093387
entity = パーセク, コモンネス = 0.025680933852140077
entity = PC, コモンネス = 0.0840466926070039
entity = PC/AT互換機, コモンネス = 0.014007782101167316
entity = プレストレスト・コンクリート橋, コモンネス = 0.024124513618677044

よって、コモンネスが最大のエンティティを抽出する関数を作成しておきます。

In [23]: import numpy as np 
    ...: def extract_entity(db, word): 
    ...:     try: 
    ...:         entities = db.query(word) 
    ...:         if len(entities) == 1: 
    ...:             entity = entities[0].entity.title 
    ...:         else: 
    ...:             max_idx = np.argmax([entity.commonness for entity in entities]) 
    ...:             entity = entities[max_idx].entity.title 
    ...:     except KeyError: 
    ...:             return None 
    ...:     return entity 

以下、何パターンかエンティティ抽出例を示します。

In [20]: extract_entity(db, 'じゃがいも')                                                                
Out[20]: 'ジャガイモ'

In [21]: extract_entity(db, '馬鈴薯')                                                                    
Out[21]: 'ジャガイモ'

# 略語
In [24]: extract_entity(db, 'パワハラ')                                                                  
Out[24]: 'パワーハラスメント'

# ドラマの略語
In [25]: extract_entity(db, '逃げ恥')                                                                    
Out[25]: '逃げるは恥だが役に立つ'

# しょうゆ => 醤油とかは出来ない、Wikipedia中でしょうゆと書かれることが無さそう
In [28]: extract_entity(db, 'しょうゆ')                                                                  

キーワード抽出器として使用する

抽出されたエンティティはWikipediaの各ページのタイトルであるので、文書中のキーワードとして扱うこともできるでしょう。

例えばSHIROBAKOの劇場版に関するページから、以下のような文書を抽出したとします。

劇場版『SHIROBAKO』(2020年春ロードショー予定)の特報映像&場面写真が初公開となった。この中には、主人公・宮森あおいや安原恵麻らおなじみメンバーに加え、新キャラクターの姿も捉えられている。劇場版『SHIROBAKO』場面写真【画像クリックでフォトギャラリーへ】本作は、P.A.WORKSと水島努監督がタッグを組み、2014年に放送されたTVアニメ『SHIROBAKO』の完全新作劇場版。宮森あおい、安原絵麻、坂木しずか、藤堂美沙、今井みどりら、アニメーション業界で働く女性5人を中心に、アニメの完成までを追う物語だ。VIDEO劇場版『SHIROBAKO』は、2020年春公開予定。「コミックマーケット96」では、企業ブース「ムービック(No.1222)」にて、初の前売り券の会場限定販売を実施。

そして分かち書きを行い名詞だけを抽出した結果、以下の単語リストが得られたとします。 (分かち書きは今回の主題では無いので省略します。またアルファベットは大文字から小文字に変換する正規化を行っています。)

words = ['劇場版', 'shirobako', '特報', '場面写真', '公開', '宮森', 'キャラクター', '姿', '劇場版', 'shirobako', 'ロー
ドショー', '予定', '特報映像', '場面写真', '公開', '主人公', '宮森あおい', '安原', '恵麻', 'ら', 'おなじみ', '
メンバー', 'キャラクター', '姿', '劇場版', 'shirobako', '場面写真', '画像', 'クリック', 'フォトギャラリー', '
本作', 'p.a.works', '水島努', '監督', 'タッグ', '放送', 'tvアニメ', 'shirobako', '完全', '新作', '劇場版', '宮
森あおい', '安原絵麻', '坂木しずか', '藤堂美沙', '今井みどり', 'ら', 'アニメーション', '業界', '働く女性', '5
人', '中心', 'アニメ', '完成', '物語', 'video', '劇場版', 'shirobako', '公開予定', 'コミックマーケット', '企業
', 'ブース', 'ムービック', '初', '前売り券', '会場', '限定販売', '実施']

先程のextract_entity関数を使用して、以下のようにentityを抽出します。

In [35]: word_entity = {} 
    ...: for word in words: 
    ...:     entity = extract_entity(db, word) 
    ...:     if entity: 
    ...:         word_entity.update({word:entity}) 
{
    'shirobako': 'SHIROBAKO', 
    'p.a.works': 'ピーエーワークス',
    '水島努': '水島努', 
    'アニメーション': 'アニメーション', 
    'アニメ': 'アニメ', 
    'コミックマーケット': 'コミックマーケット',
    'ムービック': 'ムービック'
}

割りと良い感じだと思います。作品名・制作会社名・監督名が全て抽出できていますね。

ただし、あくまでもWikipediaに存在するページのタイトルしか抽出できないことに注意が必要です。なので、一般的な単語には強いですが、特定のドメインのみで細かい単語の正規化やキーワード抽出はできません。

まとめ

  • Wikipediaを活用した表記ゆれへの対応(単語の正規化)方法を紹介した
    • 特別なマスタ・コーパス・正解データなどが必要無い
    • 機械学習の必要もない
    • 毎月更新することで最新の状態にメンテ可能
    • キーワード抽出器としても利用可能
  • ただしあくまでもWikipediaのページのタイトルとして存在する単語にしか正規化が出来ない

ダウンサンプリングによる予測確率のバイアス

機械学習(二値分類問題を考えます)において不均衡なデータセット(クラス間でサンプルサイズが大きく異なる)を扱う場合、多数派のクラスのサンプルに対してサンプリング行い均衡なデータセットに変換するダウンサンプリングが良く行われます。

この不均衡データのダウンサンプリングによって、サンプル選択バイアスが生じることが Calibrating Probability with Undersampling for Unbalanced Classification という論文で説明されています。

具体的には、少数派クラスの事前確率が大きくなります。一般的な問題設定では、正例のクラスが少数派クラスであるので、正例と予測される確率(事後確率)が大きくなります。

予測確率が重要な場合 *1 は特に、このバイアスの影響を除去しなければなりません。

実際、FacebookのCTR予測に関する論文でも、このバイアスの影響を除去するために予測確率に対するcalibrationを行っていることが記載されています。

バイアスの影響と予測確率のcalibration

知りたいのは、ダウンサンプリングを行ったデータセットで学習されたモデルの予測確率と、元の不均衡なデータセットで学習されたモデルの予測確率の間の関係です。

ここで、クラス0を負例、クラス1を正例とする二値分類を考えます。正例の数 << 負例の数であり、ダウンサンプリングが行われたとします。

 (\mathcal{X}, \mathcal{Y}) を元の不均衡なデータセット、 (X, Y) をダウンサンプリング後の均衡なデータセットとします。つまり (X, Y) \in (\mathcal{X}, \mathcal{Y}) です。元の不均衡なデータセットにおけるサンプル  (x, y) \in (\mathcal{X}, \mathcal{Y}) が、ダウンサンプリング後のデータセット  (X, Y) に含まれていれば 1、そうでなければ 0 を取るバイナリの確率変数  s を導入します。

確率変数  s を用いると、ダウンサンプリングを行ったデータセットで学習されたモデルの予測確率は  p(y = 1 | x, s=1) と表現できます。これを  p_s とおきます。一方、元の不均衡なデータセットで学習されたモデルの予測確率は  p(y = 1 | x) と表現できます。これを  p とおきます。

詳細は省きますが、Calibrating Probability with Undersampling for Unbalanced Classification に記載されているとおり、 p_s p は以下の関係式で表現できます。

 p_s = \frac{p}{p + \beta (1 - p)}

ここで、 \beta = p(s=1 | y = 0) です。これはダウンサンプリング率を表現しています。

式変形を行うことで、以下の式を導くことができます。

 p = \frac{p_s}{p_s + \frac{(1 - p_s)}{\beta}}

つまり、ダウンサンプリングを行ったデータセットで学習されたモデルの予測確率  p_s に対して、ダウンサンプリング率  \beta を用いて上式でcalibrationを行うことで、バイアスの影響を除去できることになります。

実験

実際に不均衡データに対して、バイアスの影響とcalibrationの効果を確認してみます。まずは、scikit-learnのmake_classificationを使用して、正例:負例 = 1:9でサンプル生成します。また、学習データとテストデータに分割します。

from sklearn.model_selection import train_test_split
from sklearn.datasets import make_classification
# 正例:負例 = 1:9でサンプル生成
X, y = make_classification(n_samples=100000,
                           n_features=5,
                           n_classes=2,
                           weights=[0.9, 0.1],
                           random_state=42)
# 学習・テストデータに分割
X_train, X_test, y_train, y_test = train_test_split(X,
                                                    y,
                                                    test_size=0.2,
                                                    random_state=0)

次にimbalanced-learnを用いて、正例:負例 = 1:1になるように学習データの負例に対してダウンサンプリングを行います。

from imblearn.under_sampling import RandomUnderSampler
sampler = RandomUnderSampler(sampling_strategy={0: y_train.sum(), 1: y_train.sum()}, random_state=42)
X_train_sampled, y_train_sampled = sampler.fit_sample(X_train, y_train)

そして、ダウンサンプリングを行ったデータセットを用いてロジスティック回帰の分類器を学習して、テストデータに対して予測を行います。

from sklearn.linear_model import LogisticRegression
model = LogisticRegression(random_state=42, solver='lbfgs')
model.fit(X_train_sampled, y_train_sampled)
y_proba = model.predict_proba(X_test)

ここで、予測確率のヒストグラムを可視化してみます。紫の線はテストデータにおける正例の割合、緑の線はテストデータに対する予測確率の平均を表しています。その2つの値に大きな乖離があることが分かります。

f:id:pompom168:20190721235051p:plain

次に、テストデータの予測確率に対してcalibrationを行います。

def calibration(y_proba, beta):
    return y_proba / (y_proba + (1 - y_proba) / beta)

sampling_rate = y_train.sum() / len(y_train)
y_proba_calib = calibration(y_proba[:, 1], sampling_rate)

calibration後の予測確率に対して、上と同様にヒストグラムを可視化してみます。紫と緑の線がかなり近づいており、現実に即した予測確率になっていると言えそうです。

f:id:pompom168:20190721235103p:plain

最後に定量的な評価として、予測確率の評価をするためにLog Lossを見てみたいと思います。Log Lossに関しては過去の記事を参照ください。

calibration無しと有りで比較すると、以下の結果となりました。calibrationによって、Log Lossをかなり小さくすることが出来ました。

calibration無し calibration有り
0.334 0.183

実験のスクリプトは以下にあります。

github.com

まとめ

  • ダウンサンプリングによって予測確率にはバイアスが生じる
  • calibrationを行うことでバイアスの影響を除去できる

*1:ディスプレイ広告におけるCTR予測など