MOB-LOG

モブおじの記録 (Programming, 統計・機械学習)

Optunaを用いたコーヒーのハンドドリップ最適化をSlackBotで (Human in the Coffee Loop)

TL; DL

Optunaを通じたハンドドリップ・コーヒー抽出のパラメータ最適化(Human in the Cofee Loop)をスマホから実行したかったので(コーヒー淹れるたびPCでスクリプトを実行するのがくそ面倒)、Slack botにリクエストを投げて実行出来るようにした。

以下の記事の続きである。moblog.hatenablog.jp

環境構築の詳細については別記事で詳細を示している*1

やりたいこと

メッセージをSlackbotのいるChannelに投げると(DMでもいい)、Bot君が今までのレシピと評価を考慮して(OptunaにTell)、よさげなレシピを返してくれる (OptunaにAsk)。

SlackBotの応答イメージ

システム概要・要素技術

Human in the Coffee Loop (SlackBot)

システム構成要素

  • サーバー:ボットが動作する
  • Slack:ボットとのインターフェース、ボットが動作するPlatform
  • Spreadsheet:データベース (レシピとスコアを保管する)
  • ヒト:Optuna君に従いコーヒーを作り続ける。レシピに忠実にコーヒーをドリップして評価する。

要素技術 (package等)

  • Python: ローカルサーバ上でBotとして動作
    • optuna: レシピを探索する
    • slackbot (slack_sdk, slackeventsapp): ボット君
    • gspread: Google Spreadsheetにアクセス
    • venv, dotenv: 仮想環境管理
  • ngrok: Botが動作するLocalhostのFlaskサーバをトネリングして公開する (firewallを突破するためのtounellingにも都合がいい)

Slack Botの実装 (Coding)

動作は単純で、(botがjoinしているチャンネルで)何かしらメッセージを受け取ったらとき、返事としてレシピを返す。

処理は一通り以下の通り。

  • dotenvで slackbotの認証情報、bot自身のワークスペース内のIDを取得 ( SLACK_SIGNING_SECRETSLACK_BOT_OAUTH_TOKEN.env に記しておこう)
  • flask server準備 (app)
  • SlackEventAdapter (slack_event_adapter) とappを紐づけ
  • イベントと処理の設定 (@slack_event_adapter.on('message')respond_message)
    • ここでAsk-and-Tellを行いコーヒーレシピを生成(recipe = hitcl.tell_and_ask_new_recipe())、同チャンネルにレシピを投稿する(client.chat_postMessage)
  • mainでappを起動
import flask
import slack
from slackeventsapi import SlackEventAdapter
import dotenv
import os
import threading

import hitcl

import logging
logging.basicConfig(level=logging.DEBUG)

app = flask.Flask(__name__)

# load .env
dotenv.load_dotenv(verbose=True)
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
dotenv.load_dotenv(dotenv_path)
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET")
SLACK_BOT_OAUTH_TOKEN   = os.environ.get("SLACK_BOT_OAUTH_TOKEN")

client = slack.WebClient(token=SLACK_BOT_OAUTH_TOKEN)
BOT_USER_ID = client.api_call("auth.test")['user_id'] # the bot's user ID

# slack event adapter
slack_event_adapter = SlackEventAdapter(
    SLACK_SIGNING_SECRET, 
    '/slack/events', # endpoint
    app)

@slack_event_adapter.on('message')
def respond_message(payload):
    event = payload.get('event', {})
    channel_id  = event.get('channel')
    user_id     = event.get('user')
    text        = event.get('text')
    text_success = 'Processing... wait a moment'

    if BOT_USER_ID != user_id: # from other than the bot
        client.chat_postMessage(channel=channel_id, text=text_success)
        th = threading.Thread(
            target=_respond_new_recipe,
            args=(event,),
        )

        th.start()

    return text_success

def _respond_new_recipe(event):
    channel_id  = event.get('channel')
    recipe = hitcl.tell_and_ask_new_recipe()
    response = recipe
    res = client.chat_postMessage(channel=channel_id, text=response)

        
if __name__ == "__main__":
    app.run(debug=True)

if BOT_USER_ID != user_id:Bot自身のIDと受け取った投稿の投稿者IDとを比較して、他のユーザからの投稿のみに反応するように処理する。これをしなければ自分の投稿にも反応してしまう (←SWのバトルドロイドみたいでかわいい)。

レシピの生成と投稿部分をサブスレッドで実行しているのは、Slackサーバへリクエストを受け取った旨をすぐに応答するためである。

  • イベントに対して3秒以内にHTTP 2xxの反応をしなければ(登録した関数でreturnする)、Slackサーバから合計3回ほど繰り返してリクエストが発行されてしまうため、イベントへの処理が3秒で処理が終わらない場合、ユーザからのたった1つのリクエストに対して最大で3回分の応答をしてしまうことになる(レシピが3つ発行される)。
  • そのため時間のかかるレシピの発行は(行数にもよるがSpreadsheetをfindで全検索するので現在だと10秒かかる)サブプロセスに任せ、メインのイベント処理自体をすぐに終了させることでHTTP 200をSlackサーバへ返す。

Bot起動+リクエスト待ち

サーバ上で以下のように実行(本番の運用時はnohup, バックグラウンドで実行する)。

(venv) $ python ./Human_in_the_Coffee_Loop_SlackApp.py

DEBUG:slack_sdk.web.legacy_base_client:Sending a request - url: https://www.slack.com/api/auth.test, query_params: {}, body_params: {}, files: {}, json_body: None, headers: {'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': '(redacted)', 'User-Agent': 'Python/3.10.11 slackclient/3.21.3 Windows/10'}
 * Serving Flask app 'Human_in_the_Coffee_Loop_SlackApp'
 * Debug mode: on
INFO:werkzeug:WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 *** Running on http://127.0.0.1:5000**
INFO:werkzeug:Press CTRL+C to quit
INFO:werkzeug: * Restarting with stat
INFO:werkzeug: * Debugger PIN: XXX-XXX-XXX

このようにLocalhostの5000番ポートで実行される。

ngrokを起動していなかったら、いまngrokにこのポートでポートフォワード待機してもらい、URLを SlackAppのEvents request URL (endpoint) に登録すること。

$ ngrok http 5000 &

運用した結果(感想)

未だ 21 Trials, n=1 (自分), 豆=Paulig ”NewYork”のみの運用実績。前回の記事の通り、1—7の7段階評価でスコアリングした。

今回テストで使用した 「Cafe NEW YORK, Paulig」。選抜理由はそこまで高価でないのと(テスト段階で高級な豆は使いたくなかったし、実質ハズレの方が多かったので正解だった)、現地で人気なのか製造日が3か月以内のものが安定してに見つかるため(意外と6か月とか1年過ぎた製品が詰まれていたりして話にならない。また試行中に何度か買いなおすため安定性は重要)。

スコアの遷移

自身で試した今までの試行 21-trials までのスコアの遷移は以下のようになった。

Trial中のスコアの遷移(n=1, 21-trials)

簡単にまとめると、 - 初めの10回はRandomSearchなので泥水が生まれていた - 今のところスコア 5—6をふらふら (旨いがbestではない) - 最高に旨いものはまだ出てきていない(score<7) といった感じ。

パラメータの重要度

パラメータの重要度 (n=1, 21-trials)

  • 1投目 (蒸らし) と2投目のタイミング・速度・量が重要
  • お湯の温度、挽目の細かさも重要だが、そこまでじゃない
  • リンスやスピニングとかは全然重要じゃない

という結果が出ているが、挽目の細かさは非常に重要のはず、、、(TIMEMOREのC3 12クリックでその豆で最高のやつが出た)。

暫定最高のレシピ

今までで一番おいしいやつはこいつ。酸味が強いがうま味もあって、えぐみが全くないものができる(2回目を淹れて確認した)。

1. コーヒー豆 28.8 g を 12-click で挽く.
2. 沸騰した湯を、 3 回ポッドに入れ替え冷ます.
3. ドリッパーに紙を置き、沸騰した湯をかけてリンス=False.
4. タイマーをスタート、ドリップ開始:
- 00:00-00:59 1投目 (蒸らし): 94 g まで
    - 湯 94 g を 16 sec 程で注ぐ,
    - スピニング = False,
    - 蒸らしのために 59 sec 待つ.
- 00:59-01:26 2投目: 209 g まで
    - 115 g を 27 sec 程で注ぐ.
- 01:50-02:15 3投目: 379 g まで
    - 170 g を 25 sec ほどで注ぐ。
    - スピニング = True.
5. 湯が落ちたらドリッパーを外し、 残りの湯 (21 g) をポッドに直接加える (加水: 400 g まで).

何が面白いかと言えば、現在使用している TIMEMORE C3 には「13--16 click がハンドドリップ (Pour over) に適しているよ」と書いてあり、自分では12の挽目は試さなかっただろうということ。いろいろな淹れ方を強制してくれて助かる。

TIMEMORE C3 の挽目

現状の目標は休憩室に置いてある簡易エスプレッソマシンの味を超えることで(うま味等は濃いけど若干えぐみもある)、このレシピの方が好きなのでひとまず満足。

考察(感想)

  • やっぱリンスとスピニングはどうでもいいのか?
    • 「○○式ではここで○○します。なぜならほげほげだからです」はただのポエム?(根拠のないお作法)← A/Bテストで検証できるはずなので、ここで議論しなくていい (むしろ対応アリ検定)。
  • 蒸らし(1投目)と2投目の量とタイミングが重要だという結果は、やはり井崎式やらJames Hoffman式に慣れたV60ユーザからは納得
  • 淹れ方が停滞して5—6を行き来してきたときに、今度は全く異なるレシピを提案してくるOptuna君はやはり優秀(局所最適化対策とかしているのか???)
  • レシピが気になりすぎて、コーヒーを飲みすぎてしまう (早く最適解にたどり着きたい)
    • 1日 注ぎ量400gx2回で600gのコーヒー接種 (=2x30gのコーヒー豆) → Addiction (寒気を感じながらこれを書いてる)
  • 一度Loopを始めると同じ豆で21試行以上回さないとだから(1か月以上)、ハズレ豆を選ぶと残念過ぎる。逆に高い豆を選ぶとプロレタリアートは破産する。
  • レシピを完全再現しておいしいコーヒーができるとキマる。(タイミングとか注ぎ量とか難しすぎるので達成感がある)

TODO, ほしい機能, 欠点

システム面

  • spreadsheetに直書きしているので、Credential情報等、自身のものを使用していて他人が使う際は面倒。(developer consolで使用者自身がGoogle Drive APIの設定等をやらなければならない)
  • 豆を変えたときにspreadsheetを新しく用意する必要があるし、手動で設定しなければならない(さすがにBot内で処理するのは面倒すぎる←Mongo DBとかFirebaseとかでゴリゴリに開発したほうが楽そう)。
  • spreadsheetの処理が遅くて時間がかかる(gspread.Worksheet.findが重すぎる→Suggest処理全体で10秒以上かかる)
  • 井崎式にしか対応していない (TODO: 他のレシピ, eg. 他のハンドドリップの流派、フレンチプレスとかに対応させる)
  • レシピ通りに作るのが難しい(RinseやSpinningを忘れる)
    • テキストベースのレシピではなく、動画のようなものを提示したい
  • 自分しか使えない。(Google Spreadsheetでの管理が面倒)
    • プラットフォームを整える必要(データベース)Mongo DB, Firebase
    • 理想「Flutterとかでスマホアプリを作ってみんながレシピと評価を共有」←みんなで作ろう最強レシピ
  • 非推奨のパッケージを使用している (slackeventsapi)。

⇒ Flutter とかのWeb、スマホアプリにした方が早い。

最適化面

  • 局所最適化は避けられない?(スコア5—6を延々とループしそう)(ベストにはたどり着けないかもしれないし、豆の限界があるかも)
    • 全体評価 Score = 1—7 の7段階では細かい変動が反映しない(とはいえ10段階とかにしてもヒトがそこまで細かく評価できるかが謎。官能評価実験はむずい)。
    • 多目的最適化に切り替えたほうがいい(Score, Umami, Bitter, Flavor, Acidity, Astringency) ←スコアシートには記録しているので、今までのスコアも適用可能。
  • 個人の嗜好に最適化される (万人の好みではない、体調や気分に依存。特に辛い物を食べたら味がわからないのを実感している)。
  • 初めの数回はランダムサーチ (t=10に設定しているが多いかもなのでt=5くらいでいい?)。

パッケージの更新

古いパッケージを使っていたので更新したい(それdeprecatedだよって言われる)。

  • slackpackage → slack_sdk

    slackパッケージが非推奨だからslack_sdkを使えと言われるので(プロジェクトがslackclient からslack_sdkになってモジュール名がslack.*slack_sdk.*と変わった)、移行したほうがいい。

    UserWarning: slack package is deprecated. Please use slack_sdk.web/webhook/rtm package instead. For more info, go to https://slack.dev/python-slack-sdk/v3-migration/

    (おそらく今回の実装は主にslackeventsapiの機能を使用しているので、slack_sdkは使わなくてもよいのかも)

  • slackeventsapislack-bolt

    slackeventapi自体が旧プロジェクトでslack-boltに移行してね、と勧告しているので更新したほうがよさげ。https://qiita.com/seratch/items/8f93fd0bf815b0b1d557 にものすごくわかりやすくAPI周りのことがまとめらている(Slackの中の人)。

    今のところ slackeventsapiで実装しているので、Boltに更新することになりそう(bolt-pythonもデコレータによってイベントと対応する処理を紐づけていて、簡単に実装できそう)。

    slackeventsapi

    If you’re looking for the best recommended library at this point, check Bolt for Pythonhttps://github.com/slackapi/bolt-python

参考: slackjdev/bolt-python

おわりに

SlackBotにしただけで驚くほど手軽になった。ベストなコーヒーが楽しみすぎて1日60gのコーヒー豆を消費している(データが貯まっていく感じが楽しい)。他の人にこの方法をシェアするにはGoogle SpreadsheetのAPI権を用意したりBotの権限を設定したりは面倒であるためおすすめできない(コーヒー好きの知人に伝えようとしたけど面倒になって辞めた)。他人に手軽な方法で公開できるようにしたい(ちゃんとDBを用意してID管理したり、Auth serverを用意してSlackbotを公開、もしくはFlutterでアプリを書いてFirebase上で運用するなど)。

ただしまだまだ最適化面が不安定で最高の抽出に届かない。10コを超えるパラメータに対して目的値(評価点数)が7段階評価なので、おそらく数値解析的によくない。それらパラメータの組合せに対して、7つの表現しかないので勾配(Bayesianなので厳密には勾配と違うと思う)が見つかり難いはず。出てきたレシピは5と6を行き来しているので、Optunaくんは困惑しているのだと思う。

以上の様にいろいろ不便なところが多いので、ぼちぼち改善をしなきゃいけない。そもそも20回以上も脳死で提案されたレシピ通り淹れ続けてきたので、以前はどうやって淹れてきたか長年のやり方なんて飛んでしまった(多分Optunaの方が優秀なのでその方がいい)。もうOptunaなしの抽出なんて惰性で味気ないので、長い付き合いになりそう。おともだちのみんなも自力の探求を諦めてOptuna堕ちしてほしい。``