右上➚

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

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 に通す

"Applying Deep Learning To Airbnb Search" を読んだ

[1810.09591] Applying Deep Learning To Airbnb Search を読んだときのメモをそのまま出してみます。面白かった。 本当にメモなので、詳細は原文を読んでください。

airbnb の search ranking に deep learning を導入していく過程を論文っぽくしたもの。

おしゃれなモデリング手法の提案とかじゃなくて、現実の問題に対して NN を適用していくにあたって発見した良かったこと・悪かったことについてまとめた文章になっている。

Motivation

もともと Gradient Boosted Decision Tree でやっていて結構うまく行っていたが、gain が停滞してきたのでそれの突破口を探していた。

Model Evolution

評価指標は NDCG (Normalized Discounted Cumulative Gain) を使っている。

f:id:agtn:20181121214034p:plain

"Convolutional Neural Networks for Visual Recognition" を書いた A. Kaypathy が "don't be a hero" と言っている(複雑なモデルを扱えると思わないほうがいいよ、みたいな意味?)が、"Why can't we be heroes?" と言いながら複雑なモデルに爆進したらしい

その結果、無限に時間を持っていかれて全然うまくいかなかった。

結局、最初に production に入ったモデルはめちゃくちゃシンプルな NN だった。

a simple single hidden layer NN with 32 fully connected ReLU activations that proved booking neutral against the GBDT model.

入力や目的関数も GBDT と全く同じにしている。(booking するかしないかの L2 regression loss)

ものすごく gain があったわけではないけれど、NN が production でうごく、live traffic をちゃんとさばけるという pipeline を整えるためにこの step 自体は良かったと言っている。

やりすぎないことで先に進むことはできたが、すごくよくもなっていなかった。つぎの breakthrough は LambdaRank + NN を組み合わせたことだった。LambdaRank は Learning to Rank のアルゴリズムで、簡単にいえばロス関数に直接評価指標(ここでは NDCG)を組み込める(ここまでは learning to rank 的なアプローチはまったく取っていなかった。単にクリック率をよく予測し、それを上から出すことで NDCG を最適化していた)。

これらをやりながらも Factorization Machine と GBDT は research を続けていて、NN と comparable な成果が出せることはわかっていた。comparable な成果が出ている一方で出力される list は全然別物だったので、組み合わせたらもっと良くなるのではということで、FM や GBDT の結果を特徴量に含む NN を学習させて利用することにした。

f:id:agtn:20181121214010p:plain

この時点でもうモデルの複雑さは結構なものになっていて、機械学習の技術的負債問題が顔をだしつつあった。そこで、 ensamble などをすべて捨てて、単に DNN を大量のデータ(いままでの 10x)で学習させるというシンプルな解法に舵を切った(DNN とはいえ 2 hidden layers)。入力の次元は 195 次元、1st hidden layer = 127, 2nd = 83 というモデル。入力の特徴量はシンプルなもので、価格、アメニティ、過去の booking 数、などなど。ほぼ feature engineering をせずに入力した(これが DNN にする目的だった)。

Failed Models

失敗についても言及してくれていてすごく嬉しい。

  • ID を使ったモデルは過学習がひどくて使えなかった
    • 扱っているアイテムの都合上、ある ID に対してたくさんのデータが取れることがない
      • どれだけ人気でも年間 365 までしかコンバージョンしない
  • 詳細ページへの遷移は booking よりはるかに多いし dense なので、それを使うモデルも作ったがうまくいかなかった
    • multi task 学習で、booking prediction と long view prediction を予測させた

あとは NN は特徴量を normalize したほうがいいよねとか、特徴量の distribution を観察しましょうとか、Java 上で TensorFlow のモデル動かすの大変でしたとか、いろいろ現実っぽい話が並ぶ。 もっといろいろ言ってるけど、詳細は本文を読んだほうが良い。

画像とかじゃない領域で DNN を production にいれるにはこういうステップを経るんだなというのが伝わってくるし、この話から得るべき学びがかなりある。光景が目に浮かぶ良い文章だなと思いました。

golang でテストのために時間を操作するライブラリ timejump

現在時刻に依存するコードをテストするとき,golangtime.Now を普通に使っているとモックできずうまくテストが書けないという問題があります. 時間の操作は time パッケージをそのまま使えば良いのですが,time.Now だけはモックできるようにしたいところです.

解決方法としては,グローバル変数var NowFunc func() time.Time を置いておいて,テスト時に入れ替えるという方法があり,ORM である gorm などが実際にこれを行っています.

gorm/utils.go at 2a1463811ee1dc85d168fd639a2d4251d030e6e5 · jinzhu/gorm · GitHub

例:

var NowFunc = time.Now

func Do() string {
    return NowFunc().String()
}

func TestDo(t *testing.T) {
    now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
    NowFunc = func() time.Time {
        return now
    }
    got := Do()
    if got != now.String() {
        t.Fail()
    }
}

golang の使用上,Ruby の timecop のようなことは出来ないので,こういう工夫をするしかありません.

なんとなくグローバル変数をテストのために置いて書き換えるのが嫌なのと,なんにも考えずに t.Parallel() を置けなくなるのがちょっと嫌だなと思っていました. また,時間経過をシミュレーションしたい場合は,そういう NowFunc を毎回書く必要があり,結構面倒です. あとパッケージをまたぐと厄介だし毎回書くのも嫌.

そこで,Ruby の timecop のように現在時刻をいじくり回せるようにするライブラリを作ってみました.

github.com

godoc.org

使用する際は time.Now をすべて timejump.Now に置き換える必要があります.( Ruby と違って time.Now を直接上書きできないので...) 普段は timejump.Nowtime.Nowif !active { ... } が一段挟まるだけなのでパフォーマンスに影響はほとんどないはずです.

テスト時は,timejump.Now の挙動を変えたいテストの頭で

func TestDo(t *testing.T) {
    timejump.Activate()
    defer timejump.Deactivate()
    ...
}

とします.

timejump.Activate区間はロックをとっているので,テストを並列で走らせても並列に走らなくなります.

timejump.Stop() で時間停止,timejump.Jump で時間移動,timejump.Moveタイムゾーンの移動,timejump.Scale で時間の経過速度をいじれます.

時間を止めたいだけの場合はグローバル変数NowFunc を持っておいて t.Parallel を間違って置かないように気をつけるほうが正直楽だとは思いますが,時間経過をテストしたい場合にはちょっと楽になるはずです.

もともとあるパッケージのテストをするために書いたパッケージだったのですが,目的だったテストを書く前にテストしたいパッケージが御役御免になってしまったので,timejump も御役御免になってしまいました. いつか使う日が来る気がするので,ここに寝かせておきます.

ISUCON 7 本戦出場してきました 「都営三田線東急目黒線直通急行日吉行」

ISUCON 7 お疲れ様でした!

僕らのチーム,「都営三田線東急目黒線直通急行日吉行」は学生枠 2 位,全体で 10 位という結果でした.

予選の結果が異常によかったので完全に調子に乗っていたんですが,本戦はやっぱり難しかったですね... 本戦でも社会人上位勢と戦えるくらいのスコアを出すのと学生枠優勝が目標だったのでやっぱりくやしい.

やったこと

結局テンパりすぎてスコア記録を残せていないので,なんとなく記憶を頼りに...

Go 実装でいきました.

  • room 名から ws をつなぎに行くホストが一台に固定されるように
    • 同じルームの action は全部同じホストにいくように
    • これでオンメモリに出来るようにした(結局ほとんどオンメモリにデータは持たなかった)
  • ルーム名から適当なハッシュ値を計算してふるようにしていたけれど,ルームによってアクセス頻度とかがちがうっぽい?ことに気がつく
    • 3 台に振ってるはずなのに,CPU 使用率を見ると 2 台しか使われていないとか
    • 負荷分散が運ゲーになっていて改善の結果が見にくかったので,とりあえず 1 台しか使わないように
  • ホストごとの接続数を redis に入れて,空いていそうなところに振り分けるように
    • 前段のサーバに nginx + app + redis を入れて,そこの redis に room -> host の対応情報を入れた
    • CPU 使用率で振り分けたほうが良かったかもしれない
  • schedule の計算過程を最適化出来る気がしなかったので,結果をキャッシュしようと試みる
    • どうやっても事後検証をパスできなかったので捨てることに...
  • 部屋ごとにロックをとってすべての操作を直列にした
    • トランザクションとかが複雑すぎていじれる気がしなかったので,一旦直列にして考えることを減らそうとした
    • rollback 考えなくて良くなったのでこれ自体は良かった気がする
  • @izumin5210 が adding/buying を redis にいれたり,過去の adding をまとめてくれた
    • ここは完全におまかせしてしまった
    • 最終的にまともに効いたのはこれだけだったのでは
  • 終盤はベンチマーカに弾かれる原因をひたすら探していた
    • 安全側に倒そうということで,色んな所でひたすらロックを取るようにしてなんとかパスした

という感じで,最高スコアが 17000 くらい,最終スコアが 16700 で終了しました.

反省

  • チーム名が長すぎた
    • 名札のチーム名部分のフォントが他のチームより小さかった気がする(ご迷惑おかけしていたらすみません...)
    • 手書きで書くのがつらすぎる
    • 来年は気をつけます
  • テストがせっかく用意されていたのにまったく使わなかった
    • テスト使っていたらもうちょっと計算過程の最適化にも手を出せたかもしれない
  • なにも操作がなくても 500ms ごとに status を計算していたところの最適化を入れきることができなかった
    • この計算は room ごとに一回やれば良いはずなのにコネクション一個につき一回計算していた
    • room ごとにひたすら status を計算し続ける goroutine を起動してそこから返す実装を書いていたが,終盤の fail 祭りにびびっていれられなかった...
    • 意味があったかどうかはよくわからない.
  • CPU profile をみて,bigint の計算がやばいことはわかっていたのに,手が出せなかった
    • この複雑さは結果をキャッシュしろってことだな!って勝手に思い込んでいたけど,結果のキャッシュも複雑で無理だった
    • 他のチームの方の話を聞く限り,そんなに無茶な最適化じゃなくても地道に最適化していたらそれなりにスコアに効いたのかもと思った(これはやってみないとわからないけど)

予選のときも練習のときも,「遅いのはわかっているけど複雑そうでやりたくない」部分を触らないとだめっていう教訓は得ていたはずなんだけど,結局そこでやられてしまったのが一番くやしいですね...

来年は学生枠ではなくなるのですが,社会人枠でも本戦にでて勝ちたいです!

去年に引き続き本戦に出られたのはほんとうに良かったし,またまた勉強になる良い経験でした!運営の皆様お疲れ様でした & ありがとうございました!

ISUCON7予選1日目に「都営三田線」で参加して通過できた話

ISUCON7 予選お疲れ様でした! タイトルどおりですが,「都営三田線東急目黒線直通急行日吉行」という学生チームで参加し,1日目3位枠で通過することができました. チーム編成は,去年2人チームで参加したときの相方である 0gajun と,僕の内定先の同期の izumin の 3 人チームでした.

何をやったかとかスコアの変遷は別で記録したいと思います(僕一人で把握しきれていないので)が,とりあえずリポジトリはここです.

github.com

追記: タイムラインと詳細はこっちに書きました

www.wantedly.com

f:id:agtn:20171023153909p:plain

去年2人で参加した時は学生枠で予選通過はできたものの,一般枠との圧倒的なスコア差にだいぶ打ちひしがれていました. もともと,「0gajun がインフラ,僕がインフラ寄りのアプリ」というチームだったので,アプリ側をがつがついじれる izumin が加わったことで,チームバランスが良くなった + 手数が圧倒的に増えたと思います.

去年とくらべてメンバーも増えたし学生とはいえ一年間で成長している(はず)というのもあったので,夢として「学生枠だけど一般枠と戦えるスコアを出したい」というのがありました. 結果,予選1日目上位3チーム枠で本戦出場を決められて本当にうれしかったです.2日目の上位が圧倒的なスコアだったのでちょっと凹みましたが,全体でも 10 位くらいだったと思うので,目標は達成できたと思います. また,無理やり ISUCON 特化な高速化を入れたりすることなく良いスコアを出せたと思うので,そこも良かったなと思っています. 本戦でも良いスコアが出せるように頑張るぞー!

最後に,ISUCON 運営の皆様,本当にお疲れ様でした & ありがとうございました! 予選から複数台構成で面食らいましたが,複数台は難しい分,楽しさも増すし勉強になることもたくさんありました! 本戦も楽しみにしています!

go generate する時のバイナリのバージョンを固定したい

https://github.com/golang/mockmockgen というコマンドを提供しています. これは,interface から mock を自動生成するコマンドで go generate と合わせて使うと interface に追従する mock がとても簡単に作れます.

他にも yacc とかリソースをバイナリに埋め込むとか,色々便利ツールがあり,go generate でコード生成をするのは golang のアプリケーションではよくあることだと思います.

しかし,golangvendor/ の仕組みは基本的に package として使うことを考えて作られているので,プロジェクトごとに mockgen などの生成コマンドのバージョンを固定するためには使えません.

ここで,go generate で使うバイナリのバージョンが固定されていないと起こりうる問題として

  • 生成されたコードに毎回 diff が出る
    • 気軽に git add . 出来ないし,コミット漏れや無駄コミットにつながる
  • バージョンAのコマンドとバージョンBのコマンドによって生成されたコードが混ざる
  • ライブラリのバージョンと生成コマンドのバージョンが一致しないためバグる
    • github.com/golang/mock/mockgengithub.com/golang/mock/gomock というライブラリとセットで使うので,gomock package と mockgen バイナリのバージョンは揃えたい

などがあります.

これ結構嫌な問題だと思ったのですが,パッとぐぐってみてもあまり困っている声を聞かないので普通どうやって解決しているのか気になっています. (もしかして僕が知らないだけで普通に解決されている問題だったりするのだろうか…)

とりあえず vendor 以下の package をビルドしたり固定されたバージョンのバイナリをぱぱっと実行するために

github.com

を作ってみました.

shell script で書けそうな単純な仕事しかしていませんが,go で実装されています.

bindor build github.com/golang/mock/mockgen./.bindor/mockgen というバイナリが出来ます. bindor exec command args... とやると PATH=./.bindor:$PATH した環境下で command args... を実行します.

$ glide get github.com/golang/mock
$ bindor build github.com/golang/mock/mockgen
$ bindor exec which mockgen
/path/to/current/.bindor/mockgen

という感じです.

//go:generate bindor mockgen としてもいいですが,bindor exec go generate とすればソースコードを書き換えなくても .bindor 以下のバイナリを使うようになるはずです.

bindor 自体にバージョンを固定する仕組みは入れていません.glide とかがやってくれている仕事を分散させても管理が面倒になるだけでメリットがなさそうだし,ライブラリとしても使う package の場合はどうせ glide で管理するので,vendor 以下のディレクトリの奪い合いになってしまいます.

というわけでバイナリを vendoring する bindor を作った話でした.もっといい解決方法があったら教えてください.

Rust で Unix のシグナルを channel 経由でキャッチする

Rust でシグナルハンドリングをする必要があったのですが,あまり自分の用途にあるライブラリがなかったので作りました. 僕が Windows のことをほとんどわからないので,Windows 未対応です.

github.com

docs.rs

https://crates.io/crates/signal-notify

golangsignal.Notify に寄せた API になっていて,標準ライブラリの std::sync::mpsc::{Sender, Receiver} 経由でシグナルを待ち受けることができます.

extern crate signal_notify;
use signal_notify::{notify, Signal};
use std::sync::mpsc::Receiver;

fn main() {
    let rx: Receiver<Signal> = notify(&[Signal::INT, Signal::USR1]);
    for sig in rx.iter() {
        match sig {
            Signal::INT => {
                println!("Interrupted!");
                break;
            }
            Signal::USR1 => println!("Got SIGUSR1!"),
        }
    }
}

Rust で Unix シグナルを取るライブラリとしては GitHub - BurntSushi/chan-signal: Respond to OS signals with channels. というのが有名です. こちらは標準ライブラリの mpsc::channel ではなく,chan クレイトの channel を使っています. chan クレイトはケースによってはかなり便利で,

  1. 複数の consumer を作れる (receiver.clone() ができる)
  2. chan_select! マクロによって golangselect 的なことができる

という利点があります.

一方で複数 consumer にする必要がない & chan_select! が必要ないケースでは,シグナルハンドリングのためだけに chan にも依存するのもなんとなくはばかられるという気持ちがありました. また,自分の目的として「SIGWINCHSIGIO が取りたい」というのがあったのですが,chan-signal の仕組みだとデフォルトで無視されるシグナルをキャッチできない(macOS だけ)という問題もありました. 報告するときに方法を考えていたのですが,あまり自信がなかったのとほとんど完全に仕組みを書きなおす形になりそうだったので,自分の手元で std::sync::mpsc を使って実験してみたという経緯です.

仕組み

  1. 初期化時にパイプを作る
  2. シグナルごとに通知すべき Sender を覚えておく
  3. シグナルごとに sigaction でハンドラをセットする
    • シグナルが来たらそれをパイプに write(2) する
  4. シグナル待受&通知用のスレッドを起動する
    • パイプからシグナル番号を読んで,適切な Sendersend する

という仕組みで動いています. 自信がなかったのは,「シグナルハンドラでやっていいこと一覧」をちゃんと把握していないという点です. 一応 sigaction の man を見ると write は読んでもいい関数一覧にいる気がするし,実際動いてはいるのでセーフだろうと判断しました. (もしアウトだったら教えてください)

ちなみに chan-signal の方は,

  1. シグナルごとに通知すべき Sender を覚えておく
  2. 監視用スレッドを起動し,メインスレッドでは pthread_sigmask を使ってシグナルをブロックする
    • シグナルがすべて監視用スレッドに渡るようにする
  3. 監視用スレッドで sigwait して適切な Sendersend する

という仕組みで動いているようです. sigwait は指定したシグナルが投げられるまでブロックします. ただし,macOSsigwait の man を見ると,

Processes which call sigwait() on ignored signals will wait indefinitely. Ignored signals are dropped immediately by the system, before delivery to a waiting process.

とあって,無視されるシグナルを sigwait で待っても補足できないようです. Linux の man を見るとそんなことは書いていないし,普通に動くっぽいです.

今の実装だと,シグナルを受け取る Receiver がすべて閉じても,監視スレッドは動き続けるしハンドラも残り続けるので,これはなんとかしたいなぁと思っています. アプリケーションの実行時間のうち,ある期間だけシグナルをとってそれ以外はスルーしたいというケースもそんなにないかなというのと,内部的な変更にしかならないので API が変わらないというのがあるので,この状態でとりあえず public にしました.

CLI を書いていると意外と普通に SIGINT は取りたくなることがあると思うので,ぜひ使ってみてください. issue 報告等お待ちしています.