Amazon Mechanical Turk API (Python, boto3)を使ってお手軽タスク外注

# はじめに

本記事では、Amazon Mechanical Turk (MTurk)を利用するための**Python API (boto3)**の使い方について紹介していきます。
**本物のワーカーに仕事を投げて、回答を確認する**ところまでカバーしています。

MTurkは数あるクラウドソーシングプラットフォームの中でも**小回りがきく＆APIが使える**点が素敵なサービスなのですが、如何せん国内では日本語のドキュメントが少なく盛り上がりに欠けています。
この記事で日本のMTurk利用が少しでも活発になるよう頑張っていきたい所存です。

# 注意

- アカウント登録手順とか、MTurkのウェブサイト上でタスクを作っていく機能については、既に他のブログで紹介されているためここでは触れません（後日追記とか別記事で書くかもしれません）。
    - [blog.makky.io - 日本からAmazon Mechanical Turkで作業を依頼してみた](https://blog.makky.io/articles/2017/06/25/mturk-jp/)
    - [あぱーブログ - ５分でわかる Amazon Mechanical Turk の使い方](https://blog.apar.jp/web/14596/)

- 以降では知らないと分からないMTurkの用語・概念がたくさん出てきますが、記事を簡潔にするために詳細な説明を避けています。これらについては[別記事](https://qiita.com/ssmsaito/items/fa0a0b3b64f38ec55b09)で知識集として纏めていってるので、そちらをご覧ください。

# MTurk APIの何が嬉しいか

APIを使うと、たとえば以下のような操作が**プログラム内部から**行えるようになります。

- HITを作成して投げる
- HITを期限切れ状態にする（or削除する）
- ワーカーの回答一覧を取得する
- 承認待ち状態のワーカーの回答を承認（or棄却）する
- プリペイドチャージ残額を取得する

こんなインターフェースがあることで、例えばラズパイがカメラで家の猫の写真を定期的に撮り、部屋のどこにいるのか・起きてるか寝てるかをワーカーが文章で説明してくれた結果をLINEで通知する...みたいな、**Human-In-The-Loop**なIoTシステムだって作れちゃうわけです。夢広がりません？

【余談】
学生時代は研究室内のコーヒーメーカーをIoT化して、豆を挽くたびにバックグラウンドでMTurkワーカーが顔照合タスクを解いた結果でユーザー認識＆自動課金...みたいなシステムを組んで遊んでました~~（これも機会があったら記事化したい）~~。**記事化したのでご覧ください！↓**

[研究室内コーヒーメーカーの課金処理を【画像認識＋クラウドソーシング】で全自動化した話](https://qiita.com/ssmsaito/items/27683ebb40b57b4be8aa)

# APIを触ってみる

まずMTurk Sandbox（テスト環境）で**プリペイド残高**でも見てみましょう。
詳細な公式APIは[こちら](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/mturk.html)にあります。

環境： macOS Catalina 10.15.5 / Python 3.8.0

#### boto3インストール
```shell
$ pip install boto3
```

#### 残高確認
```python:boto3-test.py
import boto3

def get_client():
    return boto3.client("mturk",
                        aws_access_key_id = "XXXXXXXXXXXXXXX",         # 自分のアカウントのAccessKeyIdを入れる
                        aws_secret_access_key = "xxxxxxxxxxxxxxxxx",   # 自分のアカウントのSecretAccessKeyを入れる
                        region_name = "us-east-1",
                        endpoint_url = "https://mturk-requester-sandbox.us-east-1.amazonaws.com"   # Sandbox(テスト環境)のエンドポイント
    )

if __name__=="__main__":
    client = get_client()
    print(client.get_account_balance())
```

出力例は以下の通り：

```python:output
{'AvailableBalance': '10000.00',
 'ResponseMetadata': {'HTTPHeaders': {'content-length': '31',
                                      'content-type': 'application/x-amz-json-1.1',
                                      'date': 'Mon, 25 Jan 2021 09:27:49 GMT',
                                      'x-amzn-requestid': 'b8e37525-b1af-450e-8432-1309891a3dd8'},
                      'HTTPStatusCode': 200,
                      'RequestId': 'b8e37525-b1af-450e-8432-1309891a3dd8',
                      'RetryAttempts': 0}
}
```

`AvailableBalance`として$10000.00が返ってきています（実際に1万ドルチャージしているわけではなく、Sandboxでは実際のお金を扱わないため、常にこの値が返ってくる仕様になっています）。

# HITを作って自分でやってみる

リクエスタとしてHITを作って、ワーカーとしてそのHITに回答してみましょう。

以降、クライアント初期化部分（`def get_client(): ...`）は割愛してメイン処理部のみ書きます。

#### HIT生成

まずHITのテンプレートファイルを以下のように作成します。
今回はテストのために、0~100の適当な数字を入力してもらうタスクを作ってみます。

```xml:my_hit.xml
<HTMLQuestion xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2011-11-11/HTMLQuestion.xsd">
    <HTMLContent><![CDATA[

    <!DOCTYPE html>
    <html>
        <head>
            <meta http-equiv='Content-Type' content='text/html; charset=UTF-8'/>
            <script type='text/javascript' src='https://s3.amazonaws.com/mturk-public/externalHIT_v1.js'></script>
        </head>
        <body>
            <center>
                <form name='mturk_form' method='post' id='mturk_form' action='https://www.mturk.com/mturk/externalSubmit'>
                    <h2>Type a random number between 0 and 100.</h2>
                    <p><input type='number' name='rand' step=1 min=0 max=100 /></p>
                    <p><input type='submit' id='submitButton' value='Submit' /></p>
                    <input type='hidden' value='' name='assignmentId' id='assignmentId'/>
                </form>
            </center>
            <script language='Javascript'>turkSetAssignmentID();</script>
        </body>
    </html>

    ]]>
    </HTMLContent>
    <FrameHeight>800</FrameHeight>
</HTMLQuestion>
```

`HTMLQuestion`やら`HTMLContent`というのはMTurk独自のXMLスキームなので深く考えなくていいです。
タスクのHTMLを`<![CDATA[ ... ]]>`の中に記述し、`<FrameHeight>`にはプラットフォームUI上で読み込まれるHITのIFrameの縦幅を指定します（何故これを指定させるのか未だに良く分かっていない...）。

※ちなみに、`<form>`の`id='mturk_form'`とか、`<input type='submit'>`の`id='submitButton'`とか`turkSetAssignmentID();`は、

```xml
<script type='text/javascript' src='https://s3.amazonaws.com/mturk-public/externalHIT_v1.js'></script>
```

の行で読み込んでいる公式のヘルパースクリプトの便宜上用いているものです。中身を見てもらえれば分かるのですが、HITがプレビュー状態の時にボタンを押せなくしたり、formの送信先をMTurkの仕様に合わせたりするための簡単な処理が行われています。同等の処理を他の場所でやっていれば、必ずしも読み込む必要はないです。

...ともあれ、以下でHITを作ってプラットフォームに投げます。

```python:boto3-test2.py
if __name__=="__main__":
    client = get_client()
    with open("my_hit.xml") as f:
        res = client.create_hit(
            Title="Input Random Number",
            Description="Please just type a single random number between 0 and 100.",
            Keywords="this,is,my,HIT,hoge",    # コンマ区切りで検索キーワードを指定
            Reward="0.05",
            MaxAssignments=3,                  # 受け付ける回答数（＝ワーカー数）上限
            LifetimeInSeconds=3600,            # HITの有効期限を3600秒（1時間）後に設定
            AssignmentDurationInSeconds=300,   # HITの制限時間を300秒（5分）に設定
            Question=f.read()                  # my_hit.htmlの中身を文字列でそのまま渡す
        )
    print("HIT ID:", res["HIT"]["HITId"])
    print("Status Code:", res["ResponseMetadata"]["HTTPStatusCode"])
```

```:output
HIT ID: 338431Z1GR7T5421FFM58DLLRHTORF
Status Code: 200
```
こんな出力が返ってきたら成功です。このHIT IDは後で使うので取っておきましょう。

#### HITに回答する

[ワーカーとしてログイン](https://worker.mturk.com)して、先程作ったHITを探します（リクエスタアカウントとは別に登録が必要）。HITを作った時にあらかじめ指定した`Keywords`の文字列やリクエスタ名で検索すると探しやすいです。
※HIT一覧画面への反映が2-3分程度遅れることがあります。適宜ブラウザをリロードしてあげれば出てくるはず。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1052741/ccc13655-7cc8-7bf7-a1a6-67a4fd171cdd.png)

HITを"Preview"または"Accept & Work"で選ぶとHITが開始します。

![Screenshot_2021-01-25 19.13.38_P9lwRs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1052741/50ca05fd-f4d4-f3ef-e69d-81b991c3a359.png)


こんな見た目になっているはずです。適当に数字を入力してSubmitボタンを押しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1052741/406229ac-c9a3-da26-2c24-23d350b39130.png)

HIT一覧画面に戻り、↑のような表示が出たらOKです。

#### 回答を確認する

下記のようにして、あるHITに対して割り当てられたワーカーのセッション情報を取ってきます。

```python:boto3-test3.py
import xml.etree.ElementTree as ET

...

if __name__=="__main__":
    client = get_client()
    res = client.list_assignments_for_hit(
        HITId="338431Z1GR7T5421FFM58DLLRHTORF"    # さっき控えたHIT ID
    )
    print(res)

    for assignment in res["Assignments"]:
        print("===")
        print("WorkerId:", assignment["WorkerId"])
        for answer in ET.fromstring(assignment["Answer"]):
            print(answer[0].text, "=", answer[1].text)
```



```python:output（一部割愛）
{'Assignments': [{'AcceptTime': datetime.datetime(2021, 1, 26, 10, 30, 31, tzinfo=tzlocal()),
                  'Answer': '<?xml version="1.0" '
                            'encoding="ASCII"?><QuestionFormAnswers '
                            'xmlns="http://mechanicalturk.amazonaws.com/AWSMechanicalTurkDataSchemas/2005-10-01/QuestionFormAnswers.xsd"><Answer><QuestionIdentifier>rand</QuestionIdentifier><FreeText>28</FreeText></Answer></QuestionFormAnswers>',
                  'AssignmentId': '369J354OFK2FESLDPAB0GTH33KLG67',
                  'AssignmentStatus': 'Submitted',
                  'AutoApprovalTime': datetime.datetime(2021, 2, 25, 10, 30, 56, tzinfo=tzlocal()),
                  'HITId': '34O39PNDLC09MADSBAHJ2TLMKISRBW',
                  'SubmitTime': datetime.datetime(2021, 1, 26, 10, 30, 56, tzinfo=tzlocal()),
                  'WorkerId': 'XXXXXXXXXXXX'}],
 'NextToken': 'p1:sE0btqYVe9mzyFbtrp69MPSNb9tJwattsbsqeMUeMlUFgJ30zg4w/kuMtJED8g==',
 'NumResults': 1,
 ...}
===
WorkerId: XXXXXXXXXXX  # 自分のワーカーID
rand = 28
```

`Assignments`には回答数分の大きさのリストが返ってきて、各要素の`Answer`キーにワーカーの入力内容が入っています。
ここもXMLだと...？というツッコミを入れたくなりますが、大人しくパースしてあげればキーと値が取ってこれます。これらは、先程作ったHITのHTML内で

```html:my_hit.xml（抜粋）
<form name='mturk_form' method='post' id='mturk_form' action='https://www.mturk.com/mturk/externalSubmit'>
    ...
    <input type='number' name='rand' step=1 min=0 max=100 />
    ....
</form>
```

と記述した部分に対応していて、`<QuestionIdentifier>`に`input#name`属性が、`<FreeText>`に入力値（`input#value`属性）が、それぞれマッピングされたものです。

#### 回答(Assignment)を承認する

このHITではリスクは低いですが、中には変な回答をするワーカーも存在するので、リクエスタには回答を受け付けてからの一定時間は回答を承認する（＝報酬を支払う）か棄却する（＝支払わない）かを決める猶予が与えられます。
とりあえず今回は、先程取得した`AssignmentId`を使って当該回答を承認してみます。

```python:boto3-test4.py
if __name__=="__main__":
    client = get_client()
    res = client.approve_assignment(
        AssignmentId="369J354OFK2FESLDPAB0GTH33KLG67"
    )
    print(res["ResponseMetadata"]["HTTPStatusCode"])
```

```:output
200
```

失敗した場合は例外が返りますが、成功した場合はメタデータしか返ってきません。ステータスコードが200ならOK。
※ちなみに、この処理をしなくても`AutoApprovalTime`を過ぎた時点で自動的に承認処理が行われます。

#### HITを期限切れ状態にする

ここで一度HITの情報を見てみると、`MaxAssignments`を3に設定したので、まだあと2人分の回答権が残っている状態であることが分かります。

```python:boto3-test5.py
if __name__=="__main__":
    client = get_client()
    res = client.get_hit(HITId="338431Z1GR7T5421FFM58DLLRHTORF")
    print(res)
```

```python:output（抜粋）
{'HIT': {'AssignmentDurationInSeconds': 300,
         ...
         'Expiration': datetime.datetime(2021, 1, 26, 11, 27, 34, tzinfo=tzlocal()),    # 有効期限
         'HITStatus': 'Assignable',    # 「まだ割り当てる回答権が残っている」ステータス
         'MaxAssignments': 3,                # 回答権の数
         'NumberOfAssignmentsAvailable': 2,  # 残っている回答権の数
         'NumberOfAssignmentsCompleted': 1,  # 割り当て済みの回答権の数
         ...
```

そこで、HITを有効期限切れの状態にすることで、これ以上ワーカーがHITを見つけられないようにします。

```python:boto3-test6.py
from datetime import datetime

...

if __name__=="__main__":
    client = get_client()
    res = client.update_expiration_for_hit(
        HITId="338431Z1GR7T5421FFM58DLLRHTORF",
        ExpireAt=datetime(1,1,1)         # 現在より前の時間
    )
    print(res["ResponseMetadata"]["HTTPStatusCode"])
```

```:output
200
```

200が返ってきたら成功。改めて`get_hit()`すると、

```python:output（抜粋）
{'HIT': {'AssignmentDurationInSeconds': 300,
         ...
         'Expiration': datetime.datetime(2021, 1, 26, 10, 42, 37, tzinfo=tzlocal()),   # update_expiration_for_hitを実行した時間が入る
         'HITStatus': 'Reviewable',
         ...
```

と表示が変わります。これで、今まさにHITに取り組んでいるワーカーを除き、他のワーカーはこれ以上このHITを見つけられなくなりました。

# 実際のワーカーにHITを投げてみる

ここまで出来たら、もうワーカーに回答してもらうテストが一通り出来たことになります。本物のワーカーにHITを依頼してみましょう。
<font color="red">注意：上記で回答が取得出来ていない場合、HITに不具合がある場合があります。そのままHITを投げてしまうとうまく報酬が支払われず、ワーカーに無駄な時間を費やさせてしまうことになりますので、エラーは必ず解消してから本番環境に投げましょう。</font>

APIの実行方法は今までと全く同じで、クライアント初期化のところを一箇所だけ変更すると本番モードになります。

```python:get_client()
def get_client():
    return boto3.client("mturk",
                        aws_access_key_id = "XXXXXXXXXXXXXXX",           # Sandboxと同じ
                        aws_secret_access_key = "xxxxxxxxxxxxxxxxx",     # Sandboxと同じ
                        region_name = "us-east-1",
                        # "mturk-requester-sandbox"から"-sandbox"の文言が抜けていることに注目！
                        endpoint_url = "https://mturk-requester.us-east-1.amazonaws.com"
    )
```

上記と同様に`create_hit`していきますが、今回は9人から回答を募ってみたいので`MaxAssignment`を9に設定します。\$0.05 * 9 = \$0.45 ≒ 50円です。お手軽ですね。

<font color="darkgrey">ところで、私の博論研究の時にも数千人のワーカーに仕事を依頼したりしてたんですが、いつになっても慣れないし緊張するんですよね。。HITの設計がマズくてワーカーから沢山メールが届いたりすると、SNS炎上したときと似たような気持ちになります。したことないですけど。</font>

```
Worker 1, SubmitTime: 2021-01-26 14:16:28
rand = 50
===
Worker 2, SubmitTime: 2021-01-26 14:16:39
rand = 20
===
Worker 3, SubmitTime: 2021-01-26 14:16:56
rand = 20
===
Worker 4, SubmitTime: 2021-01-26 14:17:05
rand = 20
===
Worker 5, SubmitTime: 2021-01-26 14:17:10
rand = 80
===
Worker 6, SubmitTime: 2021-01-26 14:18:01
rand = 9
===
Worker 7, SubmitTime: 2021-01-26 14:22:19
rand = 87
===
Worker 8, SubmitTime: 2021-01-26 14:22:26
rand = 87
===
Worker 9, SubmitTime: 2021-01-26 14:22:44
rand = 98
```

↑ともあれ、今回は無事に集まったようです。HITを作成したのが**2021-01-26 14:16:02**だったので、最初の1人は開始26秒、最初の6人は開始2分以内、9人全員は6分半以内で集まったということになります。
0とか100とか多いかと思ったんですが、現時点では意外とそうでもないみたいです。ちゃんと統計とったらどんな分布になるのか気になる。

**【考察・余談】**

あまりこの結果を考察するのは本記事のメインではないのですが少しだけ。

1. HITをクローリングしているワーカーがいる
    - 効率的にお金を稼ぐのが上手い多くのワーカーはクローラーツールを使い、**新しいHITを見つけては回答権を得る操作を自動化し、既に確保したHITの中からやりたいものを優先的にやっていく**というような作戦を取っています。こうしたワーカーは一定数いるので、新しく作られたHITではたいてい**一瞬のうちに全ての回答権が埋まります**。ただし制限時間（`AssignmentDurationInSeconds`）を超えると自動的に回答権は破棄され、他のワーカーに向けて再び空きができます。今回のHITでは制限時間を5分に設定しており、Worker 1~6に比べて7~9の回答に遅れが生じているのはこの影響と考えられます。
    - 参考（拙著）：[Kaplan and Saito, et al., "Striving to Earn More: A Survey of Work Strategies and Tool Use Among Crowd Workers" (2018)](http://susumus.com/files/pubs/hcomp2018_paper.pdf)
2. アカウントを複数持つワーカーがいる可能性
    - Worker2~4やWorker7~8は連続して同じ回答内容となっています。0~100のいずれかの値を指定したにも関わらず、こんなに同じ値が続くのはあまり考えにくいですね。**同一ワーカーが複数のワーカーアカウントを持ちうる**ことも知られており、かつそれぞれのアカウントで1.のような手段をとるワーカーがいることも考えられます。これを証明するのは非常に難しいのですが、ある程度収集されるデータにこうしたリスクを想定し、適切な対策を取っていくことが重要になります。
    - [公式ルール](https://blog.mturk.com/mturk-worker-tips-common-account-issues-7fbb7e350464)では、「他のアカウントが見つかった時点でメイン利用分以外を停止する」と言っている一方、明示的に「禁止」とは書かれていないようです。

# おわりに

色々端折って書いていたのに長くなってしまいました。boto3を使うこと自体はそんなに難しくないはずなので、これを見て少しでもMTurkに興味持ってくれる人が増えるといいなと思ってます。


# 関連記事（*TODO）

- 適切な報酬額の設定方法: [【リクエスター必読】MTurkの報酬額基準は$7.25/h〜](https://qiita.com/ssmsaito/items/3a28dbce3cc55ff4e283)

以下のような記事も近日中に書いていく予定です。

- ~~[ワーカーを事前にフィルタリングする方法]()~~
- ~~[品質を担保する方法]()~~