右上↗

プログラミングに関するメモをのこしていきます

Zig Snippets: string to enum

Zig で []const u8 から enum に変換する

たぶん builtin にも存在しない気がする?のですが、逆 (enum → string) は @tagName で可能なので、 @typeInfoinline for をくみあわせて以下のように書くことができます。

const std = @import("std");

const E = enum { a, b };

fn strToE(s: []const u8) ?E {
    inline for (@typeInfo(E).Enum.fields) |f| {
        if (std.mem.eql(u8, f.name, s)) {
            return @intToEnum(@This(), f.value);
        }
    }
    return null;
}

inline for 便利。

Zig Patterns: Diagnostics

最近流行りの zig について、どうやら self-hosting compiler がデフォルトになったという話を聞いて興味を持ち、ちょっと実装を読んだりしながら遊んでみています。 いろいろと特徴的な言語なのですが、今回は error の話です。

前提: Zig の error handling

zig には error union という機構があります。詳細は 公式ドキュメント に譲るとして、ざっくり以下のように使います。

fn fallible() !i32 {
    return error.Failed;
}
// or
fn fallible2() error{Failed}!i32 {
    return error.Failed;
}
// or
const Error = error{Failed, AnotherError, YetAnotherError};
fn fallible2() Error!i32 {
    return error.Failed;
}

// caller
fn f() !void {
    const x: i32 = try fallible();
    std.debug.print("{}\n", .{x});
}

ぱっと見は Rust の Result<T, E> に似ているのですが、大きな違いとして、Zig の error は単なるタグ以上の情報を持てません。(リソースを開放するもの難しいし、union のサイズが膨らんだりするといろいろ面倒そうなので、仕方なさそう。)

エラーによっては別にタグだけで十分なのですが、もうちょっと詳細な情報がないとエラーとしては不親切になってしまうこともあるかと思います。 (たとえば JSON パーサなどを考えると「どこでエラーが発生したか」のような情報があるとうれしいはず)

Pattern: Diagnostics

そこで頻出するパターンが Diagnostics パターンです。 エラーを蓄積する object を作って、そのポインタを渡すことで、詳細なエラーレポートを可能にします。

const std = @import("std");

const Diagnostics = struct {
    allocator: std.mem.Allocator,
    messages: std.ArrayList([]const u8),

    fn init(allocator: std.mem.Allocator) @This() {
        return .{
            .allocator = allocator,
            .messages = std.ArrayList([]const u8).init(allocator),
        };
    }

    fn deinit(self: *@This()) void {
        for (self.messages.items) |msg|
            self.allocator.free(msg);
        self.messages.deinit();
    }

    fn emitf(self: *@This(), comptime fmt: []const u8, args: anytype) std.mem.Allocator.Error!void {
        const message = try std.fmt.allocPrint(self.allocator, fmt, args);
        try self.messages.append(message);
    }
};

fn doSomething(diag: ?*Diagnostics) !void {
    // Do something that may fail.
    const status: u32 = 1;
    if (status != 0) {
        if (diag) |d| {
            try d.emitf("non-zero status code: {}", .{status});
        }
        return error.Failed;
    }
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer if (gpa.deinit()) @panic("leak");
    var diag = Diagnostics.init(gpa.allocator());
    defer diag.deinit();

    doSomething(&diag) catch {
        var stderr = std.io.getStdErr().writer();
        for (diag.messages.items) |message| {
            try std.fmt.format(stderr, "{s}\n", .{message});
        }
        std.os.exit(1);
    };
}

パターンといっても、 「Diagnostics にどういった情報をもたせるか」はなんでもありで、アプリケーション・ライブラリ固有の設計が必要になってくるところです。 サンプルでは単に []const u8 (= string) をもたせるだけでしたが、たとえばパーサだったらエラーの位置情報やエラーレベルなんかがあると便利そうです。

とはいえ、ある程度いろいろ遊んでみてわかった Practice みたいなものはあるなと思ったので、紹介しておきます。

Practice 1: 詳細なエラーレポートはオプショナルなものとして扱う

特にライブラリ作者にとって重要なポイントですが、エラーレポートのコストを支払うかどうかは、そのコードのユーザに決定権を委ねます。 たとえば「JSON パーサに渡す JSON が静的なファイルで絶対に失敗しないことがわかっている」というようなケースでは、 Diagnostics を初期化する面倒さを省きたいはずです。 また、Zig の活躍しそうなシーンである組み込みなどを想定すると、デバッグ時以外は詳細なエラーレポートなんて不要だからその分リソースをあけてほしい、というケースもありそうです。

そこで、 Diagnostics は optional (nullable) で受け取るようにしておくとよいです。 上記のサンプルで diag: ?*Diagnostics と宣言していたのはこのためです。

エラーレポートをつくる側がやや煩雑になる (if (diag) |d| { ... }) のが気になりますが、仕方なしと割り切っています。 pointer + nullable で表現するかわりに、 Diagnostics の内部で「エラーを書き込むか」を決定するやりかたもあります。

const Diagnostics = struct {
    messages: ?*std.ArrayList([]const u8) = null,
    allocator: ?std.mem.Allocator = null,

    fn emitf(self: *@This(), comptime fmt: []const u8, args: anytype) !void {
        if (self.messages) |messages| {
            const message = try std.fmt.allocPrint(self.allocator.?, fmt, args);
            try messages.append(message);
        }
    }
}

fn doSomething(diag: Diagnostics) !void {
    //
}

pub fn main() void {
    // if you don't need diagnostics.
    doSomething(.{});
}

こちらの方式は、エラーレポートをつくる側がシンプルになるという点は良いのですが、「エラーレポートのためだけの処理」がある場合には無駄な処理が実行されてしまうのを避けられません。

var gpa: std.mem.Allocator = ...;
var diag: Diagnostics = ...;

if (fail) {
    // エラーメッセージを構築するのに "{s}" より複雑な処理が必要になるようなケースを想定
    // エラーメッセージが不要ならこの allocation も不要なはず
    const detail_string = try self.allocPrintDetail(gpa, detail);
    defer gpa.free(detail_string);
    try diag.emitf("failed. detail = {s}", .{detail_string});
}

結局こういう無駄を極限まで避けようとすると、 if (diag.enabled) { ... } のような分岐が必要になります。 とはいえ大体のケースでは Diagnostics の外で allocation が必要になったりもしない、ということであれば、こちらのやりかたを採用してたまに明示的に if (diag.enabled) とするのもありかもしれません。

個人的には今のところ、 ?*Diagnostics 形式のほうが素直で意図もわかりやすいかなという気がしています。

Practice 2: Diagnostics のリソースは Diagnostics に管理させる

ようするに Diagnostics に allocator を渡して、 diag.deinit() で全部開放できるようにしよう、という話です。

Zig を書いていると「このメモリは誰が Own していて開放の責任をもっているのか」を常に意識して設計する必要があります。 Diagnostics の内部に Diagnostics が管理していないリソースがあると、 deinit がめちゃくちゃ面倒になります。

場合によっては一切動的な allocation が必要ないエラーレポート (たとえば完全にコンパイル時に決定する静的な message だけを表示したいとか) をつくることもあるかと思いますが、そういったものも諦めて allocator.dupe(u8, static_message) してしまうくらいのほうが壊しにくいです。 そもそも Diagnostics を必要としているくらいなので、リソースをそこまで厳密に切り詰める必要もないだろうと考えて、コードのシンプルさに振ったほうがいいなと思いました。 (先に書いたとおり、Diagnostics 自体が optional になっているし)

まとめ

Zig を読み書きしていて面白いなとおもったので紹介でした。 ここまでリソースをしっかり自前で管理しないといけない言語はあまり触ってこなかったので、おもしろプラクティスがいっぱいありますね。 (C++ や Rust はそれなりに書いてきたつもりだけど、スマポでぽん、の範囲で大体完結していた。)

maturin でさくっと Rust 製 Python 拡張を書く

機械学習やデータ処理をやっていると、一部だけ Python では遅すぎるので C, C++, Rust のような高速な言語で処理を書きたくなることがまれによくあります。 C++ なら pybind11、Rust なら PyO3 が非常に有名で、これらをつかえばかなり簡単に Python <-> C++ / Rust を相互に行き来する処理を書くことができます。

と、ここまでは昔の知識で把握していたのですが、最近改めて必要に迫られて Rust で Python Extension を書こうとドキュメントを読んでいたところ、更に簡単にそういった処理を書けるようにする開発ツールである maturin なるものがリリースされているのを知りました。

セットアップ

Maturin User Guide がきちんとしているので、わざわざ使い方を書く必要もないのですが、

$ maturin new --bindings pyo3 foo
$ tree foo
.
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs

で新規プロジェクトを生成できます。 pyproject.toml は、以下のようになっており、 build-system が指定されているため、もうこの時点で pip install <name> すれば Python から使える状態になります。 (ref. PEP517, PEP518)

[build-system]
requires = ["maturin>=0.12,<0.13"]
build-backend = "maturin"

[project]
name = "foo"
requires-python = ">=3.6"
classifiers = [
    "Programming Language :: Rust",
    "Programming Language :: Python :: Implementation :: CPython",
    "Programming Language :: Python :: Implementation :: PyPy",
]

ちなみに Cargo.toml があるので cargo build したくなるのですが、それはできません。 (あんまり調べてないですが、Python 関連のシンボルが undefined になってビルドに失敗する。) 代わりに maturin develop でビルド + 現在アクティブになっている virtualenv へのインストールを行います。 (maturin develop --release でリリースビルドができる。)

Rust コードだけでなく Python wrapper も同時に開発するようなケースでは、 pip install -e foo をつかうほうが便利かもしれません。 Python wrapper 側の変更だけならビルドしなおす必要がなくなり、即座に反映されるようになります。 (Rust 側をいじったときはビルドしなおす必要がある。)

配布

まだ自分で使っている範囲では PyPI に publish することはなく、プロジェクト内ローカルなパッケージとして使用しているだけなので、基本的には上記のようなコマンド群だけで開発が完結しているのですが、maturin では配布のことも考えたユーティリティも用意されています。

maturin build で配布用の wheel を生成できるのですが、これがまた便利そうで、システムに存在しているすべての Python バージョンに対して wheel を生成してくれます。 たとえば、仮に現在アクティブな virtualenv が Python 3.10 系だとしても、Python 3.9 系がインストールされているシステム上で maturin build をすると、3.9, 3.10 用の wheel をそれぞれビルドしてくれるのです。

さらに、もろもろセットアップが必要ですが、クロスコンパイルのための仕組みも充実しており、 maturin build --universal2macOS 用の universal binary (x86_64, arm 両対応)を出力したり、 maturin build --target=... でクロスコンパイルしたりできます。

さらにさらに Python の Native Package のお作法である manylinux 対応(特定の動的ライブラリとしかリンクしないことで、いろんな Linux 環境でうごくことを保証する) もビルドオプションで解決可能です。 (maturin build --release --target aarch64-unknown-linux-gnu --zig。 ここで zig がでてくるのにちょっと感動しました。)

Docker イメージGitHub Actions もすでにコミュニティによってサポートされており、CI 上でも簡単に build & publish ができそうです。

まとめ

Python だと遅いけど C++ や Rust でかいてブリッジするのも面倒だし、そこまでやるのは大袈裟かなーとかおもって結局 Python のままいったり Cython, numba でいったりする、という経験をいままで何度かしてきました。 が、maturin が想像以上に体験が良く、開発効率もよかったので、今後はより気軽に Python プロジェクトに Rust をつっこんでいくスタイルが通用しそうだなと思いました。

また、Rust は効率追いもとめたいときには本当に優秀なツールだと改めて実感しました。PyO3 もめちゃくちゃ良くできていて、混み入ったアルゴリズムだったら Rust で書いたほうが、開発効率的にも好ましいんじゃないかと思います。

ISUCON11予選に「都営三田線」で参加して予選2位で本戦出場きまりました!!

ISUCON11 予選お疲れ様でした! 「都営三田線東急目黒線直通急行日吉行」というチームで参加し、予選 2 位で通過することができました。 チーム編成は 5 年連続同じメンバーで、0gajunizumin の 3 人チームでした。

記念スクショ

ISUCON11 Portal

ISUCON11 オンライン予選 予選結果と本選出場者決定のお知らせ

ISUCON7 以来の本戦出場で、またこのメンバーで本戦出場をきめられたこともうれしいですし、予選 2 位 / 100 万点越えを達成できたのも本当にうれしいです!

やったこと

言語は Go で、middleware まわりはとくに変なことはせず nginx + MariaDB のまま設定だけいじってすすめました。 例年は Redis やオンメモリで大胆なキャッシュを試みることが多かったのですが、今年は大人な戦い方で堅実にスコアを伸ばせたのでとても満足しています。

正確に記録をとれていない面もあるのですが、箇条書きでやったことを列挙してみます。 作業 repository も公開する予定です。

  • 初期スコア
    • 1803
  • 10:38 いつものように実装だけみて足りない index をはる
    • 17124
    • 大体いつも、書き込みがボトルネックになったら見直す、という前提で雑につっこむ
  • 11:10 postIsuCondition の INSERT をまとめる
    • ほぼ変わらず
  • 11:20 getTrend の N+1 をつぶす
    • 18614
  • 11:40 無駄な SELECT * を消す
    • 19240
  • 11:58 keepalive と worker connection 追加
    • 20028
  • 12:33 静的ファイルを nginx から配信する
    • 19300
  • 13:07 各いすの最新コンディションを別テーブルに保存
    • 19936
  • 13:12 index.html に cache-control
    • 25392
  • 13:19 dropProbability を 0.9 → 0.7 に
    • 13084
    • 捌ききれなかったが、↓ の複数台構成とセットで伸びたのでヨシとした
  • 13:28 DB を別サーバーに出して 2 台構成に
    • 31258
  • 13:33 echo の余計なアクセスログを消した
    • 37395
  • 14:00 リクエスdrop 時のログを消した
    • 40120
    • このあたりまではアプリケーションの CPU が限界だったので、ログをきるだけで大きく伸びた
  • 14:11 condition_level を事前計算し、それをつかって filter する
    • 83782
  • ??:?? このあたりで dropProbability を 0.7 → 0.4 に
    • 94000 くらい
  • 14:45 グラフ生成時の SELECT を適当に 25 時間分にしぼる
    • 114958
  • 15:03 25 時間分にしぼったつもりが、start 側しかしぼれていなかったので修正
    • 143372
  • 15:11 /api/trend にほぼ同時に来たリクエストを束ねて計算を省略(singleflight)
    • 161966
    • 50ms スリープし、その間にきたリクエストに対して 1 回だけ trend を計算するようにした。
  • 16:06 DB1 台、App2 台構成にした
    • 198583
  • 16:16 書き込みが限界だったので、DB を 2 台にして最新コンディションのテーブルへの書きこみを分離 (DB, DB + APP, APP 構成)
    • 214492
  • ??:?? dropProbability や Load Balancing の調整、middleware の設定をなんやかんやした
    • 30 万くらい
    • ここからひたすら書き込み負荷を減らす努力を開始
  • 18:12 postIsuCondition の書き込みを非同期 & Batch 化
    • 372101
    • fail にびびって 10ms 以内の書き込みしかまとめないようにしていたが、もっと攻めたら書き込み負荷をより軽減できたかもしれない
  • 18:15 isu_condition の timestamp sort をアプリでやることで index を消す
    • 400792
    • index をひとつ消すだけで 30,000 も伸びた。DB が完全に限界にきていて、これを緩和すればするほどスコアが伸びる状態。
  • 18:19 slow query log などを disable
    • 593482
    • !?
  • 18:30 interpolateParams=true
    • 1045520
    • !??!??

すすめかた

インフラ的なパラメータ調整などは完全に 0gajun にまかせていたのですが、下回りでつまることが一度もなかったので感動しました。最終的につかわなかったのですが Redis の用意までしてもらったり、デプロイやプロファイルまわりの整備もやってもらい、アプリの改善が 120%活きる状態をつくってくれました。 例年は izumin の実装速度にものをいわせた飛び道具を仕込む戦いをしていたのですが、今回は大人にたたかおうということで堅実なアプローチを採用するよう心掛けていました。そういう堅実な手を数多くためすことができたのは、やはり izumin の手によるところが大きいです。Go への慣れという面でも頼りになりました。 このチームでまた本戦に出場できてよかったです。

手をつける場所の選定やコストのかけかたが今回はかなりうまくいった気がします。 グラフのところなんかは誰も最後まで仕様を正確には理解していなかったのですが、「ぱっと見いけそうだから雑に 25h でしぼってみる、だめだったらちゃんと理解してからやりかたを考えよう」で突撃したのがうまくはまったりしていました。25h なのは、仕様を理解していないので保守的にとってみたことの表れです。

マニュアルに書き込みを遅延してもよい、というような内容が記載されていたので、そこがキモかな、とおもっていたのですがどうだったんでしょう? 僕たちのチームは最終的には書き込みの負荷をいかに減らすか、というところが壁でした。 正直終盤の伸びは、自分でもなんでこんなに伸びたのかよくわかっていなかったりしますが、割とぎりぎりまでチューニングされていたからちょっと負荷がへるだけで大幅な伸びにつながった、と解釈することにしました。

まとめ

最近の ISUCON ではいつもアグレッシブな最適化をした結果、謎 fail が発生するようになる、というパターンで苦しんでいたのですが、今回は fail に苦しまず最後まで改善をつづけることができました。 堅実に改善していったものがハマって最後に爆上げする、というパターンははじめて経験したのですが、最高に楽しいですね!

運営のみなさま、ありがとうございました!問題もわかりやすく、ボリュームもちょうどよくて、問いていてとても気持ちよかったです! 本戦もがんばります!

おまけ

go1.17 から(フラグで有効にすれば部分的に) Generics がつかえるようになったので、実は前日まで Generics つかいたおしてみようか、という話をしていました。 意外とやりたいシーンがなく、 結局お蔵になったのですが、izumin が Go Generics で snippet を用意してくれていたので、せっかくだし載せておきます。 (「やりたいシーン 4 回くらいあったぞ!!!!! by izumin」 とのことです。)

izumin5210-sandbox/go-generics

pgjdbc の SQLException.getErrorCode() は常に 0 を返す

最近になって仕事で初めて JVM 上で動く言語を書いています。 そこでちょっとハマったことがあり、色々調べて解決したのでメモがてら共有しようと思い、記事にすることにしました。 もっと遭遇している人多そうなんですが、パッとググった限り日本語でこの問題について言及している記事が見当たらなかったので、有用だと信じています。(経験ある JDBC ユーザにとっては当たり前なんですかね?)

TL;DR

https://github.com/pgjdbc/pgjdbc を使っていると、 java.sql.SQLException.getErrorCode() が常に 0 になる。 代わりに java.sql.SQLException.getSQLState() を使おう。

問題

UNIQUE 制約をはったテーブルに対して「レコードがすでにあればそれを SELECT する。なければ新規に作る。」という操作をしたいことがあります。 そういったときに、文字通り「1. まず SELECT し、 2. なかったら INSERT」というふうに実装してしまうと、同時に二つ以上のリクエストが処理されると想定通りの挙動になりません。(1. と 2. の間に別のリクエストによって 2. が実行されるかもしれない。) そこで、こういったケースでは「1. まず INSERT を試み、 2. UNIQUE 制約に引っ掛かったら SELECT」というふうに実装するのが正しいです。(Rails 的にいうと find_or_create_by ではなく、 create_or_find_by しよう、という話です。)

val record = try {
    Users.insertAndGet(...)
} catch (ex: SQLException) {
    if ( /* ex が UNIQUE VIOLATION である */ ) {
        Users.select(...)
    } else {
        throw ex
    }
}

こんな感じです。(疑似コードですが)

さて、ここで /* ex が UNIQUE VIOLATION である */ ことの確認をする方法が必要です。 java.sql.SQLException のドキュメント を見てみると、int getErrorCode() という API があるので、これを使ってみます。vendor-specific exception code が取得できる、と説明があります。 PostgreSQL のエラーコード一覧 によると、23505 が unique_violation らしいので、以下のようなコードで判定できるように見えます。

const val UNIQUE_VIOLATION = 23505
if (ex.errorCode == UNIQUE_VIOLATION) {
    ...
}

動かない!

これでテスト書いてうまく動くことを確かめよう〜と思ったら、なぜか全然テストが通らない! しかも SQLException が投げられていて、その内容が完全に UNIQUE 制約に引っ掛かっているというエラーでした。 何事...

デバッガで追ってみると、 ex.errorCode == UNIQUE_VIOLATION が false になっているようです。エラーは確かに unique_violation だというのに。

さらにデバッガでよく見てみると、なんと ex.errorCode が 0 になっています。 何事...

pgjdbc は getErrorCode に対応していなかった

PSQLException の実装 をよく見てみると、そもそも getErrorCode の定義がありません。どうやら常にデフォルトの 0 を返すようです。

https://github.com/pgjdbc/pgjdbc/pull/623 で対応が試みられていますが Close されています。 PostgreSQL のエラーコードはアルファベットを含むものもあり、全てを統一的に getErrorCode で返すことができないというのが理由でした。 言われてみれば当たり前だし、確かによくみると PostgreSQL のエラーコード一覧 にはちらほらアルファベットがありますね...

対応

String SQLException.getSQLState() を使います。 さらに PSQLState という enum が定義されているので、これを併用して

if (ex.sqlState == PSQLState.UNIQUE\_VIOLATION.state) {
    ...
}

とすれば期待通りの挙動になります。

まとめ

しかしこの挙動、もうちょっとドキュメントとかに書いてくれていてもいいんじゃないかなぁと思ったんですがどうなんでしょう。 PSQLException のドキュメントを見ても何も書いていないんですよね。 PostgreSQL のドキュメントをちゃんと見れば Error Code が int で表現しきれないことは自明だ、と言われればそれはそうなんですが。

pgjdbc の話からは逸れますが、こういう「統一インターフェースを標準で提供するから内部実装は各自ライブラリでやって差し替える」系のライブラリ、統一インターフェースの方のドキュメントばかり読んでしまって個々のライブラリのドキュメントをあまり読まなくても使えてしまうので、こういう罠があるとハマりますね。よくできているということでもあると思います。 (まぁ今回は個々のライブラリのドキュメントを見ても何も書いていないと思うのですが...)

pgjdbc には別の問題でもハマっていて、そっちは PR 出して無事マージ & リリースされたので、いつかその問題についても書いてみようと思います。

RecSys Challenge 2020 に "Team Wantedly" で参加し3位に入賞しました!

Wantedly の同僚二名とチームを組んで、国際学会 RecSys 2020 の併設コンペである RecSys Challenge 2020 に参加しました。 最終的に 賞金圏内である 3 位 を獲得し、RecSys 併設 Workshop への論文投稿と口頭発表をすることも決定しました!!!

RecSys は推薦システムに関するトップカンファレンスのひとつで、業務でも取り組んでいた "推薦" というトピックについて、業務外でも通用するアウトプットを出せたのはとても嬉しいです! チームメイトがひっぱってくれたのでとても感謝しています 🙏

くわしくはチームメイトが会社のブログに書いてくれているので、ぜひそちらをご覧ください!!

「Team Wantedly」 が RecSys Challenge 2020 で3位に入賞しました

atmaCup #5 に参加してきて Private 29 位(Public 27 位)でした!

atmaCup #5 に参加してきました! atma.connpass.com

Kaggle 以外のコンペに参加したのは初めてだったのですが、お祭り感があってとても楽しかったです! 参加者・運営全体で盛り上げていく雰囲気があったので、初参加でしたが最後までモチベーション高く取り組み続けることができました。 Twitter TL でよく見かける方々と競えるので燃えました。

運営のみなさま、本当にありがとうございました!ぜひまた参加したいです!

問題設定

2 値分類タスクで、評価指標は PR-AUC でした。 正例が少なく、指標も PR-AUC だったので、CV / LB が安定しなかったのが悩ましいところでした。

コンペ中の動き

1 週間の開催で短期決戦だったので、ドメイン知識の獲得から始める余裕はないと判断して、CNN 様に抽出していただく戦略を取りました。(結果的にそこそこ NN チューニングに時間を使ったので、ドメイン知識をちゃんと学びに行けばよかったとやや後悔しています) また、短期決戦前提の書き殴り実験をしまくってしまったので、自分が何をやっていたのか正確な記録が無く終盤若干混乱しました。1 週間だったのでギリギリなんとかなりましたが、Kaggle みたいな長期戦は厳しそうなので、そっちに挑む際はもうちょっと丁寧に生きた方が良さそう。仮に入賞したとしても再現できるようにするコストがものすごく高い実装になってしまっていました...

実験管理を mlflow でやったのですが、結構よかったです。 最終日はローカルを投げ捨てて GPU 使い始めたので、今までローカルに積み上げてきた実験との比較が若干厄介でした。(統合せず、別の mlflow ui をタブで開いて眺めていました...) ただ、CV 戦略を変えたり評価指標をいじったときに、同条件で単純比較できない実験にもかかわらずそれが同じテーブルに並んでしまうのが悩ましいなと思いました。 時系列で並んでいるうちは良いのですが、指標ごとにソートすると本当にどれが信じられる結果なのかわかり辛くなってしまいました。 このあたりはタグなどを活用すると良いのかもしれない?もしくは CV などの設計が変わった時点できちんと experiment のレベルで分離すべきだった気がします。

また、実は事前にちょこっとだけ準備していて、「学習データ、テストデータ、CV、モデル、評価関数」あたりを渡すと mlflow にログを書きつつ CV 評価して oof と test の prediction をはく薄い wrapper を書いていました。 最初はまぁ使えていたんですが、NN のことを全く想定していなかったので NN に移った時点で全く使えなくなってしまったのと、細かいことをやりだすとやっぱり wrap された内部に手を入れたくなってしまって厳しかったです。 予想はしていたので相当薄めに作ったつもりだったのですが、それでもこうなっちゃうか〜という感じでちょっと辛い。 ライブラリとしての作りにしたのが間違いだったのかもと思っています。ライブラリだと内部にコンペ固有の変更を入れるのは厳しいので。 単なるスニペット集にしておいて、コンペ中はゴリゴリ内部も書き換える前提で使った方が良さそう。

やったこと

以下は Discussion にも投稿した内容とほぼ同じですが、こちらにも書いておきます。

ぐるぐる

中盤まで CV スコアすら安定せず、ちょっとシードを変えるだけで大きくスコアが変動してしまっていました。 そのため、正直何が効いていて何が効かなかったのかの判断を誤っていた可能性が高いです...

何をやっても安定しなかったので、最終盤にやけになってアンサンブルで誤魔化す作戦に出たのですが、これは結果的によかったと思います。 特に、CV がある程度安定して比較可能になったおかげで、打つべき手の選択を見誤り辛くなったのが大きかったです。 逆にいえばもっと早くこれをやっておけば、もう少し良いモデルを作れたかも?と反省しています。次回に活かしたいです。

最終サブは CNN と、それをベースに stacking した LightGBM の 2 つを出していました。

CNN

  • 正規化 / Scaling を全くせずナイーブに Conv1D ベースの NN に突っ込む
    • ((Skip Connection + Conv1D (kernel_size=5) → BatchNorm (or GroupNorm) → ReLU) * 2 → AveragePooling1D (pool_size=2, strides=2)) * 3 → GlobalAveragePooling と GlobalMaxPooling の concat
  • テーブル特徴量は MLP を通して CNN の出力に Concatenate した
    • 採用した特徴量は beta, rms, params1 ~ 6, tsfresh の特徴量たちの中から LightGBM での feature importance が上位 100 以内だったものを取ってきただけ
  • テーブル特徴量を MLP に通したものを CNN の途中でに足したらなぜかスコアが上がったので採用
    • 本当はテーブル特徴量を使って、どの領域に注目するかの Attention の計算をしようと思っていたが、そちらはスコアが上がらず...
    • 悔しいので惰性で試した加算がなぜか効いた
  • 始めは BN を使っていたが安定しなかった。極端に大きい入力が入ってきたときに BN の statistics がぶっ飛ぶのが悪いのでは、とあたりをつけて GN に変更したところ、伸びはしなかったが安定性が増したように見えたので採用。
  • Optimizer は AdamW を使っていたが安定しなかったので、SWA / Lookahead を試したところどちらも安定性の向上を確認できた。最終性能はほぼ変わらなかったが、Lookahead の方が収束が速かったのと個人的に好きだったので採用。
    • Lookahead 採用後は BN でも安定したので、最終的には BN / GN 両方のモデルを作って rank average した。
    • & CosineDecay with linear warmup
    • val prauc で early stopping
  • 学習中に checkpoint をとっておき、val loss がよかった 5 epoch 分のモデルの出力の平均を使用
  • class weight つき binary crossentropy loss
  • CV 戦略は StratifiedKFold(k=5)
    • タスク的には StratifiedKFold じゃまずそうと思いつつも、Group, StratifiedGroup は CV / LB の相関が取れなかったのと、seed を変えた時の暴れ方がすごくて断念

LightGBM

こちらは最終日にもう何も思い付かず、とはいえサブを余らせて終わるのも悔しかったので、やってみるかぁという惰性で挑戦したものでした。 CV は Stacking した LightGBM の方がかなり高かったので興奮したのですが、流石に怖かったので CNN も提出していました。 結論としてはどちらも Public / Private 共にほぼ差はなかったです。

時間がなかったのであまり検証はできず、勘で良さそうな構成を選ぶしかなかったのですが、最終的に採用したのは以下のような構成です。

  • CNN (BN, GN) の出力を rank にしたもの + テーブル特徴量全部盛り
  • optuna の LightGBMCVTuner でハイパラ選択
  • 2 Seed Average (Rank Average)
  • imbalance 対策は undersampling + bagging
    • 1 : 10 になるように undersampling したデータで普通に 20 モデル作り、単純に平均をとった
    • 1:1 にしたり is_unbalance=True にしたりいくつか実験しましたが、これがもっとも CV が高かったです

うまくいかなかったこと

  • Attention
    • Transformer ベースのモデルと、Conv ベースモデルに MultiHeadAttention を足したものの両方を試しましたが、どちらも work せず
  • Squeeze and Excitation
  • SeparableConv1D にして層を増やす
  • Kernel Size を増やす
  • NN で Undersampling + Bagging
  • 生データの scaling
    • Standardize や 99.9%ile で clip した方が学習は安定したが、スコアがものすごく下がった...
  • (Denoising) Variational Auto Encoder
    • train/test で chip が違うことがわかっていたので、教師なし事前学習 → finetune したら LB 上がるのでは、という目論見
    • Reconstruction Error と Encoder の出力 64 次元ベクトルを LightGBM に食わせるのも試したが work せず
  • 微分して入力チャネルに追加して CNN に通す