Ahogrammer

Deep Dive Into NLP, ML and Cloud

Lambdaレイヤーを公開するためのシェルスクリプト

最近、AWS Lambdaをよく使っているが、その機能の一つとしてLambdaレイヤーがある。レイヤーにパッケージを取り込むことで、複数のLambda関数から使用可能になる。ビジネスロジックを共通化する、ビルドの手間を省く等の恩恵があるが、私的にはデプロイするファイルのサイズを小さくするのに重宝している。Lambdaの制限は結構きつくて、デプロイするファイルのサイズが大きくなり、デプロイに失敗するということが起きる。

Lambdaレイヤーにパッケージを取り込むには、パッケージをzip化してアップロードすればいいのだが、それがまた面倒くさい。pipディレクトリを指定してパッケージをインストールし、zipコマンドで圧縮した後S3にアップロードし、Lambdaコンソールから取り込む。また、単にどこでもいいからパッケージをインストールすればいいというわけでもなく、Lambdaの実行環境に合わせた環境でビルドしてやらないと実行に失敗する。手動でやるのは実に面倒くさい。

そういうわけで、一連の作業を一つのシェルスクリプトにしてみた。以下のスクリプトでは、Docker Lambdaを使ってパッケージをインストールし、それをzip化した後、Lambdaレイヤーにアップロードしている。不要なパッケージを除去してパッケージのサイズを小さくするような機能はないが、面倒くさいことをせずに、とにかくパッケージをアップロードしたいときに使える。

gist.github.com

以下のようにオプションを指定して実行するようになっている。nにはパッケージ名、rにはリージョン名、pにはPythonのランタイムを指定する。

sh publish_lambda_layer.sh -n spacy -r us-east-1 -p python3.7

質問応答におけるパッセージ検索 BERT vs BM25

最近、文章を書く機会が少なくなっているので、リハビリがてら以下の論文を紹介しよう。

この論文は、BERTによるパッセージ検索がBM25と比べて、どのような状況で強いのか分析している。パッセージ検索とは、検索クエリに関連するパッセージを検索するタスクである。情報検索システムで重要な技術だが、この論文の文脈では質問応答システムを想定している。つまり、質問文をクエリとした時に、対応する回答が含まれるパッセージが検索結果の上位に来ると嬉しい。このようなパッセージさえ得られれば、あとは機械読解の技術を使って回答を抽出すればよい。

f:id:Hironsan:20200627194858p:plain
情報検索ベースの質問応答システムのアーキテクチャ(Speech and Language Processing 25章より引用)

具体的には、以下の4つの仮説を検証している。

  • 仮説1: BM25はBERTと比べて高頻度のクエリ語にバイアスがかかった結果になる
  • 仮説2: 高頻度語がBM25の性能に悪影響を及ぼしている
  • 仮説3: BERTはより新語に強い
  • 仮説4: BERTはBM25と比べて長いクエリに強い

仮説1では、BM25の検索結果は、クエリに出現する語が繰り返し現れる文書なのではないかという仮説を検証している。その検証のために、FQTと呼ばれる指標を計算している。定義については論文を参照してもらいたいが、要するに検索した文書中にクエリ語が現れた割合を計算している。たとえば、10単語の文書でクエリ語が4回現れたらFQT=4/10=0.4になる。結果として、BM25の方がFQTが高かったので、よりクエリ語の頻度に影響を受けた文書が得られることがわかった。

仮説2では、高頻度語がBM25の性能に悪影響を及ぼしているか検証するために、FQTの値をいくつかの区間に分けたときの検索性能について調査している。その結果、FQTの値が大きいほど性能が低下していることがわかった。つまり、平均的には、クエリ語が含まれる割合が高い文書には回答が含まれる可能性が低いということになる。この傾向は、BERTとBM25の両方の場合で見られたが、BM25の方がより顕著であった。

f:id:Hironsan:20200627200234p:plain
FQTと検索性能の関係

仮説3では、BERTは新語が含まれる文書の検索に強いのかを検証している。その検証のために、FNTと呼ばれる指標を計算している。これは要するに、文書中の語のうちクエリに現れない割合を計算している。たとえば、10単語の文書で、そのうちクエリに現れない語が8単語であればFNT=8/10=0.8となる。要するに、クエリに含まれない単語を多く含む文書を検索できるのかを測っている。結果として、BERTの方がBM25よりFNTが高いという結果になった。

仮説4では、BERTはBM25と比べて長いクエリに強いか調査している。そのために、クエリ長ごとの性能を検証している。どのクエリ長でもBERTの方が性能が良かったが、クエリ長が長くなるほど、双方のモデルで性能が低下するという結果になった。しかも、BERTの方がより大きく性能が低下していたので、この仮説は正しくなかった。

f:id:Hironsan:20200627202204p:plain
クエリ長と検索性能の関係

AWS LambdaにGiNZAを載せて、固有表現認識APIを作成する

一週間ほど前、AWS LambdaにElastic File System(EFS)をマウントできる機能が追加されました。この機能を使うことで、マウントしたEFS上への読み書きがLambda関数からできるようになりました。これまではLambdaの制限により、/tmpで使用可能な容量が512MBなので、大きなファイルの読み込みは難しかったのですが、EFSを使うことでそれが可能になります。特に機械学習系のパッケージやモデルの容量は何かと大きいので、新機能の恩恵に与ることになります。

そういうわけで、本記事ではEFSに日本語の自然言語処理ライブラリであるGiNZAを置いて、それをLambdaから呼び出してみようと思います。実のところ、GiNZAのパッケージは400MB程度なので、/tmpに載せることもできるはずです。その場合は、Lambda LayersとLambdaを組み合わせて、S3上に置いたモデルを/tmpに読み込んでくることになると思います。実際、そのような方法をspaCyをLambdaに載せるときに採用したことがありますが、今回はEFSを使ってやってみます。

❯ curl -X POST \
       -d '{"text":"太郎は東京都出身だ"}' \
       https://example.execute-api.us-east-1.amazonaws.com/v1/analyze-entities | jq
[
  {
    "text": "太郎",
    "label": "Person",
    "start": 0,
    "end": 2
  },
  {
    "text": "東京都",
    "label": "Province",
    "start": 3,
    "end": 6
  }
]

手順は以下の通りです。

  • EFSの作成
  • GiNZAをEFSへコピー
  • Lambda関数の作成
  • API Gatewayの作成

EFSの作成

まずはEFSの作成から行います。Lambda関数からEFSにアクセスするには、Lambda関数がEFSに到達できるようにVPCを設定する必要があります。ここでは、各AWSリージョンで自動的に作成されるデフォルトのVPC を使用することにします。

EFSコンソールで、「ファイルシステムの作成」を選択し、default のVPCとそのサブネットが選択されていることを確認します。またすべてのサブネットで、VPC内の他のリソースへのネットワークアクセスを許可するセキュリティグループを使用します。ここでは簡単のために、すべてのトラフィックを許可するセキュリティグループを設定しています。

f:id:Hironsan:20200625083716p:plain

次に、EFSにNameタグを付け、「次のステップ」へ進みます。

次に、「アクセスポイントを追加」を選択します。User IDとGroup IDに1001を使用し、/packagesパスへのアクセスを制限します。

f:id:Hironsan:20200625085342p:plain

これでEFSの作成は完了です。次のステップへ進みましょう。

GiNZAをEFSへコピー

Amazon Linux 2上にEFSをマウントするディレクトリを作成し、作成したEFSをマウントします。EFSをマウントするために、amazon-efs-utilsをインストールします。

sudo yum install -y amazon-efs-utils
mkdir efs
sudo mount -t efs fs-[YOUR EFS ID]:/ efs

EFSをマウントできたら、GiNZAをインストールします。

mkdir ginza
pip install -t ./ginza/ ginza

インストールが完了したら、パッケージをEFSへ移動します。

sudo chown -R 1001:1001 ginza
sudo mv ginza efs/packages/

これで、LambdaからGiNZAを使う準備ができました。

Lambda関数の作成

次に、固有表現認識を行うためのLambda関数を作成します。Lambdaコンソールで、AnalyzeNamedEntity関数を作成し、ランタイムとしてPython 3.7を選択します。アクセス権限には、 AWSLambdaVPCAccessExecutionRoleおよびAmazonElasticFileSystemClientReadWriteAccess を持ったロールを指定します。

関数を作成したら、VPCの設定を行います。ここでは、EFSに設定したものと同じデフォルトVPCとセキュリティグループを指定します。 f:id:Hironsan:20200625091933p:plain

次に、新しく追加された「ファイルシステム」セクションで「ファイルシステムの追加」を選択します。EFSファイルシステムとアクセスポイントには前に作成したものを選択します。ローカルマウントパスには、/mnt/packagesを設定します。これは、アクセスポイントがマウントされるパスであり、EFSの/packagesに対応しています。

f:id:Hironsan:20200625092529p:plain

あとは、Lambdaのコードエディタに、以下のコードを貼り付けて保存します。

import sys
sys.path.append('/mnt/packages/ginza')
import spacy


def lambda_handler(event, context):
    nlp = spacy.load('ja_ginza')
    doc = nlp(event['text'])
    response = [
        {
            'text': ent.text,
            'label': ent.label_,
            'start': ent.start_char,
            'end': ent.end_char
        }
        for ent in doc.ents
    ]
    return response

試しに、{"text": "太郎は東京都出身だ"}というデータでテストをしてみると、以下のレスポンスが返ってきます。

[
  {
    "text": "太郎",
    "label": "Person",
    "start": 0,
    "end": 2
  },
  {
    "text": "東京都",
    "label": "Province",
    "start": 3,
    "end": 6
  }
]

ちなみに、メモリの割当を大きくしておかないと以下のエラーが発生します。メモリの大きさとタイムアウトについては適切な値を設定しておきましょう。

{
  "errorType": "Runtime.ExitError",
  "errorMessage": "RequestId: b93ab443-c91b-4bb3-a0cb-bab486335751 Error: Runtime exited with error: signal: killed"
}

API Gatewayの作成

ここまできたら後は簡単です。API Gatewayでリソースとメソッドを作成し、AnalyzeNamedEntity関数に結びつけるだけです。ここではリソースとしてanalyze-entities、メソッドとしてPOST、デプロイのステージとしてv1を指定します。デプロイが完了すると、以下のようにしてAPIを叩くことが出来ます。

❯ curl -X POST \
       -d '{"text":"太郎は東京都出身だ"}' \
       https://example.execute-api.us-east-1.amazonaws.com/v1/analyze-entities | jq
[
  {
    "text": "太郎",
    "label": "Person",
    "start": 0,
    "end": 2
  },
  {
    "text": "東京都",
    "label": "Province",
    "start": 3,
    "end": 6
  }
]

以上です。今回はパッケージをまるごとEFSから読み込みましたが、パッケージはLambda Layersに置いておいて、モデルだけEFSから読み込むといったやり方もありそうです。

参考資料