バンドルカードの本人確認改善の取り組み

デザイナーの@torimizunoです。

この記事では、バンドルカードでの本人確認改善の取り組みについて、プロジェクトチームの活動の一部をご紹介します。

バンドルカードの本人確認とは

バンドルカードの本人確認
バンドルカードの本人確認

バンドルカードのバーチャルカードは本人確認不要で利用を開始できますが、リアル+カードを発行する場合は利用上限額が上がるため、本人確認手続きが必要になります。 本人確認手続きの詳細はお伝えできないのですが、手続きの一部として、本人確認書類と撮影した本人確認書類と本人情報をご提出いただき、本人であるかの確認を行います。(以降、「本人確認」と呼びます)

確認及び一定の審査が完了すると、カードの発行を行い、お客さまのもとへカードが送られます。 本人確認ができなかった場合は再度申請をお願いすることになり、お客さまのもとへカードが届くのにお時間がかかってしまいます。

本人確認でき発行へ進めたことを承認率と定義し、カスタマーサポートチームが日々状況を計測していました。 2021年5月にこの承認率が過去最低値を記録したことから、改善に注力したプロジェクトが発足し、私はデザイナーとして参加しました。

プロジェクトメンバー
取り組みの流れ
  1. 目的と目標の認識あわせ
  2. 一次情報による原因調査
  3. 課題と仮説立て
  4. イデアから検討
  5. プロトタイピングと実装とリリース
  6. 効果を振り返る
  7. 振り返りから次の打ち手を再検討し実施(2〜6を繰り返し)

目的と目標の認識あわせ

本人確認の承認率には、最初の申請で本人確認ができた初回承認率と、何度か再申請を重ねて本人確認ができた最終的な承認率と、ふたつの定義がありました。

初回の承認率が上がれば、最短でお客さまにリアル+カードが届くことにつながり、その後の承認率向上にもなるため、初回の承認率に的を絞ることを決めました。 過去の最高値を超える値を平均としていくために、過去の最高値を目標値として目指すとしました。

一次情報による原因調査

何が原因でお客さまは本人確認ができなかったのか、住所の間違いや氏名の間違いなど、理由は分類して計測できるしくみが既にできていました。

原因の割合でそれまでも傾向は見ていましたが、プロジェクトメンバーによって一次情報まで確認できている人とそうでない人で課題感の差がありました。 そこでひとりひとりが多角的に課題を知るために、プロジェクトメンバー全員が数百件ずつ申請情報を見に行き、本人確認できなかった理由を抽出していきました。

例えば、本人確認書類の厚みは「表面の厚みや内容が識別できる」を判断基準で真正性を確認していますが、本人確認ができないものとしては下記のような原因が抽出されました。

  • 撮影した書類の全体像が写っていない
  • 書類がぼやけて本人情報が読めない
  • 暗すぎる、反射で本人情報が読めない
  • 厚みが見えない
  • 厚みの角度が急すぎて本人確認情報が見えない
  • 書類の裏面の厚みを撮影してしまっている

本人確認書類の厚みの判断基準
本人確認書類の厚みの判断基準

また、自分たちで一次情報を確認していると「この申請は自分が本人確認ルールに則ってチェックすると承認だと思うけど、非承認になっている」と感じる内容があることにも気が付きました。

課題と仮説立て

本人確認できなかった原因を見ていくと、申請前 申請中 申請後のフローごとの課題と仮説が見えてきました。

フローごとの課題
フローごとの課題

申請前

そもそもの本人確認書類の情報と、住所や氏名等の現在の情報が合っていない場合が見られました。本人確認の趣旨が伝わっていない、住所確認でなく送付先住所と思われている等の仮説があげられそうです。

申請中

書類の撮影が上手くできなかったり、入力する情報を間違えてしまうなどの課題が見受けられました。撮影した書類の情報が他者にとって読み取れる必要があることが伝わっていない、書類の厚みの必要性が伝わっていない、などの仮説があげられそうです。

申請後

本人確認のチェックをしている方たちが、人によって申請内容の承認にぶれがあることが見えてきました。判断に悩む要因を一律本人確認できないとしている可能性がありそうです。

ここから各課題の割合と影響人数を出し、インパクトのシミュレーションを作成し、初回承認率の上昇に影響のある大きさの課題から取り組む優先度を検討していきました。

イデアから検討

課題によって、プロダクト側から申請前・申請中にできるアプローチと、確認オペレーションで申請後に解決できそうなものと両軸が考えられそうなため、平行して検討と実施を進めることにしました。

オペレーションについては カスタマーサポート・不正検知・コンプライアンスのメンバーを中心として、本人確認を実施しているメンバーが判断に迷ったものはプロジェクトメンバーにエスカレーションしてもらう流れを一時的に行いました。 そこでプロジェクトメンバーがチェックを行い、迷う判断基準を言語化し、確認マニュアルに落とし込んでいきます。

プロダクト方面からのアプローチは、Googleスプリントの一部のプロセスを採用し、アイデアだしはクレイジー8を活用しながら実施していきました。

クレイジー8とは

  • 課題解決の参考になる情報を集め案を考える(手書きでOK)…(10分)
  • その時点での案をひとり3分ずつプレゼンする…(3分✕人数)
  • ほかのメンバーのプレゼンを聞いたあと、アイデアを「ひとりで」練り、8つのマスに各1分で書く(8分)
  • 各メンバーのソリューションを匿名で批評・検討し、ベストを決める(20分)

カンムではmeetを利用しているので、ワークショップはMTG中に作成できるホワイトボード機能を利用して実施しました。 私以外のメンバーはクレイジー8をするのは始めてだったのですが、ワークショップ中にどんどんアイデアを書き起こしていってくれて、すごいなと感じました。

クレイジー8で出たアイデア例
クレイジー8で出たアイデア

まずは厚み・生年月日・氏名を取り組むことを決めたので、各課題について毎週のようにアイデアだしを行い、最終的にはどのアイデアが最も初回の本人確認の承認率を上昇させそうかを軸に投票し、ベストなアイデアを絞り込みつつ考慮すべき点もメンバーで洗い出していきました。

プロトタイピングと実装とリリース

イデアを絞り込んだ後は、ひたすらプロトタイプを作成してはチームメンバーで操作してブラッシュアップをしていきました。 例えば申請中の厚みの課題に対して、下記のような施策を実施しました。

①撮影前に注意ポイント画面を挟む 厚みは表面の撮影が必要なこと、厚みと情報が読める必要がある点を伝わりやすくする

撮影前
撮影前

②撮影時のガイドを読みとりやすいものへ 後ろ倒しの斜めから、手前斜め45度のガイドに変更して確認している本人情報が見えやすい角度での撮影を促す

撮影時
撮影時

③撮影後の確認画面の調整 厚み撮影後にチェックする箇所を確認する体験を挟み、注意点に気付けるようにする

撮影後
撮影後

ユーザーに表示される画面が増え完了までその分お時間が発生してしまうのですが、撮影で気をつけるポイントがわからず、撮影が上手くいかず申請後にやり直しが発生するほうがユーザーにとってもサービスにとってもデメリットが大きいと判断し、撮影前と撮影後のガイドを充実させました。 他にも、細やかにライティングの調整等を実施しています。

効果を振り返る

各アイデアを順にリリースしていき、それぞれで効果の振り返りをしていきます。 施策によっては仮説していた想定効果がでないものもあり、新たな仮説を立て直し次の施策へと回していきました。

仮説に対して効果があったものでいえば、例にもあげていた申請中の厚みに対する施策は効果が見られました。 リリース前とリリース後で厚みが原因の割合がどう変化したのか調査したところ、一時期は本人確認できなかった原因の平均4割を占めていましたが、施策以降は最小1割、平均2割以下にさがっていきました。

本人確認できなかった厚みの原因の100%のうち、改善前は「読み取れない&角度が急で読めない」が55%を占めていましたが、施策後は18%になり37%の減少が見られました。 裏面の厚みを撮影してしまう27%を占めていましたが、17%の割合に減少しました。

厚みが原因の内訳の変化
厚みが原因の内訳の変化

振り返りから次の打ち手を再検討し実施

振り返りでの分析で、数年前に申請して久しぶりに再申請を試み、本人確認できないユーザーが半数くらいいることもわかりました。 これに関しては、現状申請時にどの書類で申請したか情報を保存できておらず、一度でも再申請が発生すると、初回のガイドつき申請UIでなくガイドなしUIになってしまっている課題があるため、申請時に書類の情報の取得から順次取り組みをはじめています。

振り返りを重ねながら継続的に施策を実施した結果、2022年1月に初回承認率は目標値を達成し、その後数ヶ月安定して経過しています。

撮影時の厚みの課題以外に、入力時の間違いを減らす施策に関しても施策を繰り返しているため、また別の記事としてお伝えしていきたいです。

今回のプロジェクトから得た学び

実際に申請された情報をひとつひとつチームメンバーが見にいくことで、課題抽出の解像度があがりました。 それがメンバー間での意見活性化や、仮説とアイデアの立てやすさに繋がった感覚があります。 プロジェクトメンバー全員が一次情報をきちんと見ようとした意識で自然と動いていったのは、カンムの「事実と向き合う」文化がにじみ出ているのではないかと思います。

施策によっては効果がなかったものもありますが、実施したことでこの仮説ではない…という事実がわかったトライ自体に価値があるとカンムに来てから感じています。

一度施策を実施した上で再び一次情報を見にいった際、わかった事実を自分が持っているので、今まで見えてなかった観点の課題が見えるようになった時は学びが自分の中に入っている感覚がありました。その感覚が知れたことが嬉しいです。

引き続き、バンドルカードは「誰もがかんたんにわかる」プロダクトとしての品質を高めるアップデートを続けていきます。

採用リンク

カンムではプロダクトを一緒に磨いてくれる仲間を募集しています。

採用情報 team.kanmu.co.jp

社内イベント: エディタについて語る会で Vim script と ISO8583 の話をしました

エンジニアの佐野です。最近記事を書いていなかったので小ネタです。先日、菅原企画の社内イベント、エディタについて語る会が催されました。職種にもよりますがカンムでは多くの従業員はオンラインで業務を行っています。たまにはオフラインで交流も...ということで来れる人はオフィスに集まってエディタの話をしつつ軽食を楽しむというコンセプトです。

当日は Vim, Emacs, Visual Studio Code, nano... と様々なエディタのゆるい話から熱い話が語られました。私は VimVim script について話したので今日はそれを記事化します。


0. 私とエディタ

私は長らく Vim をエディタとして使っています。「エディタ」というものを意識したのは大学生の頃でしょうか。機械工学系だったのですがソフトウェア工学や C や C++ がカリキュラムにあり自分もそれらを履修しました。それらの演習では Microsoft Visual Studio (Visual C++だったかな...?)やメモ帳を使っていました。それにしてもなぜメモ帳だったのか...。

大学入学は2000年でした。ちょうどその頃に一般家庭にもインターネットが普及し始めました。IT革命という言葉が世間に踊った時期だった気がします。住んでいたアパートにもインターネット回線を導入して私はインターネットにどっぷりハマっていきました。インターネットに触れることでコンピュータに興味が出てきた私は Perl を独学したり PC を自作してサーバ構築の真似事のようなことを始めました。コードを書くときに前述の Visual Studio を使いたかったのですが有償(だったと思う)で手が出しにくい、メモ帳では機能が微妙すぎる、何かないだろうか?と本屋を物色していたら Vi (Vim), Emacs あたりの本を見つけました。どうやら Vi (Vim) というのがシンプルで良いらしいということで Vi を使い始めました。

使い始めた当初は「モードって何?」「なんでバックスペースで削除できないんだ?」という状態でした。普通の人からするとそんなものだと思います。設定ファイルの存在も知らず、毎回起動しては :set nu (行番号を表示する)を打っていました。

サラリーマンになって Java を書いていたときは Eclipse を使っていましたが、それ以外のプログラミング、サーバでのオペレーション、ファイル編集などはずっと Vim でした。これはほとんどのマシンに標準で入っていたというのと、大学からの流れで手に馴染んでいる、というのが理由でしょうか。

その後、新しいエディタやIDEが出る度に乗り換えを試みますが、結局は Vim に戻ってきました。私はロールとして長らくサーバサイドのアプリケーションエンジニアとインフラエンジニアをやっています。サーバに ssh してマシン上で作業したり手元でコードを書いたり...業務中は端末を操作している時間が長いため、多くの作業をターミナルで完結させたくなります。他のエディタを起動して時にターミナルに切り替えたり...というのがちょっとしたストレスになり、結局はVim に(ターミナルに)戻ってきてしまいます。

最近では GoLand や VSCode への乗り換えを試みましたがやはりダメでした。

1. Vim の設定

思い出話が長くなりました。学生の頃から使っていた Vim ですが、初期のころはいろいろなプラグインを入れたり設定をこねくり回したりして盆栽のように設定を育てていました。今はプラグインはだいぶ減って以下のもののみになっています。

これらのプラグインとともにいくつかの設定を施しています。 Vim を知らない人向けに少し書くと、Vim の設定ファイルは .vimrc で基本的な配置位置は $HOME/.vimrc になります。Vim のカスタマイズはこちらのファイルを編集することで行います。私の設定は以下のようになっています。

"=========================================================
" Basic Configuration
"=========================================================

set notitle
set nocompatible        "vi互換をoff
set nobackup            "バックアップファイルを作らない
set noswapfile          "スワップファイルを作らない
set number              "行番号を表示
set laststatus=2        "ステータスを常に表示
set showmode            "モードを表示する
set showcmd             "コマンドを表示
set noshowmatch
set display=uhex        "謎の文字を16進数で表示
set wildmenu            "補間候補を表示する
set wrap                "自動折返しを有効
set expandtab           "タブをスペースに変換
set tabstop=2           "タブをスペース4つ分として表示する
set shiftwidth=4        "シフトで移動する文字幅
set softtabstop=2       "タブキーを押したときに挿入する半角スペースの数
set noincsearch         "インクリメンタルサーチはしない
set wrapscan            "最後まで検索したら先頭へ戻る
set ignorecase          "大文字小文字無視
set smartcase           "検索文字列に大文字が含まれている場合は区別して検索する
set hlsearch            "検索文字をハイライト
set splitbelow          "新しいウィンドウを下に開く
set splitright          "新しいウィンドウを右に開く
set nocursorline        "カーソルのある行をハイライトしない
set nocursorcolumn      "カーソルのある列をハイライトしない
augroup Cursor
  autocmd WinLeave * setlocal nocursorline "カレントウィンドウから離れたらカーソルハイライトを消す
  highlight ZenkakuSpace cterm=underline ctermfg=lightblue guibg=#666666 "全角スペースを見えるようにする
  autocmd BufNewFile,BufRead * match ZenkakuSpace / /
augroup END

filetype plugin indent on

"=========================================================
" Private
"=========================================================

let mapleader = "\<Space>"
nnoremap <Leader>r :reg<CR>

"=========================================================
" Encode
"=========================================================
"表示するときの文字コード(ターミナルの設定と同じ)
set encoding=utf-8
"保存するときの文字コード
set fileencoding=utf-8
"文字コード自動判別の候補とする文字コード種を列挙
set fileencodings=iso-2022-jp,euc-jp,cp932,utf-8

"=========================================================
" Plugin management
"=========================================================

" プラグインインストールディレクトリ
let s:dein_dir = expand('~/.cache/dein')
let s:dein_repo_dir = s:dein_dir . '/repos/github.com/Shougo/dein.vim'

" dein.vim がなければ取得
if &runtimepath !~# '/dein.vim'
  if !isdirectory(s:dein_repo_dir)
    execute '!git clone https://github.com/Shougo/dein.vim' s:dein_repo_dir
  endif
  execute 'set runtimepath^=' . fnamemodify(s:dein_repo_dir, ':p')
endif

if dein#load_state(s:dein_dir)
  call dein#begin(s:dein_dir)

  let g:rc_dir    = expand('~/.vim/rc')
  let s:toml      = g:rc_dir . '/dein.toml'
  let s:lazy_toml = g:rc_dir . '/dein_lazy.toml'

  call dein#load_toml(s:toml,      {'lazy': 0})
  call dein#load_toml(s:lazy_toml, {'lazy': 1})

  call dein#end()
  call dein#save_state()
endif

if dein#check_install()
  call dein#install()
endif

"=========================================================
" Plugin configuration
"=========================================================
" gitgutter
set updatetime=250
let g:gitgutter_max_signs = 500

" goimports
let g:goimports = 1

" vim-quickhl
nmap <Space>m <Plug>(quickhl-manual-this)
xmap <Space>m <Plug>(quickhl-manual-this)
nmap <Space>M <Plug>(quickhl-manual-reset)
xmap <Space>M <Plug>(quickhl-manual-reset)

"=========================================================
" Color
"=========================================================
colorscheme cyberspace
set background=light

" 256色
set t_Co=256
" 背景色
set signcolumn=yes
hi SignColumn ctermbg=black
hi SignColumn guibg=black

"=========================================================
" Popup color
"=========================================================
hi NormalFloat guifg=#ffffff guibg=#191970
hi Pmenu guifg=#ffffff guibg=#191970

"=========================================================
" status line color
"=========================================================
set noshowmode
let g:lightline = { 'colorscheme': 'wombat' }

syntax enable

前半部分に set xxxx という設定が羅列されていますが、プラグインマネージャ dein の箇所には変数 ( let ... )や関数呼び出し ( call ... )、外部コマンドの呼び出し (execute '!git ...)、条件分岐 ( if ... ) が表れたりします。 set xxxx も含めこれらは Vim script と呼ばれます。つまり Vim の設定ファイルやプラグインの正体は Vim script の塊です。

ちなみに Vim script はオブジェクト指向プログラミングもサポートしています。 Vim script は単なる設定ではなくれっきとしたプログラミング言語です。

2. Vim script を書く

ではその Vim script を書いてみます。Vim の設定というよりは Hello, world のような簡単なプログラムを書いてみます。

Vim script の始め方は簡単で、 Vim を起動したらコマンドラインモードでそのまま Vim script が書けます。次の例ではテキストの2行目から4行目を取得して echo で結果を出力しています。結果はそのままステータスラインに表示されます。

map() などの便利な組み込み関数もあります。似たようなコードですが、2行目から4行目の数値をリストで取得して出力、続いてそれを map 関数を通して倍にして出力しています。

一連の処理は .vimrc に関数として定義することで call FuncName() で利用できます。次の関数は数値のリストを受け取ってそれを加算するものです。

3. ISO8583

ここまでで Vim script で普通のプログラミングができることを示しました。さて、カンムと言えば ISO8583 らしいのですが...先日の GoCon オフィスアワーで ISO8583 を Go で Parse してみましょうという出題がありました。これを Vim script で倒してみます。

ISO8583 自体の説明や GoCon の問題の説明や解説は下記のエントリを参照していただけると幸いです。が、少しだけ解説します。ISO8583というのはクレジットカードのデータ通信時に使われるプロトコルで、店頭でカードを切ったときにこのプロトコルにのっとってデータが飛んできます。カード会社はこのプロトコルを捌く必要があり、カンムでもこれを処理するサーバとアプリケーションが元気に稼働しています。これは Vim script ではなくは Go で書かれています。

tech.kanmu.co.jp

3.1 問題のファイルを読む

問題を解くにあたりまずやるべきことは問題のバイナリデータを読むことです。Vim script でもファイルシステムからファイルを読むことができます。次のようにファイルをバイナリモードで読み込んで1バイトずつ処理できます。

let inputfile = "/path/to/github.com/kanmu/gocon-2022-spring/message.bin"
for b in readfile(inputfile, 'B')
  " ISO8583 Processing
endfor

3.2 結果を送信する

さらに問題を解くには結果を送信する必要があるのですが Vim script でも当然できます。ソケットを開いてそこに HTTP を書き込むことで HTTP 通信ができます

let channel = ch_open("168.138.192.92:80",
            \ {"callback": "Callback", "mode": "raw", "waittime": "1000ms"})
let body = printf("{\"input\":{\"Type\":%d,\"PrimaryAccountNumber\":%x,\"ProcessingCode\":%x,\"  AmountTransaction\":%x,\"ExpirationDate\":%x}}",
            \ str2nr(mt), pan, pc, amount, ed)
let header = printf("POST /v1/data/iso8583/validation HTTP/1.1\r\nHost: 168.138.192.92\r\nConte  nt-Type: application/json\r\nContent-Length: %d\r\n\r\n",
            \ len(body))
let payload = header . body
call ch_sendraw(channel, payload)

3.3 ビット演算をする

ISO8583 を処理するにはビット演算を活用する必要があります。こちらについては詳細は上述したとおり GoCon の問題解説のエントリを見てください。and()論理積がとれます。 こちらはビットが立っている場所を調べて、立っていたら処理を行う...というコードになります。

" PAN
if and(bitmap, 0b0100000000000000000000000000000000000000000000000000000000000000) != 0
  if i == 10
    let panbyte = b/2
    let i = i + 1
    continue
  endif
  if i <= 10+panbyte
    let shift = 8 * (panbyte - (i - 10))
    let pan =  pan + b * float2nr(pow(2, shift))
    let i = i + 1
    continue
 endif
endif
" Processing Code
if and(bitmap, 0b0010000000000000000000000000000000000000000000000000000000000000) != 0
  if i <= 10+panbyte+pcbyte
    let shift = 8 * (pcbyte - (i - (10 + panbyte)))
    let pc =  pc + b * float2nr(pow(2, shift))
    let i = i + 1
    continue
  endif
endif

3.4 ビットシフトする

バイナリを処理する傍らビットシフトしたくなるんですが Vim script でも当然それはでき....なかった! Vim script にシフト演算子はありません(たぶん...)!よってここは2のべき乗で対応します。

if i <= 10+panbyte
  let shift = 8 * (panbyte - (i - 10))
  let pan =  pan + b * float2nr(pow(2, shift))
  let i = i + 1
  continue
endif

これは何をやっているのかというと、例えばカード番号 4019-2499-9999-9999 は ISO8583 では次のように BCD で表現されています。

0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99

1バイトずつ処理していくため、まず 0x40 を左に7バイトシフトして 0x4000000000000000 とする、次に 0x19 を左に6バイトシフトして 0x19000000000000 とする... を行い、

 0x4000000000000000
   0x19000000000000
     0x240000000000
     ...
+
------------------------
4019249999999999

といった形でカード番号を取り出しています。

4. ISO8583() 関数を書く

重要な処理はだいたいここまでです。あとは一連の流れを組み立てていきます。結果、ISO8583 を処理する関数を書くと次のようになります。

function! ISO8583()
  let inputfile = "/path/to/github.com/kanmu/gocon-2022-spring/message.bin"

  let mt = ""
  let bitmap = ""
  let bitmapbyte = 8

  let panbyte = 0
  let pan = ""

  let pc = ""
  let pcbyte = 3

  let amount = ""
  let amountbyte = 6

  let ed = ""
  let edbyte = 2

  let i = 0
  for b in readfile(inputfile, 'B')
    " message type
    if i <= 1
      let mt = mt . printf("%02s", b)
    endif

    " bitmap
    if i >= 2 && i <= 9
      let shift = 8 * (bitmapbyte - (i - 1))
      let bitmap =  bitmap + b * float2nr(pow(2, shift))
    endif

    " data element
    if i >= 10
      " PAN
      if and(bitmap, 0b0100000000000000000000000000000000000000000000000000000000000000) != 0
        if i == 10
          let panbyte = b/2
          let i = i + 1
          continue
        endif
        if i <= 10+panbyte
          let shift = 8 * (panbyte - (i - 10))
          let pan =  pan + b * float2nr(pow(2, shift))
          let i = i + 1
          continue
        endif
      endif
      " Processing Code
      if and(bitmap, 0b0010000000000000000000000000000000000000000000000000000000000000) != 0
        if i <= 10+panbyte+pcbyte
          let shift = 8 * (pcbyte - (i - (10 + panbyte)))
          let pc =  pc + b * float2nr(pow(2, shift))
          let i = i + 1
          continue
        endif
      endif
      " Amount
      if and(bitmap, 0b0001000000000000000000000000000000000000000000000000000000000000) != 0
        if i <= 10+panbyte+pcbyte+amountbyte
          let shift = 8 * (amountbyte - (i - (10 + panbyte + pcbyte)))
          let amount =  amount + b * float2nr(pow(2, shift))
          let i = i + 1
          continue
        endif
      endif
      " Expiration date
      if and(bitmap, 0b0000000000000100000000000000000000000000000000000000000000000000) != 0
        if i <= 10+panbyte+pcbyte+amountbyte+edbyte
          let shift = 8 * (edbyte - (i - (10 + panbyte + pcbyte + amountbyte)))
          let ed =  ed + b * float2nr(pow(2, shift))
          let i = i + 1
          continue
        endif
      endif
    endif
    let i = i + 1
  endfor

  echo "--------------------"
  echo printf("Message Type: %s", mt)
  echo printf("Bitmap: %b", bitmap)
  echo printf("PAN: %x", pan)
  echo printf("ProcessingCode: %x", pc)
  echo printf("Amount Transaction: %x", amount)
  echo printf("Expiration Date: %x", ed)
  echo "--------------------"

  let channel = ch_open("168.138.192.92:80",
              \ {"callback": "Callback", "mode": "raw", "waittime": "1000ms"})
  let body = printf("{\"input\":{\"Type\":%d,\"PrimaryAccountNumber\":%x,\"ProcessingCode\":%x,\"AmountTransaction\":%x,\"ExpirationDate\":%x}}",
              \ str2nr(mt), pan, pc, amount, ed)
  let header = printf("POST /v1/data/iso8583/validation HTTP/1.1\r\nHost: 168.138.192.92\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n",
              \ len(body))
  let payload = header . body
  call ch_sendraw(channel, payload)
  echo payload
endfunction

function! Callback(handle, msg)
  echo a:msg
endfunction

ここで :call ISO8583() を呼び出してみます。クリアできました。

最後に

あまりドスの効いていない社内イベントの紹介記事になってしまいましたが...。Vim やカンムの社内イベントに興味を持っていただけたら幸いです。

Go Conference 2022 Spring: クイズ ISO 8583 Message Challange の紹介と解説

バンドルカードのバックエンドやインフラを担当しているエンジニアの summerwind です。最近は WebAssembly と JIT に興味があります。

4月23日に開催された Go Conference 2022 Spring ではカンムのメンバーが Go に関する内容で登壇しました。セッションで紹介したスライドは以下で参照できます。将棋プログラミングについては自分もまったく知らない世界の話だったのでとても興味深かったです。


今回のイベントにはカンムもスポンサーとして参加させていただき、今回もオフィスアワーの催しとして Go を使ったクイズ「ISO 8583 Message Challange」を公開しました。

クイズの問題

今回のクイズの問題は以下のリポジトリで参照できます。もし興味がありましたらぜひチャレンジしてみてください。

github.com

今回の問題のテーマは「バイナリ処理」です。インターネットでの通信やデータの保存などでは様々な形式のバイナリが使われていますが、一般的なプロダクト開発ではバイナリを直接扱うようなコードを書く機会は意外と少なかったりします。個人的には Go でバイナリを扱うコードを書くが好きなので、今回のクイズではバイナリのパースを通じてより多くの人にバイナリの操作を楽しんでもらえたらと思い、このテーマを設定してみました。

問題では、カンムの決済処理にも使われている ISO 8583 形式のメッセージをパースしてその中から答えとなる値を見つける、というゴールを設定しています。メッセージのバイナリファイルはリポジトリmessage.bin として保存されているので、これをパースしていくと答えが見つかる仕組みです。今回の問題では、より手軽にクイズにチャレンジしてもらえるよう以下のように parse.go ファイルに定義された Parse() 関数の中身だけを実装すれば回答を出せるようにしてみました。

問題の解説

ここからは実際にバイナリをパースしていく方法を解説してみたいと思います。

まず最初に、実装が必要な Parse() 関数の定義を見てみると次のようになっています。引数 buf にはバイナリの値が byte の配列として保存されており、*Message を返せばいいことが分かります。

// ISO 8583 メッセージのバイト列をパースして Message を返す関数
func Parse(buf []byte) *Message {
    var msg Message

    // TODO: バイト列 buf をパースして msg の各フィールドに値を設定してください

    return &msg
}

次に、戻り値である *Message の構造を見みてみると、次のようになっています。

// ISO 8583 メッセージ構造体
type Message struct {
    // メッセージ: 3桁の数字
    // ISO 8583 バージョンの 0 は含まないことに注意してください
    Type uint16

    // カード会員番号 (PAN): 16桁の数字
    PrimaryAccountNumber uint64

    // 処理コード: 6桁の数字
    ProcessingCode uint32

    // 取引金額: 任意の桁の数字
    AmountTransaction uint64

    // 有効期限: YYMM の4桁の数字
    ExpirationDate uint16
}

この構造から今回のクイズでは、バイナリから以下の5つのデータをパースして抽出すればよいことが分かります。

  • メッセージタイプ
  • カード会員番号
  • 処理コード
  • 取引金額
  • 有効期限

データの抽出には ISO 8583 メッセージのバイナリフォーマットの知識が必要になります。弊社の hiroakis が以前発表した資料にフォーマットの詳細と詳しい解説がありますので、ここからはこの資料とあわせて読み進めてみてください。

最初にメッセージタイプをパースします。これはメッセージの種類などを示す値でメッセージの先頭2バイトを4ビットのパック10進数 (Packed BCD) として扱う必要があります。今回の解説では処理を簡略化するため、BCD の処理については公開されているパッケージである https://github.com/albenik/bcd を利用していきます。

// メッセージタイプを取得
msg.Type = bcd.ToUint16(bin[0:2])

次にカード会員番号のパースに取り掛かりたいところですが、最初にビットマップと呼ばれる領域を取得しておく必要があります。

ビットマップはメッセージにどんな種類のデータが存在するかを示すビットを保存している64ビットの領域です (64ビットより長い場合もあります) 。この領域の各ビットの値が1だった場合はそのビット位置に対応するデータの存在を示しています。例えば、ビットマップの値が2進数で 01110010 であった場合、2、3、4、7番目のデータが存在していることを示しています。

// ビットマップ領域を取得
bitmap := binary.BigEndian.Uint64(bin[2:10])

ビットマップが取得できたので、カード会員番号を抽出します。カード会員番号の存在を示すのはビットマップの左から2ビット目の値なので AND 演算で確認します。ビットが存在した場合は値を抽出しますが、カード番号の値は可変長の値なので、最初に先頭1バイトの長さを抽出し、その長さ分のバイト数を Packed BCD として読み出します。

// バイナリのオフセット
offset := 10

// カード会員番号のビットの存在を確認
if (bitmap & 0x4000000000000000) > 0 {
    // カード会員番号の長さを取得
    length, _ := binary.Varint(buf[offset : offset+1])
    offset += 1

    // カード会員番号を取得
    msg.PrimaryAccountNumber = bcd.ToUint64(buf[offset : offset+int(length)])
    offset += int(length)
}

次に処理コードを抽出します。カード会員番号と同じようにビットマップで存在を確認してから、処理コードの値を取得します。処理コードの値は3バイトの固定長なので、そのまま読み出します。

// 処理コードのビットの存在を確認
if (bitmap & 0x2000000000000000) > 0 {
    // 処理コードを取得
    msg.ProcessingCode = bcd.ToUint32(buf[offset : offset+3])
    offset += 3
}

残りは取引金額と有効期限になりますが、これらは処理コードと同じ固定長のフィールドなので、次のように処理コードと同じように値を取得できます。

// 取引金額の取得
if (bitmap & 0x1000000000000000) > 0 {
    msg.AmountTransaction = bcd.ToUint64(buf[offset : offset+6])
    offset += 6
}

// 有効期限の取得
if (bitmap & 0x0004000000000000) > 0 {
    msg.ExpirationDate = bcd.ToUint16(buf[offset : offset+2])
    offset += 2
}

実はビットマップの存在確認に使用する各ビットの値は定数として事前に定義されているので、それらを使用すると最終的な Parse() の実装は次のようになります。

// ISO 8583 メッセージのバイト列をパースして Message を返す関数
func Parse(buf []byte) *Message {
    var msg Message

    // メッセージタイプを取得
    msg.Type = bcd.ToUint16(buf[0:2])

    // ビットマップ領域を取得
    bitmap := binary.BigEndian.Uint64(buf[2:10])

    // バイナリのオフセット
    offset := 10

    // カード会員番号を取得
    if (bitmap & BitPrimaryAccountNumber) > 0 {
        length, _ := binary.Varint(buf[offset : offset+1])
        offset += 1

        msg.PrimaryAccountNumber = bcd.ToUint64(buf[offset : offset+int(length)])
        offset += int(length)
    }

    // 処理コードを取得
    if (bitmap & BitProcessingCode) > 0 { 
        msg.ProcessingCode = bcd.ToUint32(buf[offset : offset+3])
        offset += 3
    }

    // 取引金額の取得
    if (bitmap & BitAmountTransaction) > 0 {
        msg.AmountTransaction = bcd.ToUint64(buf[offset : offset+6])
        offset += 6
    }

    // 有効期限の取得
    if (bitmap & BitExpirationDate) > 0 {
        msg.ExpirationDate = bcd.ToUint16(buf[offset : offset+2])
        offset += 2
    }

    return &msg
}

実装ができたのでクイズの README.md の記載に従いコードを実行してみると、バイナリファイルから正しい値を取得して正解が表示されました。

$ go run .
--------------------
Message Type: 100
PAN: 4019249999999999
Processing Code: 327327
Amount Transaction: 1138
Expiration Date: 2204
--------------------
Result: VALID: You have successfully parsed the ISO 8583 message! ...

回答判定の仕組み

問題が解けたところで、今回のクイズの回答判定の仕組みについても簡単に紹介します。

カンムではこれまでにも Go Conference の開催にあわせて CTF やクイズを公開してきました。これまでの問題では出題を担当するメンバーが独自に回答を判定する仕組みを実装していましたが、今回のクイズ作成にあたっては、より汎用的な回答判定をする仕組みを採用してみることにしました。

今回の回答判定に採用したのは Open Policy Agent (OPA) です。OPA を使用すれば正解判定を Rego で記述したポリシーとして扱うことができるため、非常に簡単に回答判定の仕組みが用意できました。今回の回答判定では次のようなポリシーを使用しています。

package iso8583

default validation = "INVALID"

validation = "VALID: You have successfully parsed the ISO 8583 message!" {
  input.Type == 100
  input.PrimaryAccountNumber == 4019249999999999
  input.ProcessingCode == 327327
  input.AmountTransaction = 1138
  input.ExpirationDate = 2204
}

目視によるパース

実は ISO 8583 形式のメッセージは16進数で見てみると、ある程度データの値を推測できます。実際に目視でパースして正解した、という方もいたようでした。

$ hexdump -C message.bin
00000000  01 00 70 04 00 00 00 00  00 00 10 40 19 24 99 99  |..p........@.$..|
00000010  99 99 99 32 73 27 00 00  00 00 11 38 22 04        |...2s'.....8".|
0000001e

おまけ

クイズのバイナリに含まれる 3271138 といった値はスターウォーズでたびたび登場するマジックナンバーに由来しています。これは Go のトリビアをリスペクトしてみました。

おわりに

Go Conference の開催中は、Twitter などでクイズの問題に実際に挑戦してバイナリパースの楽しさを感じてくれた方のコメントを見かけたりして嬉しかったです。

カンムでは実際に ISO 8583 のメッセージを処理するシステムを開発して決済サービスを提供しており、バイナリ処理に楽しさを感じるようなエンジニアを募集しています。カジュアル面談などは随時実施していますので、ぜひお気軽にお声がけください。

kanmu.co.jp

最後に、今回も素晴らしい Go Conference の場を提供してくれた運営のみなさま、参加者のみなさま、どうもありがとうございました!

カンムは SRE NEXT 2022 にスポンサーとして参加します #srenext

こんにちは!カンムでエンジニア採用を担当している @ayapoyo です。 ついに今週土曜・日曜は SRE NEXT の開催日ですね! 今回はじめての参加になるのでいまからワクワクしております!

sre-next.dev

ゴールドスポンサーとして協賛します!

今回カンムで初めて SRE NEXT にスポンサーとして参加させていただきます!

カンムは運営する『バンドルカード』は500万ダウンロードを突破、2022年には手元の資産形成に活用できるクレジットカード『Pool』をリリース予定と、サービスが拡大していく中でより信頼性の高い決済インフラを構築していくことが求められます。

今回のスポンサーをきっかけに、さまざまな業種・領域・フェーズにおける SRE 領域の知見を得るとともに、カンムとしても SRE コミュニティへ還元していければと思っています。

kanmu.co.jp

スポンサーセッションに登壇します!

今回はスポンサーセッションとして、インフラエンジニアの @sgwr_dts が登壇いたします!

もうすぐリリースされる新規事業『Pool』についてお話します。 Track B にてご視聴いただけます!ぜひ御覧ください。

そしてイベント当日はオンラインブースも出展予定です! CTO の @mururururu COO の @_achiku が滞在しているので、ぜひ遊びに来てくださいね。

参加申し込みは SRE NEXT HP から

sre-next.dev

当日たくさんの方とお話できることを楽しみにしております!イベントでお会いしましょう〜👋

カンムのセキュリティ事情

こんにちは、livaです。

カンムでセキュリティエンジニアやってます。入社してから半年程度経った今はPCI DSSの監査準備だったり優先度高めにした施策をOKRに落とし込んで手を動かしたりと慌ただしく動いてます。

初執筆のテックブログでなにを書こうかなと考えていて、3月の末に出たPCI DSSv4がいいかとも思ったんですが、読むだけで一苦労だったので諦めました。あとからゆっくり読みます。 今回はカンムの今と将来のセキュリティ事情を書こうと思います。

入社前の想定

面接や面談時にいくつか課題を聞いていて、大きく2つになるのかなーと考えてました。

1. PCI DSSの運用の課題

カンムはクレジットカードの決済フローでは「イシュア」にあたり、業界のセキュリティ基準であるPCI DSSに準拠している必要があります。これがないとそもそものビジネスが成り立ちません。毎年の準拠が必要なため、最低限のセキュリティの体制は出来上がっています。が、日々の運用や監査前の準備は慌ただしくなりがちでした。

2. プロダクトセキュリティ全般の課題

すでにいたエンジニアの良心に任せた運用とPCI DSSの要件に沿った運用がされています。防御機構も一通り入っているもののアラートに対する反応であったり、AWSで回しているスキャナ結果への反応だったりに対してあまり積極的に手を動かせていませんでした。

入社してから現在までの動き

PCI DSS

今までの自分は監査する方の手伝いはしたことあるんですが、受ける側はまともに経験してないので「とりあえず今年回してなにができるか考えて来年やりやすいように変える!」で動かしています。実質なにもできてない。v4のリリースもあって来年はこっちに揃えるつもりでもいるので「v4への対応&運用効率化」に課題が増えました。ナンテコッタイ。

プロダクトセキュリティ

PCI DSSと違って得意領域で、さらに時間をかけずに片付けられる課題もあったので以下の様なものはさっと片付けました。

それ以外にある腰を据えて取り組まないといけない課題については洗い出してざっくりな優先順位を出しました。

その他

なにかが起きた時、全社としてのなにかしら判断基準があるわけではなくて各チームの判断で動いているので、一体感のある何かは作りたいなぁというのを感じたので課題に積みました。そのための指標としてカンムで持っているいろんな情報資産のレベル分けをして、それをベースに意思決定できるような仕組みを作ろうとしてます。前職であったんですが色んなことが進めやすくなったのでカンムでも作ろうかと思って動いてます。

セキュリティOKR

立てようとしたきっかけが完全に覚えてないんですが、CTOと話してるときにそういう話をしたと思います。 この話の前後で同僚が退職したのもあり「1人だとどんだけできんだべ?」と考えた結果、Objective1つにKeyResult3つを立てました。この時気にしていたのは以下の点です。

  • 自分が楽しめること
  • 進捗0.7が完了
  • 0.7から1.0は趣味
  • チェックポイントの到達条件は明確に

OKRの立て方としては基本的なところですよね。基本に忠実に、やりやすいようにしました。自分が楽しめるのも個人的にはずっと大事にしているので織り込んでます。やりたいことをやるために先に手を付けなきゃいけない部分があるのでやる、みたいなことですね。先に面白いことが待ってるなら意外とやれるもんです。

これから

今は「目の前にあるもの片付けて隙間時間で新しいことの仕込み!」って状態になってますが、将来も見据えてはいます。

チーム

今は1人ですが、今後2人3人と増えていって、できることが増えた時も、各自が主担当領域を持ち、他の領域にもオーバーラップしていくのが自分としては好きなので、そういうチームを目指したいなぁと思います。例えば監査、インフラ、アプリと分けたとき、それぞれに担当を分けるけど、それぞれがなんとなく全部を把握していてバックアップとして機能できる、という。色んなチーム体系があるけど、自分の中ですんなりいきそうな体制はこうなるのかなぁと思ってます

PCI DSSの運用

ここは最近界隈でホットなOPA(Open Policy Agent)と相性がいいんじゃないかという話になっていて、今年度の監査が終わってから本格的に着手しようかなぁなんて考えてます。個人的にも「運用できたら3割くらいは心労が減るんじゃね?」って思ってたりするので手を付けたいですね。このビジネスをしている以上、監査からは逃げられないのでなるべく準備に手がかからない未来を目指したいところです。

プロダクトセキュリティ

エンジニアの個人技でどうにかなってるので、これを統一した仕組みに持っていきたいと思ってます。 「PR作ったらCIでDAST/SASTの各種スキャナ動かして、その結果を適当なとこに集めて、Dashboardで状況把握ができる」なんて形を作れたらなぁと思ってます。アプリもコンテナもインフラもそこを見ればどんな脆弱性があって具体的なリスクはこれで対応状況はどうなっていてというのが見れたら視覚的にも楽しいと思うんですよね、多分。あまりに検出結果が多くてげんなりすることが多いかも。 カードにありがちな不正利用にもセキュリティが齧る余地があるので、対応チームの動向をチラ見しながら自分なりに考えてます。自分がやられて嫌なこと考えてると楽しいのもあり、タスクで煮詰まった時の気分転換になってます。

全体

基本的なものはあるのだけど、それを日常的に動かそうとなると不足しているものがそこそこあるので、そういったものの整備や社内広報をして…なんてのをぼんやりと考えてます。カンムのいいところとして、エンジニア以外の職種でもエンジニアチックな動きができるので、そういった文化は活用しながらカンムに合ったセキュリティ体制を作っていきたいという思いが強いです。

最後に

現状と未来は出せる範囲で伝えられたかなぁと思います。「どこから手を付けるかはだいたい見えてるけどやることいっぱいで手が足りない!」って状況なので、一緒にやってくれるエンジニアを募集中です。「セキュリティに興味がある」ってだけでも大丈夫です。自分が必要なことを教えます。もちろん「こういうのやりたい!」って飛び込んできてくれる人も大歓迎です。

kanmu.co.jp

カンムは Go Conference 2022 Spring にスポンサーとして参加します #GoConference #gocon

こんにちは!カンムでエンジニア採用を担当している @ayapoyo です。 ついに今週土曜日は Go Conference 2022 Spring の開催日! 今回もスポンサーとして参加させていただきます。

gocon.jp

シルバースポンサーやります ʕ◔ϖ◔ʔ

2021年に引き続き、今回もスポンサーとして Go Conference に参加させていただきます!

カンムが展開する "バンドルカード" そして新規事業の "Pool" のバックエンドは Go メインで開発しており、このイベントを通して Go コミュニティの発展に寄与できればと思っています 💳

kanmu.co.jp

エンジニアがセッションに登壇します ʕ◔ϖ◔ʔ

今回の GoCon では、CTO の @mururururu バックエンドエンジニアの @pongzu がセッションに登壇します! 以下スケジュールで登壇するので、ぜひご覧ください。

16:50 Go で始める将棋AI @mururururu

17:55 外部コマンドの実行を含む関数のテスト @pongzu

オフィスアワーで クイズ を開催 ʕ◔ϖ◔ʔ

今回の GoCon も完全リモート開催!Remo でオフィスアワーを実施します。 カンムのブースではこんなことをやります!

決済処理に使われている ISO 8583 のバイナリを解析するクイズに挑戦!

カンムが提供するクレジットカードの決済処理にも使われている ISO 8583 形式のバイナリを解析するクイズを当日公開予定。 ブースやTwitterで随時ヒントの発表や解説などを行います!

カンムのエンジニアが作成したこのクイズ、なかなか解き応えのあるものに仕上がっています…! セッションの休憩時間、ぜひクイズにもチャレンジしてみてくださいね!

このほかにも

  • カンムやバンドルカード・Pool について聞いてみたい!
  • 登壇していたエンジニアと話してみたい!
  • Go についてわいわい話したい!

などなども大歓迎です!Remo のブースに遊びに来てみてくださいね!

参加登録は connpass から ʕ◔ϖ◔ʔ

gocon.connpass.com

gopher のみなさんとお会いできることを楽しみにしております👋

カンム流朝会最適化#1 『確率モデル編』

はじめに f:id:fkubota_owl:20220331123312p:plain

こんにちは、カンムでバンドルカードの機械学習部分を担当している fkubota です。(ちなみに機械学習エンジニアめっちゃ探してます👀)
前回の記事から日が経ってしまいました。
あれから、沖縄に移住する(2月に)などプライベートで大きめのイベントが多発して忙しく過ごしていました。
そのせいもあり新しい技術に触れるような時間が少なくてなかなかテックブログネタが思いつかなかったのですが、朝会で不便を感じていたのでテックでいい感じにしたろ!と思い立ち勢いで書きます。

前回の記事も朝会についてでした。 カンム流『朝会』をやってみたら予想以上にウケが良かった件
ざっと概要を話すと、

  • リモートで雑談減ったよね
  • 雑談する会を設けても継続的に行うことって難しいよね
  • 新しく入社した人が関係構築するのも難しいよね

という思いから、飽きない、形骸化しづらいしくみの朝会を開催しました。
2021年6月ごろにはじめて未だに(2022年03月31日現在) 続いているのでなかなか悪くない仕組みでは?と思っています。
仕組みは簡単で、

  • 週に2回、朝15分開催
  • メンバーは毎回ランダム(現在は18人から5人選ぶ感じ)
  • 聞き専禁止
  • テーマなしの雑談

という感じです。
短いこととランダムなことが効いて飽きづらい仕組みにしています。
biz寄りの人もいればデザイナーもいて普段仕事で会話しない人とも会話できて楽しいです:)

もうすこしいい感じにしたい朝会

f:id:fkubota_owl:20220331124101p:plain
この朝会を運用していてもう少しいい感じにしたいなぁと思うことがありました。
ランダムにメンバーを選んでいるのですが、あれーあの人全然選ばれてないなぁとかあの人とあの人が一緒の会に参加してるの見たこと無いなぁとかそんなことを思っていました。
20回参加している人もいれば、5回しか参加していない人もいます。
AさんとBさんはそれぞれ10回参加していますが、同時に参加したことは0回だったりもします。
朝会は月に多くても 10回程度しか開催されないので、そういう状況はまあありえますよね。

問題の形

f:id:fkubota_owl:20220331124847p:plain

現在の悩みのタネは

  • 全員の参加回数に偏りがある
  • 同時に会に参加したことのないペアが存在する

です。
前者だけであれば、簡単にできそうなのですがペアまで考慮するとちょっと複雑そうです。
こういう制約のある数理最適化問題みたいのって存在しそうなんですが、僕は詳しくないので知りません。
なにか知っている人いたらコメントで教えて下さい。

確率モデルを導入して解いてみる

ということで確率モデルを導入してみます。
確率モデルというと仰々しいですがそんなにかっこよくて難しい話ではありません。
もう少しお付き合いいただくとわかってくるかと思います。

僕は、問題を解くための土台として以下のような表を用意しました。

f:id:fkubota_owl:20220331125217p:plain

これは、A~Jの10人のメンバーが朝会に参加した記録です。
10人から3人が朝会に参加するとします。
[A, B, E] が選ばれた場合、表の A行B列、A行E列、B行E列のセルに1が加算されます。
A行B列とB行A列のセルは同等な意味を持ちますので上三角部分だけが意味を成します。

上の表を導入して話を進めていきます。
次に朝会を30回行った場合を見ていきます。
メンバーはランダムに選びます。(pythonで実装しています。)

1回目
f:id:fkubota_owl:20220331125705p:plain

5回目
f:id:fkubota_owl:20220331125829p:plain

15回目
f:id:fkubota_owl:20220331125900p:plain

30回目
f:id:fkubota_owl:20220331130408p:plain

こんな感じになりました。
BさんとEさん(B行E列)は5回同じ朝会に参加していますが、AさんとDさんは一度も同じ会に参加していません。
各人の合計参加回数はどうでしょうか?

f:id:fkubota_owl:20220331130618p:plain

となっており、明らかに偏りがあります。
Bさんの32回に対してDさんは6回なので、参加回数に5倍も差がありますね。
これはなんとかしたいところ。

やりたいことは明らかで、この参加回数表の値が小さいところが選ばれやすいような確率モデルを導入すればいいだけです。
現在のランダムな状況をモデルとして考えると、以下のようになります。

f:id:fkubota_owl:20220331131239p:plain

各セルの値が選ばれやすい確率を表しており、すべてのペアを等しい確率で選んでいるので0.022で一定値です。
ちなみにすべてのセルを足すと1になるように正規化しています。 100をかければ%になるので、 0.022×100 = 2.2%なのですべてのペアは2.2%の確率で選ばれます。

このすべて同じ値の表(確率モデル)を改良して、すべてのペアでバランス良く朝会を実現しようというのが今回解いている問題です。
ここまでこれば後は解けたようなものですね。
サクッとやってしまいましょう。

まずは、各セルの値ごとに 朝会に参加していない度 を定義したいと思います。
各セルの参加回数を  n _ {ij}、全体の朝会開催数を N とします。
以下のような指標 \alpha _ {ij}はどうでしょうか?


\alpha_{ij} = \frac{N - n_{ij}}{N}

Nが定数(今回だと30)なので、  n _ {i,j} が大きいほど、上の指標 \alphaは小さくなります。
つまりペアが実現した回数が大きいほど小さくなります。 朝会参加していない度 として使えそうです。
実際にセルに当てはめてみます。

参加回数表

f:id:fkubota_owl:20220331132618p:plain

上記から計算した  \alpha
f:id:fkubota_owl:20220331132655p:plain

回数が少ないほど、 \alpha が大きな 大きな値を取っています。
例えば、  n _ {A, D} = 0 だと  \alpha _ {A, D} = 1 であり、 n _ {B, E} = 5 だと  \alpha _ {B, E} = 0.833 となっています。
しかし、0回と5回で極端に差が開いているのに、1/0.833 ≒ 1.2 と仮に確率として扱うと、1.2倍程度しか差がなくて微妙です。
もう少し、回数の差に対して勾配をつけたいです。さらには、その勾配加減を調整できると嬉しいです。
実現方法はいくらでもありますが、僕は 指数関数を用いることにしました。

新しく定義する 朝会参加していない度 \beta とします。


\beta_{ij} = e^{\lambda \alpha_{ij}}

ここで、 \lambda \lambda >0 の実数であり、\lambdaが大きいときには n _ {ij}が小さい場合、 \beta _ {ij}をより小さく、大きい時より大きくします。(勾配をコントロールします)

\lambda=1 の場合と  \lambda=5 の場合の  \beta を見てみましょう。

\lambda=1 の場合
f:id:fkubota_owl:20220331134847p:plain

\lambda=5 の場合
f:id:fkubota_owl:20220331134920p:plain

\lambda=5 のほうが n _ {ij} の値の大きさに激しく値が反応している事がわかります。

あとは、これを正規化(すべてのセルを足して1にする)して確率  p _ {ij} として扱います。


p_{ij} = \frac{e^{\lambda \alpha_{ij}}}{\sum_{all\_cells} e^{\lambda \alpha_{ij}}}

という式になり、おなじみにの softmax関数となりました。

作ったモデルで遊んで見る

作ったモデルで実際にシミュレーションしてみます。
朝会のメンバーを選ぶプロセスは

  1. 参加回数表から確率の表を作成
  2. 表を元に3人の参加者を選ぶ
  3. 表を更新
  4. 1~3を繰り返す

とこんな感じです。

  • randomに選ぶパターン
  •  \lambda=5 で選ぶパターン
  •  \lambda=50 で選ぶパターン

をやってみました。
朝会の開催回数うは  N=50としています。
結果は

  • random
    f:id:fkubota_owl:20220331140510p:plain

  •  \lambda=5
    f:id:fkubota_owl:20220331140609p:plain]

  •  \lambda=50
    f:id:fkubota_owl:20220331140907p:plain

となり、  \lambdaが大きいほど極端に多い/少ないが見られません。
意図した動作になっているようです。
ちなみに、 \lambda=50 の終了時の確率の表は以下のようになっていました。

f:id:fkubota_owl:20220331141112p:plain

また、参加合計回数を randomと  \lambda=50 で比較すると \lambda=50 のほうが公平に参加できている事がわかります。

  • ramdom
    f:id:fkubota_owl:20220331142514p:plain

  •  \lambda=50
    f:id:fkubota_owl:20220331142359p:plain

評価してみる

上述したように  \lambda によって、参加回数をコントロールできるようになり、参加回数表も均一になっているように見えます。
どの程度均一になっているのか?を定量的に評価したくなったのでこれもやってみました。
まあ、分散でいいだろうと思ったのでちゃちゃっと計算した結果を見せます。
分散は、参加回数表の各セルの平均値を \mu、セル数を N_cとした時以下のような式で表されます。

\displaystyle{
var = \frac{1}{N_c}\sum_{all\_cells} \left( n_{ij} - \mu \right)^2
}

横軸に朝会の開催回数、縦軸に  var を取ったグラフを書きました。

f:id:fkubota_owl:20220331142112p:plain

こちらも \lambdaが大きいほど分散が小さいことがわかります。
意図した動作が実現できています。

おわり

以上です。 なんか朝会ガチ勢みたいな記事になってしまって申し訳ないですが、最後まで見てくださってありがとうございます。
頑張って考えましたが実践導入するかちょっと迷います。
めんどくさくなっちゃうといけないので、そこまでのメリットがあるのかは要検討です。
とはいえ、自分で問題を作って解くというはやっぱり面白いなと思いました。
結構楽しめてよかったです。

第二弾があるかはわかりませんが、面白そうな事ができそうならチャレンジしてみたいです。

定番ですが、積極採用中です!
機械学習エンジニアをめっちゃ探してますのでカジュアル面談からでも何卒!!!!

kanmu.co.jp