MOB-LOG

MOB-LOG

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

Firebaseで”hoge.key”などドット入りのフィールドは作ってはいけない。 DocumetReferenceのsetとupdateに与えるフィールド名の仕様が異なり困った話――docRef.set{’key.hoge’: hoge}) と docRef.update({’key.hoge’: hoge}) は意味が全く異なる。――

TL;DR

Firebase (Firestore)でデータを管理していて、DocumentReference.update(data) を使用してデータを更新するようなシステムを開発していたら、updateは成功しているのにfirestore上で値が書き換わらないということが起った。どうやら与えるマップのフィールド名 "key.hoge"key: { hoge: value} というように違うレベルのフィールドとして認識されてしまうらしい。

そのため、下記を気を付ける。

  • データ更新の際にもDocumentReference.set() を使いSetOptions(merge: true)付ける。(mergeを絶対つけること。でないと他のフィールドが消える)
  • (そもそもドット付きのフィールド(key.hoge)なんて使わない)

不具合・問題・症状

症状として、個人開発中のスマホアプリでユーザが持つあるクラスのオブジェクトをFirebaseのあるコレクション上で保存していて、既存のデータが更新できない、という状態に陥った。

コレクション上のDocumentReferenceから docRef.update(data)を行い既存のデータを更新しようとして、updateメソッド自体は正常にdocRef返してonErrorなどでキャッチされていないため、コレクション上には無事に書き込まれているはずなのに、firebase上 (console.firebase.google.com)で該当ドキュメントを確認しても更新されていない。

現状の状態と手続きとしては、

  • 新規保存 (Class.toJsondocRef.set()で保存)
  • フェッチしてオブジェクトを復元が可能な状態 (docRef.data()snapshot.data()でフェッチ後→Class.fromJsonで復元)
  • 更新は成功するが(docRef.update(object.toJson())が問題なく終了)、Cloud Firestoreでは更新されていない(再度フェッチして fromJson で復元しても値が変わっていない)。

という状態。

  • ドキュメントの構造
    • フィールドの形式はMap<String, dynamic>でマップが入れ子になっているような状態。
    • フィールド名などは
      • "hogeClass.Id":"hoge"
      • "hogeClass.childClass":
        • "childClass.id": "hoge"
        • "childClass.field":"hoge"
      • "hogeClass.field": "hoge"

      という具合

そこで、Cloud Firestoreで更新されるはずだったドキュメントを改めて確認してみると、

  • "hogeClass.Id":"hoge"
  • "hogeClass.childClass":
    • "childClass.id": "hoge"
    • "childClass.field":"hoge"
  • "hogeClass.field": "hoge"

に加えて、

  • "hogeClass":
    • "Id":"hoge"
    • "childClass":
      • "id": "hoge"
      • "field":"更新済みの値"
    • "field" : "更新済みの値"

という新しいネストされたフィールドが出来上がっていて、さらに値も更新されている、という状態であることが発覚。

つまり、{"hogeClass.Id":"hoge", …} とMap形式で渡したものが{"hogeClass": {"Id": "hoge"}, …} というようにMap<String, Map<String,dynamic>>のようにドット前後で分解されて"key.nestedKey": "nestedFieldValue"というようにネストされたデータだと解釈・保存されてしまっている。なぜ…🤔

原因

Firebaseのデータ更新のための DocumentReference.update()docRef.update({"key.hoge": "value"}, ) とするとドットを含んでいるキー "key.hoge" が階層に分けられて {"key": {"hoge": "value"}} として保存されてしまう。 逆に新規追加・上書きによく用いられるDocumentReference.set()の場合、docRef.set({"key.hoge": "value"}, ) とすると階層として展開はされず、"key.hoge"にそのまま"value"が格納される。 そのため、新規追加のときにdocRef.set("key.hoge":"value")を使用して、更新でも同じようにdocRef.update("key.hoge":"value2") とすると更新しているように見えて全く違う値が書き換えられる(更新されていないように見えてしまう)。

どうやらそれが仕様らしく(?)、そういう書きかたがされている様子(詳細は最後のおまけに記載)。

解決策

もはや "key.hoge" というフィールドでFirestoreのデータ構造のデータベースが出来上がって使用されていた場合、"key.hoge""key": {"hoge": ...}に置き換えるなんて作業はしたくない(オブジェクトを復元するときのfromJson()とかも書き換えなきゃだし絶対に嫌!)。

key.hoge というようにドットを含んだキーのフィールドを更新したい場合は、調べたところupdateではなくsetSetOptionsをつけてdocRef.set(map, SetOptions(merge: true))とすると、マージ(更新)できるらしい。 (逆にsetでmergeをつけないと上書きされて今うので要注意 ← 与えたmap中にないフィールドは消されてしまう)

まとめ(所感)

  • もしくはデータ更新の際にもDocumentReference.set() を使いSetOptions(merge: true)付ける。
  • DocumentReference.set(data)としてmergeをつけないと、他のフィールドが消えるので、実装するときは注意すること(上書きされていまう)。
  • そもそもドット付きのフィールド(key.hoge)なんて使わない方がいい。
    • それはそれとして、setとupdateで仕様が異なり、setで新規追加時に「こう保存されるのか」と確認したものが updateに同じような形式でデータを与えると異なる解釈がされて全く違う結果となる、という罠がある。初学者(自分)は注意が必要。

おまけ

DocumentReference.setDocumentReference.updateでは別のデータ処理が行われている様子。

  • setでは_CodecUtility.replaceValueWithDelegatesInMap
  • updateでは_CodecUtility.replaceValueWithDelegatesInMapFieldPath

    にデータが渡されて、フィールドが階層構造に(updateでは)分けられたり、(setでは)分けられなかったりする。

  @override
  Future<void> set(Map<String, dynamic> data, [SetOptions? options]) {
    return _delegate.set(
      _CodecUtility.replaceValueWithDelegatesInMap(data)!,
      options,
    );
  }

  @override
  Future<void> update(Map<Object, Object?> data) {
    return _delegate
        .update(_CodecUtility.replaceValueWithDelegatesInMapFieldPath(data)!);
  }

xcode - Flutter xcodebuild: error: Unable to find a destination matching the provided destination specifier (flutterでiOSアプリ開発していて、iOS simulatorの仮想デバイスとうまく接続できないとき)

問題→解決

iOSアプリ開発をしていて、iOS Simulatorをターゲットにしたらデバイスが一致しないぞ、と言われた。

xcodebuild: error: Unable to find a destination matching the provided destination specifier:
            { id:XXXX-XXXXX-XX-XXX}

StackOverflowで完璧に解決策が示されていて、これで解決できた。

RunnerBuild SettingsSupported PlatformsiOSにしたら良いらしい。(画像では修正済みだが、元々Debug: iOS, Profile: iphone, Release: iphoneとなっていてエラーが発生しており、すべてiOSにしたらエラーが発生しなくなった)

そもそもPlatformとしてiphonesという値が不正なのに、なぜか設定で紛れ込んでしまっている、というのが今回の問題でした。

Runner→Build Settings→Architectures→Supported Platforms

余談・反省

n番煎じだって感じだけど、初めてAppStoreにアプリをアップロードしてよし開発続けるかって時にいきなり出てきたエラーで、初見殺しでエラーコードの情報からは(初心者にとって)どうしようもなかった。まずはエラーコード読めが鉄則だけど読んでみてビルド初期化とかいろいろ試して時間を食ってしまったのが裏目で、検索したら一発だったのに、という感想。

GitHubでIssueが建てられており、理由はわかっていないらしい。

I don't know why this works, but I think I found how to solve it.

1. Open Xcode
2. Click "Runner" (root)
3. Click "Build Settings"
4  Change "Supported Platforms" from iphoneos to iOS
Make sure Profile and Release should be iOS, not iphoneos.

なぜこういう設定になったかは分からないが、Xcodeをアップデートしたらこうなったんだよ、という人もいるみたい。

After Xcode update to 15.2 it started happening.

Flavorを適当にいじったからではないかと指摘している人もいる。

**jmagman commented on Jan 19 • edited**

I suspect you have your debug flavor set up incorrectly to use the Profile or Release version of Flutter. Make sure your xcconfig Configurations are set up correctly in Xcode (see note in the docs)

自分の例だと、Podfileを手動でいじってMinimum Targetを指定したことが不味かった?かもしれない(コメントアウトをアンコメントした)。

platform :ios, '13.0'

普段VSCodeからいろいろいじっているけど、XcodeからRunnerの設定を変更したほうが無難かもしれない。

環境など

(Flutter doctor -v)

[✓] Flutter (Channel stable, 3.22.0, on macOS 13.6.7 22G720 darwin-x64, locale en-JP)
    • Flutter version 3.22.0 on channel stable at /Users/indiakilo/development/flutter_3.22.0
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 5dcb86f68f (10 weeks ago), 2024-05-09 07:39:20 -0500
    • Engine revision f6344b75dc
    • Dart version 3.4.0
    • DevTools version 2.34.3

[✓] Xcode - develop for iOS and macOS (Xcode 15.0.1)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 15A507
    • CocoaPods version 1.15.2

Reference

lateでnullableは初期値をnullとして扱ってくれないので明示的に代入しなければいけない (`late Object? = null;`)。そもそもnullでいいならlateにするな。

TL;DR

late Object? hoge = null; とするとnullableなのでnull代入要らないよ、と提案されるのにもかかわらず(avoid_init_to_null)、明示的な代入を覗いてlate Object? hoge; にすると、いざ他に初期化してない場合には (LateInitializationError: Field 'hoge' has not been initialized.)が出る。

そのため、

  • 明示的に代入するか(→ late Object? hoge = null;)、
  • 特にlateである意味がないのであれば(どうしても他の値で初期化させたい)、lateを外してObject? hoge;

とする。

lateはnull以外に初期化させなければいけないときのみに使おう。

詳細

  late Object? lateNullableWithNull = null; // nullableだからnull代入要らないよと注意される(**avoid_init_to_null**)
  print(lateNullable); // > null

  late Object? lateNullable; // nullableなので代入せずとも null のはず
  print(lateNullable); // ただし代入してないと使用時に `LateInitializationError` (nullableにnull初期値が無視されてlateの制約が優先される)
  
  Object? nullable; // 初期値nullでいいなら`late`を外して単純なnullableにする。

dartでは

  • nullable型 変数**?**と?付きで宣言されたnullの可能性がある変数)にすると宣言時に勝手にnullに初期化される。 そのため、nullableをnullで初期化すると、nullで初期化しなくてもいいよと注意される(avoid_init_to_null)。

    nullableに=nullで初期化すると「nullableはnull初期化しなくてもいいよ、初期化取り除いていい? (`avoid_init_to_null`)」と促される。

  • nullableでない変数は宣言時やコンストラクタで初期化されなければいけないが (構文エラー)、lateキーワードを使うとその場で初期化しなくともよい (使用前に初期化さえすればよい)。

  • lateでnullableな変数の場合 (late Object? lateNullable;)、nullableにもかかわらずnullで初期化されず明示的に初期化代入しなければ、使用時にLateInitializationErrorで怒られる。
  • つまり nullableのnull初期化はlateの初期化にカウントされない (late優先)。

ということなので、avoid_init_to_nullの注意を無視するか (late Object? lateNullableWithNull = null;)、lateを外して Object? nullable; で十分。

おまけ

  • late final でnullableかつ、StatefulWidgetのwidgetを使って初期化する、という特殊なときにはlate nullableを使わざるを得ないみたい。知らんけど。
class HogeWidgetextends StatefulWidget {
  HogeWidget(){}
  Object? hoge; // nullable
  
  @override
  State<HogeWidget> createState() => _HogeState();
}

class _UserRecipePageState extends State<UserRecipePage> {
  final Object? hoge = widget.hoge; 
  // クラスのメンバはwidgetを使って初期化できない。initStateやbuild内でしなければならない
  // widget.hoge も nullable
  // finalなのでここかコンストラクタで初期化しなければならない。
  // 
  late final Object? hoge = widget.hoge; // late にするとなぜかここで代入できる。

  // 省略
}
  • vscodeの静的解析の提案に従って、プロジェクト内の全部の avoid_init_to_null を自動修正してしまって戻さなくいけなくなったときは(やってしまった)、プロジェクト内検索(ctrl+shift F)の正規表現 late [a-zA-Z ]+\? で全部見つかるはず。 (late [a-zA-Z0-9\s\S]+\? の方が強そう)

やむを得ず List<子クラス>→List<親クラス>→List<子クラス>にキャストしたいとき(List<Child>→List<Parent>→List<Child>)。 ⇒ 要素ごとにキャストする。

TL;DR

Flutter (Dart) でモバイルアプリを作っていたとき、Listの型キャストで以下の様に詰まった。

  • やりたいこと:List<子クラス>List<親クラス>List<子クラス>の型キャストをしたい。
  • 問題:List<親クラス>List<子クラス> の時点で汎化→特化ができないため、エラーが発生する。
  • 解決策:Listの要素を一つ一つas 子クラスでキャストする。

Listにすると型キャストがややこしくなるんだなぁ。

問題の詳細

Flutterでアプリを作っていて、いろいろあってList<子クラス>List<親クラス>List<子クラス> という型キャストを実行する必要が出てきた。一般的かわからないが(推奨されているかどうか)、子クラス>親クラス子クラス はオブジェクト自体は同じのため通してくれる(一時的に子インスタンスを親インスタンスとして扱っているだけなので。便利)。ただし上記のようにList<子クラス>List<親クラス>間だと怒られてしまう (List<親クラス>はList<子クラス>のサブタイプではないよエラー)。

↓ 具体的なコード※ 以下、親クラス→Parent、 子クラス→ChildAと置き換えています。

Future<List<T>> refreshItems() async {
  List<Parent> itemList =
      await Parent.queryUserItemList(type: T); 
           /// List<ChildA> を返すけどList<ChildB>の場合もあるためList<Parent>で受け止める
           /// そもそもParent.queryUserItemListもジェネリックで作って List<T> を返すようにすればよかった
  this.items.clear();

  // this.items は List<T> なのでitemList List<Parent> (オブジェクト自身はList<ChildA>)から型キャストしなければいけない
  this.items.addAll(itemList as List<T>); // `as List<T>` や `as List<ChildA>` ではエラーが出る

  return this.items;
}

ややこしいが、ジェネリック<T extends Parent>を使用してTにクラスChildA (Parentからの派生クラス) を指定しており、List<ChildA>List<Parent>List<ChildA>を というキャストが発生している。もちろん、List<Parent>List<ChildA> (汎化→特化)はできないため、ParentChildAのsubtypeじゃないよというエラーが発生する。

Error: TypeError: Instance of 'JSArray<parent>': type 'List<Parent>' is not a subtype of type 'List<ChildA>’

ただ、ChildAParentChildA はできるんだよなぁ、オブジェクトは同じだから何とか融通してくれよ(鼻ほじ)と思いながらいろいろ試した。

解決策

ChildAParentChildA はできるので、List内の要素それぞれでキャストしてしまえ(ParentChild)、となった。以下の様に itemList.map((item) => item as T).toList() とすれば許された。

Future<List<T>> refreshItems() async {
  List<Parent> itemList =
      await Parent.queryUserItemList(type: T);
  this.items.clear();

  // 型変換のためには要素ごとに`T`へキャストして、新しいリストを作成しなければいけないらしい
  this.items.addAll(itemList.map((item) => item as T).toList());

  /// なんなら、もし`Parent→T`のキャストができない場合にはエラーが出るため、
  /// 以下の様にwhereを使用して確実にキャストできるアイテムのみ加える
  ///(型キャスト可能かの確認はプログラマに責任がある)
    // this.items.addAll(
    //   itemList.where((item) => item is T).map((item) => itemas T).toList()
    // );
    
  return this.items;
}

List<ChildA>List<Parent>List<ChildA> であってリスト内のオブジェクトが子クラスのインスタンスである前提でキャストしているが(自分が総設計したので)、list.where((item) => item is T) のように確実にキャストできる(キャスト先のインスタンスである)要素のみを選別してからキャストする方が無難である。

   this.items.addAll(
      itemList.where((item) => item is T).map((item) => itemas T).toList()
    );

まとめ(所感・反省)

  • 配列や連想配列にするとキャストがいろいろややこしくなるらしい が(ChildParentChildは楽だけどList<Child>List<Parent>List<Child>は面倒)、要素ごと対処したら何とかなった。
  • クラスの継承とジェネリック型は上手く設計しないと、型の解決にややこしい処理が必要となる(気持ち悪い)。
    • 今回の場合は Parent.queryUserItemList(type: T)List<Parent>しか返せないため、キャストしなければいけない状況に陥った。元々ジェネリックスを使ってParent.queryUserItemList<T extends Parent>() => List<T> という設計になっていれば余計な処理は必要なかったはず。

FlutterでDebug時は上手く動いたのにRelease/Profile では動かないってときには、どうデバッグすればよいか? →printデバッグすれば何とかなる

TL;DR

今回の場合は、Text オブジェクトのtoString() の内容がdebugモードとreleaseモードで異なっていることが原因でした(toString()自体の仕様なのかもっと複雑な原因があるのかは不明)。

Release/ProfileモードでDebug時には起きなかったバグ・挙動が発生したときは、Profileモードで実行してprintデバッグしましょう。以下の様にprofileモードで振舞いを確認するのが手っ取り早い(自分の場合はそうだった)。

  1. 機能しない箇所にあたりをつけ(追加・変更箇所)、 print()developer.log() などで手掛かりを出力(いわゆるprintデバッグ
  2. 標準・エラー出力を確認するためにProfileモードで起動して、変数や振舞いが期待通りかを確認する。
  3. (運よく見つけられたら)バグを修正

ここにきて printデバッグをするとは思わなかった。🤦‍♂️🤦🤦‍♀️

背景(なにが起こったか )

FlutterでDataTableを使用してFirebase上のデータを表示していて、ソート機能を追加していたが、releaseモードで実機で試したところ期待通りの動作が得られなかった。Debug時には上手く動いていたのに、なぜ?

今回は、Scoreカラムの “Total avg. score: {数値}” をStringのまま比較して、数値順にソートしようとしていました。

DataTableの一部。Scoreがソートされる対象で、ヘッダのScoreをタップすると昇順・降順か切り替わりソートされる。

DataTableで対象カラムにの値に対してソートするにはsortColumnIndex を指定して、そのカラムのヘッダー(columns)には以下のようにソートアルゴリズムが設定されたDataColumnを渡します(onSortに方法を記述する)。以下がソート対象のヘッダーのDataColumn

DataColumn(
  label: const Text('Score'),
  onSort: (columnIndex, ascending) {
    _sortByScore(columnIndex, ascending);
  })

で、以下がソートアルゴリズム (_sortByScore)

  void _sort<T>(Comparable<T> Function(DataRow) getField, int columnIndex,
      bool ascending) {
    _dataRows.sort((a, b) {
      final aValue = getField(a);
      final bValue = getField(b);
      return ascending
          ? Comparable.compare(aValue, bValue)
          : Comparable.compare(bValue, aValue);
    });

    setState(() {
      _sortColumnIndex = columnIndex;
      _isAscending = ascending;
    });
  
  void _sortByScore(int columnIndex, bool ascending) {
    _sort<String>((row) {
      dynamic _content = (row.cells[columnIndex].child as SizedBox).child;
      _content = _content is Column
          ? (_content as Column)
          : _content is Wrap
              ? (_content as Wrap)
              : null;

      _content = _content.children.first;
      Text _text = (_content is Text) ? _content : Text(_content.toString());
      // DateTime.parse(_text.data.toString().replaceAll('\n', ' '));
      return _text.toString();
    }, columnIndex, ascending);
  }

_sortByScore がrowの中から該当カラムの値 (Widget) を引っ張り出して内容を抽出し(スコアが記されたText)、スコアを_sortへ渡して比較・ソートしてもらう、という処理をやっています(WidgetがColumnだったりWrapだったりでごちゃごやです)。

このソート機能が、debugモードでは機能したのに(AndroidStudioでも実機でもちゃんとソートされていた)、Releaseモードでビルドして試すと全く機能せず、ソートしてくれない、という状況です。

解決策(なにを試したか)

ざっと調べたところによると 1 2 3 、DebugとReleaseとの主な違いはコンパイラであり、DebugモードではJiT (Just-in-Time) コンパイラ、Release/ProfileモードではAoT (Ahead-of-Time) コンパイラが使用されます。そして今回の様に挙動が異なる理由は、JiTとAoTコンパイラで実行される内容が若干異なること、実行速度が異なることでlate変数の評価が間に合うかどうか、などが考えられます。

なるほどAoTコンパラで実行時のいろいろな値が観れれば問題の原因が見つかるだろう、ということでprintデバッグすることにしました。以下の様に、ソート時の値をprintで標準出力させます(ソートできてないということなので比較対象の値を確認する)。

  void _sortByScore(int columnIndex, bool ascending) {
    _sort<String>((row) {
      dynamic _content = (row.cells[columnIndex].child as SizedBox).child;
      _content = _content is Column
          ? (_content as Column)
          : _content is Wrap
              ? (_content as Wrap)
              : null;

      _content = _content.children.first;
      print('_content: ${_content.runtimeType}'); // <= ここ
      Text _text = (_content is Text) ? _content : Text(_content.toString());

      print('\t${_text.toString()}'); // <= ここ
      return _text.toString();
    }, columnIndex, ascending);
  }

Releaseでは標準出力が確認できない (? ←ほかに方法はあるかも) ため profile モード(AoTコンパイラ)で実行します($ flutter run --profile)。

出力された値はこんな感じ。

デバッグ時 (期待通り動く場合)、

I/flutter ( 1188):      : Text("Total avg. score:    22.0")
I/flutter ( 1188): _content: RichText
I/flutter ( 1188):      : Text("RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "No rating yet.")")
I/flutter ( 1188): _content: Text
I/flutter ( 1188):      : Text("Total avg. score:    21.0")
I/flutter ( 1188): _content: RichText
I/flutter ( 1188):      : Text("RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "No rating yet.")")

各行の値として "Text("Total avg. score: 22.0")""Text("RichText(softWrap: wrapping at box width, maxLines: unlimited, text: "No rating yet.")")" というように、TextオブジェクトをtoString()した値がスコア値を含んでおり、それらが比較されソートされているとわかります。

そしてProfileモードでは、

I/flutter (31224):      : Instance of 'Text'
I/flutter (31224): _content: Text
I/flutter (31224):      : Instance of 'Text'
I/flutter (31224): _content: Text
I/flutter (31224):      : Instance of 'Text'
I/flutter (31224): _content: Text
I/flutter (31224):      : Instance of 'Text'

なんと、すべてが "Instance of 'Text'"というStringになってしまっていました。ソートできないはずですわ。

つまり AoTコンパイラでは TextをtoString()してもTextの内容まで出力してくれないため、ソートされるべき内容を評価できていなかったということです。

以下の様に text.toString()ではなくtext.dataと内容を直接参照することで、解決しました(正規表現なりで数値をパースしてfloatにしろってのはその通り!)。

  void _sortByScore(int columnIndex, bool ascending) {
      _sort<String>((row) {
          // 略
          return _text.data.toString();
    }, columnIndex, ascending);
  }

まとめ(反省・学び)

  • Release/ProfileモードでDebug時には起きなかったバグ・挙動が発生したときは、Profileモードで実行してprintデバッグすると手っ取り早い。
  • JiTコンパイラ (Debugモード ) と AoTコンパイラ (Release/Profile) で、やはり振舞い(処理・評価)が異なることがある様子。(今回は toString() がその中身であるdataを出力するかどうか)
  • デバッグ情報を確認するために「Profileモードでテストしましょう」と書いたがReleaseでもprintの標準出力では問題なく標準出力される様子 (コンソールから flutter run --releaseflutter run --profile)。developer.log() だとreleaseモードでは流れない。--profileで動かせば全部流れてくるのであまり考えずProfileを選べばよさそう。

Coffee調達 in Finland #3—ハンドドリップ編—

はじめに

本記事は 海外TUT Advent Calendar 2023 の20日目に寄せた記事です(大遅刻)。

FIは物価が高く、おいしいものを摂取するには高いお金を払わなければなりません。ならば普段飲むコーヒーはできる限り旨くQoLをキープして、最低限文化的な留学を送りましょうというシリーズです。今回はハンドドリップ抽出のレシピを改善する記事です。

TL;DR

フィンランドでいろいろなコーヒー豆を試して美味しいハンドドリップコーヒーを飲むためにレシピ最適化アプリを作った。」

コーヒーのハンドドリップ抽出のレシピを考えるとき、できるだけ美味しくなるようにヒトのセンスでパラメータを変更して改善しますが、

  1. 豆ごと(産地・焙煎度合い)に抽出する方法は異なるはずだが、それぞれの豆に合わせてレシピを考えるのが困難、
  2. ひとりの味覚・個人のセンスでレシピを改善していて果たして最善のレシピにたどり着けるのか、

という問題があります。そこで、個人の味覚に依らず統計的にレシピを改善するモバイルアプリを開発することにしました(Human in the Coffee Loop: HitCL)。ハイパーパラメータ最適化手法を使ってレシピを自動提案し、個人の味の評価に合わせて最適化していきます(いずれユーザー間でデータを統合して個人の味覚に依らない最適のレシピを作りたい)。なので人間は提案されたレシピに従ってコーヒーを淹れその味の評価を行うだけで、何も考えず旨いコーヒーを淹れて飲むマシンと化します。

現状は一般未公開ですが(かつ1つのユーザのデータに依存して最適化)、いずれ公開し、ユーザからのデータを集めることで複数ユーザの嗜好を基に最高の1杯を作り上げるつもりです。

背景

ハンドドリップで淹れるのが自由度が高くコスパが良い方法です。ただし豆によってはえぐみが強かったりするので、抽出時間やら湯温を変えレシピを整える必要があります。つまり美味しく入れようとすると面倒なので、ひとによっては続かずに断念してしまいます。またせっかくロースターから買った美味しい豆を淹れ方次第でまずくするのは非常に残念です。

ハンドドリップ (hand pouring) を面倒にする要素はおよそ以下の3つ

  1. 豆を自分で挽くのが面倒。 セラミックのミルだと確かにめっちゃ時間が掛かる(挽くのが時間が掛かるからインスタントに戻ったという人もいる)
  2. 器具が高い。 しっかりとしたミルを用意すると 15,000JPY やら 電動なら高くて60,000 JPYする。
  3. レシピを考えるのが面倒。 メモして味見して変更して、というのを頭で考えるのは非常に面倒。忙しい人には無理。

1は豆で買うことを前提としていますが(レギュラーより豆の方が旨いので)、 高い金属ミルを使えば何とかなります (KinGrinder K, P、TimeMore C、Commandante 、ミルっこ、Kalita Next G、など)。なので実質1は2と実質等しいですが、1万円を超える手動ミルは一生ものなので償却されません(つまり資産!!親から子へ引き継ぎましょう)。

つまり最終的にはレシピを考えるのに時間的、精神的に面倒だという部分がネックになります。でそもそもレシピってなんだ、ということですが、まず淹れ方の流儀が様々で例えば第15代ワールドバリスタチャンピオン(2012年)である井崎英典さんが薦めているのは、3回に分けてお湯を注ぎ、注ぐタイミングと湯量を調整するというものです。(1回で全部注いでええやろ、とか4回だとかいろいろな淹れ方がありますが、今回は井崎式のみを考えます)

お湯の温度、蒸らし時間、3回分の投入タイミング・量、スピニング・リンスの有無、豆と出来上がり量の比など、パラーメータの数が15項目ほどあります。私は毎日最低400gのコーヒーを淹れますが、忙しいのでそんなに考えている暇はありません(次第に適当になって、豆に関わらずバリスタおすすめのプリセットで済ませたりすることになります)。ともあれもっとおいしいコーヒーが飲みたいので「何にも考えないで美味しいレシピが欲しい」がふんわりとした要求になります。

ある豆に関してコーヒーレシピの最適なパラメータを求める問題は、単純においしさを最大にする最適解を求める問題は、  \mathbf{X}_\mathrm{awesome} = \underset{\mathbf{X}}{\mathrm{arg}} \mathrm{min} f(\mathbf{X},b) と表せます(豆 bについてパラメータ \mathbf{X}からおいしさ y=f(\mathbf{X},b)を最大化する。 f(・)ブラックボックス(世界の真理)なので深く考えません)。つまり既存の統計・機械学習のアプローチで解決できるって話です。後はヒトが使いやすいインターフェースを作るだけ、ということで作りました。

手法

ヒトが手軽に触れるインターフェースとして、モバイルアプリとして実装します。今回はGoogleさんのFlutterを使用します *1。そしてパラメータの最適化にはOptunaというパッケージを使います(Preferred Networks)。Genetic algorithmであったり焼き鞣し法のような他の典型的な手法でもいいんじゃないかという話がありますが、Optunaを利用すると比較的簡単に実装できるため、これを使用します*2

(開発の詳細、技術面は 『コーヒードリップレシピのパラメータ最適化 Human in the Coffee Loop (HitCL) モバイルアプリ』 にあります)

細かいことは以下の通り。

  • 評価方法:酸味・苦味・うま味・香り・えぐみを7段階で評価する。
  • 最適化条件:コーヒー豆別、ユーザー別で、評価値(=酸味+苦味+うま味+香り-えぐみ)を最大化する。
  • 器具:(今のところ) ドリッパーはV60、グラインダーはTIMEMORE C3で固定。温度調整できるコーヒーケトルと0.1g刻みで重さをはかるスケールが必要。
  • 最適化するパラメータ:
    • (お湯の注ぎ量の総量は400g=2杯分で固定)
    • 豆の挽きの細かさ(グラインダーのクリック数)
    • 湯温(℃)
    • リンスするかどうか(ペーパーフィルターをお湯ですすぐやつ)。
    • お湯対コーヒー豆の重量比 [g/g] (コーヒーの濃さが変わる)
    • 1投目(蒸らし):
      • 注ぎ量 [g]
      • 注ぎ時間 [sec](0秒—何秒までで上の注ぎ量をそそぐか)
      • 蒸らし時間(0秒から2投目まで時間)
      • スピニングをするかどうか(boolean)
    • 2投目:
      • (注ぎタイミング:蒸らし時間と同じ)
      • 注ぎ量 [g]
      • 注ぎ時間 [sec]
    • 3投目:
      • 注ぎタイミング [sec]
      • 注ぎ量 [g]
      • 注ぎ時間 [sec]
      • スピニングするかどうか(boolean)

アプリ概要 (Human in the Coffee Loop)

ヒトがやることは

  1. 淹れるコーヒー豆を選んで、
  2. レシピをリクエストし、
  3. レシピ通りコーヒーを淹れて、
  4. 飲んで評価する、

だけです。後は勝手にコーヒーがおいしくなっていきます。

メリットとデメリットは以下の通り。

  • なにも考えずに毎日コーヒーを淹れるドリップマシンになれる(レシピを考えなくていい)。
  • 最適化の速度が遅いため(ベストにたどり着くことはあるのか?)、淹れすぎ飲みすぎてしまうこと(カフェイン量を考えると毎日400gを2試行までしか回せないのでもどかしい)。
  • ハンドドリップは時間が掛かる(慣れたので自分は感じないが、面倒な人はいそう)。

レシピを考える時間が省略されたのと、確実にそれなりに美味しいコーヒーが淹れられるようになるので、ハンドドリップに手を出すハードルは下がったように思います。

アプリの様子は以下の通り(スクリーンショット)。

コーヒー豆の登録、レシピの一覧、レシピのサジェスト(reject or accept)、レシピの評価画面

結果

PualigのCafe New Yorkを使ってアプリの使用感を検証しました。[浅煎りの酸味が強い豆。FIでは浅煎りの方が一般的らしいので]これを30回以上入れた結果、体感ですが徐々に旨いコーヒーが出来上がっているように思います (n=1)。

試行ごの評価の改善具合(おいしくなっているか)

評価をプロットすると、試行ごと(trial)に全体的な評価が向上していることが分かります(トレンドが上向き)。ただ、開発者である自分が淹れて評価してを繰り返した結果なので、確実にバイアスが載っているはずです(徐々に旨くなるはずと思い込んでいるので評価に影響している可能性が高い)。

試行ごとのレシピ評価値(New York, Paulig. N=1, 75 trials)

最強のレシピ(暫定)

暫定の最高のレシピは以下の通り。強い酸味・うま味でえぐみが全くないめちゃくちゃ旨いコーヒーでした(誰か試してみて下さい)。

  1. コーヒー豆 28.8 g を 12-click で挽く (TIMEMORE C3を使用)
  2. 85℃のお湯を用意する
  3. リンスはしない(←ペーパーフィルターに湯をかけるやつ)
  4. タイマーをスタート、ドリップ開始:

    00:00-00:59 1投目 (蒸らし):

    • 94 g まで湯 94 g を 16 sec 程で注ぐ
    • スピニングなし
    • 蒸らしのために 59 sec 待つ

    00:59-01:26 2投目: 209 g まで

    • 115 g を 27 sec 程で注ぐ

    01:50-02:15 3投目: 379 g まで

    • 170 g を 25 sec ほどで注ぐ
    • スピニングする
  5. 湯が落ちたらドリッパーを外し、 残りの湯 (21 g) をポッドに直接加える (加水: 400 g まで)

パラメータの重要度

パラメータごとの重要度を比較すると、

  • 3回目の注ぎ時間
  • 豆・お湯の総量費(コーヒーの濃さ)
  • 蒸らし(1投目)の注ぎ量

などが評価に影響していることが分かります。湯温 (Temperature[℃])は体感はめちゃくちゃ重要なはずですが、思いのほか重要度が低く見積もられています。もしかしたら、どんな湯温でも淹れ方次第(注ぎ量・タイミング・時間など)で美味しくなるということかもしれません(要検証)。 それにしてもSpinningやらRinseやらの重要度は低く、味には影響しないように見えます。湯温と同様に淹れ方次第なのかもしれませんし(蒸らし時のスピニングは重要な気がしないんでもない。A/Bテストなどで検証すれば確実にわかるはず)、ドリップ時にどや顔でやっている小技はそこまで味に影響しないないのかも(挽く前に豆を湿らせるのは意味があります)。

パラメータごとの重要度(New York, Paulig. N=1, 75 trials)*3

展望

直近:

  • 器具と流派を条件付けできるようにしたい(異なるグラインダーだとやはり結果が違うしクリック数とかが異なる。井崎式だけじゃないし、ネルドリップしたい人もいるはず)
  • とりあえず一般公開したい(現在Androidの内部テスト版をPlay Storeで配信中。iPhoneは配信方法が面倒なので未公開)。

近い未来:

  • (特に地域のロースターが販売している豆について)コーヒー豆の販売者が「この豆にはこのレシピがおすすめです。理論値(我々の実験上)これくらい美味しくなります。」というアドバイスや味の目安(リファレンス)が示され、消費者が豆を選びやすくなる。
  • FIのハンドドリップ愛好家に布教する(現地のロースターとか)。
  • ユーザーが増えてデータが貯まったらコーヒーのレシピと嗜好について何かしら論文などで発表したいし、データセットを公表してKaggleコンペとかやりたい(レシピ評価値の予測など)。

遠い未来:

  • 評価・パラメータの改善機能を備えたコーヒーマシンを作りたい(スマホで操作してレシピを選んで評価を行う)。

おわりに

コーヒーのレシピを考える時間が省けて簡単にコーヒーを享受できるようになりました。どんな豆でも数十試行後にはそれなりに旨いコーヒーがので、いろいろな高価な豆に挑戦することができます(無駄にしたくない)。FIの現地のロースターを周って美味しい豆を買いに出かけましょう。

実験環境(おまけ)

仕様器具は下のとおり。

  • フィルター:HARIO V60 (VDMR-02-HSV, ステンレスの1—4杯分のやつ)
  • グラインダー:TIMEMORE C3
  • コーヒースケール:適当なやつ(400gまで計れて0.1g刻みであればよい。タイマー付きだけどタイマー自体はスマホとかで十分)。
  • コーヒーポッド:1℃単位で温度調整ができて、注ぎ口がいい感じのやつ(これ)。

*1:特にアクセス数が爆発するように思えないのでGCP=Google Cloud Platformで十分だろうというのと(初心者)、私はモバイルアプリエンジニアではないのでマルチプラットフォームで開発できる(iPhone版とAndroid版を一緒につくれる)という点で選びました]

*2:自身が深層学習のプロジェクトでハイパーパラメータチューニング目的で使用していたのでちょうどいい、という理由もありますBayesianであればXGBoostでもええやろとも言えますが、OptunaにはAsk-and-Tellという機能があり使いやすそうだったので。

*3:このプロットの値について詳しくは関知していませんが、ANOVAによる結果の様子なので、分散の小ささや主効果に関する値なのかもしれません

(アンチパターン)親知らずを抜かない海外留学

本記事は 海外TUT Advent Calendar 2023 の15日目に寄せた記事です。

14日目は『(脱法)睡眠管理 in Finland』を書きました。今日もカフェインを摂取して目を覚まし、メラトニンをキメていい夢を見たいと思います。

はじめに

よくある留学のTIPSでは「親知らずは抜いておいた方がいい。でないともしもの時に治療費がとんでもなくなる。」とよく書かれます。私は精神が未熟なのでよく逆張りをします。そしてこう思いました。

  • 親知らずを抜かずにもし留学中にポップして痛くなったとき、何が起こるのだろう?
  • TIPSに従って誰も親知らずを抜かずに留学していないとしたら、親知らずで痛む経験or抜く経験は貴重なのでは?
  • (あと、この歯いらないし邪魔じゃない?と歯科医師にお金を払うのは癪)

ということで抜かずにいたのですが、ついに親知らずがポップして痛みが来ました。

(ちなみに4本あるうちの左上の1本は留学以前に無事に処置要らずの軽症で生えてきたので、特に心配はしていませんでした)

ここではその体験を書きます。

症状・対処

今回生えてきた親知らずな歯は下あごの右側でした。以下は時間経過と症状・対処をまとめたものです。

  • まずは舌の先でつついてみて、なんか痛いのと歯茎のふくらみを感じました。ここで、温めていた親知らずがもしや?と思い当たりました。
  • 親知らずの認知から1日後(18時間後くらいの夕方)、ダルさと熱が出始め(正確に測ってはいない)、その日は汗だくになって寝ました。
  • 次の日(24時間以降のち)は熱とともに歯茎の痛みが極まっており、脈拍に伴って鈍痛が走ります。ここでデスクワークができなくなるのが癪なので、鎮痛剤・抗生物質を買いに薬局へ走りました(熱が出るのは繁殖した雑菌への生理反応なので、痛みはともかく抗生物質さえあればなんととかなると考えていました)。
  • 店員さんに抗生物質はどこか聞くと「処方箋はあるか?処方箋がないと購入できない」と教えてもらったので、鎮痛剤だけ買ってきました。購入したのはBurana(抗炎症剤=anti-inflamation)です。
  • 大人の場合は1日3状まで、1回1/2—1錠の接種が用法で、血管の拡張やら炎症による痛みが治まります。こいつを飲んでおけば熱も痛みも治まって(熱も収まるのはなぜ?)、日常生活を送ることが出来ました。
  • これを飲み続け5,6日後にはほぼ痛みが治まり、Burana要らずになりました。

Burana接種開始後に問題になった痛みのピークは

  • ジムでのトレーニング中と後(心拍数が上がったとき)、
  • 夜に歯を磨いた後、

くらいだったので、そこで1錠飲んでおけばなんとかなります(眠れないときはさらにMelatoninを飲みましょう)。

今回は、痛みの発生からBurana要らずになるほど痛みが治まるまで1週間程度でした。

Burana (ibuprofen)

Buranaは抗炎症剤で、Finlandでものすごく一般的な痛み止めの様です。内容自体はイブプロフェンらしく、関節炎や発熱、炎症の鎮痛に効く様子。

Finlandの医者に掛かると高確率でBuranaが処方されるため(〇〇痛の場合やねん挫など)、「いつもBuranaが処方される」というMEME (? )があるらしい。

When you always get prescribed Burana*1. 頭痛、heart attack、睡眠障害にもBURANAを処方される(?)と書いてあります(Heart Attackでも????)。

私も以前、ねん挫も何も外傷してないのに足首が炎症を起こし歩けなくなりましたが、病院行こうかなと考えていたらFinnの友達が「まずBurana飲めばいいよ」と教えてくれて、飲んだだけで炎症が収まり問題がなくなりました。(疲労でひびが入ったのかと思ったものの、骨には問題がなくただの関節の炎症だったみたいです)

Buranaは万能(炎症の鎮痛と抗炎症に限り)。

(発熱の所為か、Buranaの副作用なのかはわかりませんが、酸味への味覚が薄まった感覚がありました←浅煎りのコーヒーがおいしくなかった)

結論(事前に抜くか・抜かないか)

親知らず経歴をまとめると、

  • 1本(上あご左)は日本で何事もなく(痛みのみ)生えてきた。
  • 1本(下あご右)は、今回自身を実験台にし潜在的な歯を抜かずに放置した結果、無事に抗炎症剤(Burana)で乗り越えられた。
  • 残り2本 (下あご左、上あご右) が潜んでおり、どうなるかは不明。

2/4で治療の必要がなく、残り2/4は未知です。

ここで留学前に抜くか抜かないかの選択を改めて考えると、別に抜かない選択肢を取る意味は特にありません。

  • 留学中親知らずが生えてきて大変だった自慢をしたい人、
  • 人生がつまらなくてリスクを味わいたい人、
  • 海外の歯医者に掛かりたい、保険を使用する経験をしたい、知的好奇心があふれた経験廚の人、

は残せばいいですが、親知らずを抜かない程度ではそこまで大事にもならなさそうです。

もし、自分親知らず抜いてないんだけど、という人も、今回の様に抗炎症剤を飲んでしっかりと歯を磨いておけば(健康で生え方に問題がなければ)痛みを凌いで何とかなります(しらんけど)。

学び

今回のTIPSです。

  • 軽度の親知らず(?)はBuranaで十分(他の炎症・腫れも大体Buranaで大丈夫)。
  • 抗生物質(anti-biotics)は処方箋がないと買えない(逆に抗生物質さえあれば何も問題がないと考えていたので一時は絶望しました)。
    • なので銃で撃たれたときは薬局に行かずに病院へ行かないと抗生物質は手に入りません*2
  • なにか怪我やらがあれば電話の準備を。
    • 電話をしないと予約できません(オンラインで症状等情報をまとめてから予約しろという情報を見かけましたが、最低でもPilice Card (Residence permit cardの強い版) によるオンライン身元確認が必要なので滞在1年未満予定の留学生には難しいのと、そもそも私のいる地域ではオンライン予約に対応してませんでした)。
    • General Practitioner (GP) の診察を受け、重大であれば専門医に推薦状をもらう、というシステムで時間が掛かるため、覚悟を決めたら早めに電話しましょう。
  • 親知らずは留学前に抜いておくのがベター(保険とか問題はなくとも、留学先サバイバルをしたくなければ面倒)
  • FYI: 痔を治療しない留学を実践した留学経験者の治療体験を発見しました。さて、私は痔持ちだぞ。

事前に抜いたほうが面倒はないので、みなさんは抜きましょう(他人の歯なんてどうでもいいので何とでも言います)。

おわりに

おわり。

16日目は今井裕之輔さん【合格体験記】トビタテ!留学JAPAN15期1次審査書類作成編!です。

*1:When you always get prescribed Burana - Very Finnish Problems, Very Finnish Problemsより引用

*2:病院に行けない身の上の人が銃で撃たれて薬局で抗生物質を入手するのをアメリカのクリミナルドラマで見ました。