MOB-LOG

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

df.concat でInvalidIndexError が出る [python, pandas.DataFrame]

TL;DR

pd.concat(..., ignore_index=True) は、結合対象の DataFrame においてカラム名がユニークでない、かつ列構造が一致していないと InvalidIndexError を起こす。 特に np.nan を含むカラム名の重複が原因である可能性がある。 カラム名の重複を検査して、解決できそう (.columnsis_uniquedf.columns.duplicated() を行う)。

1行で動くかどうか確かめるなら df = df.drop(labels=df.columns[df.columns.duplicated()], axis="columns", )

(今回はDXの文脈で馬鹿なフォーマットの不整合なexcelデータ (xlsx) をDataFrameにしようとしてエラーが出た。DXあるある)

背景・問題

DX化みたいなプロジェクトの作業をしていて、pd.read_excel で読み込んだ Excel データを pd.concat(dfs, ignore_index=True) で結合しようとしたところ、以下のエラーが発生。

InvalidIndexError: Reindexing only valid with uniquely valued Index objects

ignore_indexのし忘れだと思っていたらちゃんとTrueにしているので、なにか面倒なことになっていそう。

DataFrame のカラム数が異なる様子。

dfs[0].shape # -> (1, 2811) dfs[1].shape # -> (1, 2810)

さらに調査すると、複数の DataFrame において np.nan を含む重複カラムが存在。

for i, df in enumerate(dfs):
    duplicated_cols = df.columns[df.columns.duplicated()]
    if not duplicated_cols.empty:
        print(f"df[{i}] has duplicated columns: {duplicated_cols.tolist()}")
# >>>
# df[0] has duplicated columns: [nan, nan, nan, nan, nan, nan]
# df[1] has duplicated columns: [nan, nan, nan, nan, nan, nan]
#

Excelファイル (xlsx)を確認すると、ヘッダーの途中で空のカラムが複数あり、それが  np.nan に埋められてしまい、重複したnp.nan が生まれた様子。変な表データ作るな。

検証 (やったこと)

以下の条件ごとに pd.concat(..., ignore_index=True) の動作を検証。

Case 1: np.nan を含むが、列数・構造が同一

df1 = pd.DataFrame([[1, 2]], columns=[np.nan, np.nan])
df2 = pd.DataFrame([[3, 4]], columns=[np.nan, np.nan])
pd.cocat([df1,df2])

→ 成功。列構造が一致しているため問題なし。np.nanだから比較できないというわけではなさそう

Case 2: カラム名が重複しており、列数も一致

df1 = pd.DataFrame([[1, 2]], columns=["a", "a"])
df2 = pd.DataFrame([[3, 4]], columns=["a", "a"])
pd.cocat([df1,df2])

→ 成功。カラム構造が完全一致していれば重複があっても concat 可能。

Case 3: np.nan を含み、かつ列数不一致

df1 = pd.DataFrame([[1, 2]], columns=[np.nan, np.nan])
df2 = pd.DataFrame([[3, 4, 5]], columns=[np.nan, np.nan, np.nan])
pd.cocat([df1,df2])

→ エラー発生。InvalidIndexError

Case 4: カラム名が重複しており、列数も異なる

df1 = pd.DataFrame([[1, 2]], columns=["a", "a"])
df2 = pd.DataFrame([[3, 4, 5]], columns=["a", "a", "a"])
pd.cocat([df1,df2])

→ エラー発生。InvalidIndexError

Case 5: カラム長は同じだが、カラム名重複かつ順序が異なる

df1 = pd.DataFrame([[1, 2, 3]], columns=["a", "a", "b"])
df2 = pd.DataFrame([[4, 5, 6]], columns=["b", "a", "a"])
pd.cocat([df1,df2])

→ エラー発生。InvalidIndexError

検証のまとめ

ケース np.nanあり カラム重複あり カラム数一致 エラー発生 理由
Case 1 ✅(すべてnp.nan) 全行同じ構造・カラム一致のためOK
Case 2 構造が一致しているためOK
Case 3 一意でないnp.nanと異なる列構造
Case 4 同じ名前のカラムが複数・列数不一致
Case 5 カラム名は重複・かつ順番や対応関係が不明瞭

解決策

InvalidIndexError が出たら、 df.columns.duplicated()でカラムの重複を確認して重複対象に応じて名前を変更するなりすること。

削除するなら、

  1. df.columns.is_uniqueカラム名に重複がないかを確認 (Falseなら重複してる)。
  2. 重複しているカラム名を特定 df.columns.duplicated()
  3. 重複している対象 (今回はnp.nan) のカラムを除去 df.drop(labels=df.columns[df.columns.duplicated()], axis="columns")
# df = df.drop(labels=np.nan, axis="columns")
df = df.drop(labels=df.columns[df.columns.duplicated()], axis="columns", )

まとめ

  • pd.concat(..., ignore_index=True) で、結合対象の列構造が異なり、かつカラム名がユニークでないと InvalidIndexError が出る
  • カラム名が重複していても、df1, df2のカラム長と順序が一致していれば、順序にそって問題なくとかしてくれるしてくれる様子
  • np.nan がカラムでも問題ないが、他のデータ型と同様に重複していた場合はエラーが出る
  • 対策としては、重複したカラム (今回の場合はnp.nan ) の除去、カラム名変更など、ユニーク化でなんとかなる
  • InvalidIndexErrorが出た場合は (ignore_index でもだめなら)、カラム名の重複を検査すると良さそう。( .columnsis_unique をチェック+ .columns.duplicates )
  • (今回はDXの文脈で、馬鹿なフォーマットの不整合なexcelデータ (xlsx) をDataFrameにしようとしてエラーが出た。DXあるあるだと思う。)

実行環境

  • Ubuntu 24.04LTS: Linux 6.11.0-29-generic #29~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Jun 26 14:16:59 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
  • Python: 3.11.13 (main, Jul 2 2025, 10:00:40) [GCC 13.3.0]
  • pandas: 2.2.3
  • openpyxl: 3.1.5

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による結果の様子なので、分散の小ささや主効果に関する値なのかもしれません