LayerX エンジニアブログ

LayerX の エンジニアブログです。

〜OCR戦記〜適格事業者登録番号との戦い🔥🔥🔥

この記事はLayerXテックアドカレ2023の5日目の記事です。 昨日はmakogaさんがEngineering Career Ladderを作るときに気をつけたこと 其の一を書いてくれました。 次回はyuya-takeyamaさんがMicrosoft Graph APIについて書いてくれます!乞うご期待!

こんにちは、機械学習を通じて誰かをラクにしたい yakipuです。

今回は、10月から始まったインボイス制度に伴う適格請求書発行事業者登録番号(以下「登録番号」と表記します)のOCR読み取りの戦いについて記したいと思います。

インボイス制度は、売手が買手に対して正確な税率や消費税額を示す適格請求書(インボイス)を交付することで、買手が仕入税額控除の適用を受けるために必要な制度です。売手側は登録事業者として登録番号などが記載されたインボイスを交付し、買手側はインボイスを保存する必要があります。詳しくは国税庁 -インボイス制度の概要をご参照ください。

登録番号のOCR読み取り対応

LayerXが提供しているバクラクは書類をアップロードするだけで支払金額や支払期日などを自動で読み取り補完してくれるOCR機能があります。このOCR機能の読み取り項目の一つに登録番号があります。

インボイス制度に対応した適格請求書には発行者の登録番号が記載されており、そこを読み取ってくれます。

バクラク請求書発行で出力した適格請求書サンプル

支払金額や支払期日などの既存の読み取り項目は機械学習モデルを使用していますが、登録番号の読み取りは人間が設定したルールに基づく処理(ルールベースロジック)を採用しました。

ルールベースロジックを採用したポイントは以下の通りです。

  • 適格番号は"T"に続く13桁の数字という認識しやすい文字・固定されたフォーマットであるため、文字認識や登録番号かどうかの判断が容易
  • インボイス制度開始後、想定外のパターンが来た場合でも柔軟にアドホックな対応が行える
  • 制度開始前には登録番号が記載されたデータが少ないため、そもそも機械学習モデルを構築しようとしても難しい

事前に想定できた対応(ハイフンの有無、'登録番号'というラベルの有無など)は行っていましたが、インボイス制度が始まるまでは正直どうなるかわからない状態でした。

いざインボイス制度が開始…!!!

10月のインボイス制度スタートに伴い、大量の適格請求書がバクラクにアップロードされるようになりました。(もちろん事前にインボイス対応を完了されていた企業様もいらっしゃいましたが、10月に入ってきて一気に増えました)

「なるほど、そうきたか〜」と思うような様々なパターンが出てきたため、多くの企業様がどれだけ試行錯誤してインボイス対応をしてきたのかが伝わりました。対応された方々、お疲れ様です…!!

今回はその登録番号の一部をご紹介しようと思います!

エントリーNo.1 登録番号ハンコ

インボイス制度開始前、ハンコ業界がざわざわしていたのをご存知でしょうか

news.yahoo.co.jp

飲食店などで市販の複写式領収書などをご利用されている方の場合、新たに適格番号を領収書に印字しなければなりませんが、市販の領収書のため一枚ずつ記載する必要があります。

そこで店名や住所などと同様に、適格番号のハンコを押せばいいじゃないか!という発想です。なるほどですね!!

一方でハンコを導入したはいいものの、ハンコを押せるスペースが限られていたためか、こんなパターンもありました

領収書に記載されている登録番号が直角になっている
領収書に記載されている登録番号が直角になっている

領収書に記載されている登録番号が斜めになっている
領収書に記載されている登録番号が斜めになっている

※実際に存在する適格請求書を元にLayerXで作成した領収書サンプル画像です
※画像内で色付けされている箇所が実際に「バクラク経費精算」が読み取っている箇所です

この件については、特に問題なく読み取ることができました。

というのも、バクラクのOCRは最初に文字認識を行っているのですが、この段階で文字の向きが異なっていても読み取れるようになっていました。そのため、登録番号が直角や斜めに表示されていても問題なく読み取ることができました!

エントリーNo.2 レシートの台紙に適格番号が印字されている

これも一部界隈で話題になったので、ご存じの方もいるのではないでしょうか

レシートの台紙に登録番号が印字されている
台紙に印字されている…!!

レシートの台紙の紙に印刷してしまえば、プリントする内容は何も変更しなくてよくなります!

こちらに関しても、登録番号はどこかしらの部分ではっきり印字できているものがあるため、その部分を読めれば問題ありませんでした。

(一方でこのタイプは、他の読み取りたい項目に被ってしまうと悪影響が出る可能性があり、登録番号の読み取りとは別の観点で辛いところがあったりします)

エントリーNo.3 登録番号が改行されている

登録番号が改行されている
登録番号が改行されている

これを見た時は、事前に想定していなかったパターンだったため驚きました…!

初期リリースバージョンではさすがに読み取れていなかったため、改行しても判定できるようにシュッと修正して対応しました!

エントリーNo.4 自社の登録番号が記載されている

請求書には基本的に発行主の適格番号が記載されていれば良いのですが、親切にも宛先の適格番号=自社の適格番号が記載されるケースもありました(受け取り請求書の場合)

このパターンの請求書では、誤って自社の登録番号を読み取ってしまうケースが発生しました。

自社の登録番号も記載された請求書
自社の登録番号も記載された請求書

こちらに関しては、現在進行形で自社の登録番号が読み取られないように対応を進めています!(もし今すぐ除外できないと困る!とお困りの方がいらっしゃれば、サポートまでお問い合わせください)

11/30追記 自社の登録番号を読み取らない(読み取っても除外する)対応を行い、こちらリリースされました!

support-invoice.layerx.jp

エントリーNo.5 仲介業者様の登録番号が記載されている

請求書には通常、発行者と宛名(請求書を受け取った場合は自社)が記載されています。しかし、取引の間に仲介業者が介在すると、仲介業者が代理で請求書を発行することがあります。

(例えばECサイトを運営されている企業様(仲介業者)が、店舗出店されている企業様に代わって代理で請求書を発行されている、など)

このケースで仲介業者様の登録番号が記載されていると、誤って仲介業者様の登録番号を読み取ってしまうケースが発生しました。

仲介業者の登録番号が記載された請求書
仲介業者の登録番号が記載された請求書

こちらに関しては代理発行の場合は仲介業者様の登録番号を読み取らないように、アドホック対応を入れました…!!(一部の代理発行のパターンのみ対応)

まだまだあるよ、こんな登録番号!!!

未対応のものを含め、他にも様々なパターンが出てきました。

  • 登録番号は記載されているが適格請求書ではない(適格請求書としては使えません、という記載がある)
  • 登録番号が括弧()で囲われている
  • 登録番号のTが〒や別の数字として文字認識してしまう

今後もまだ出てくると思いますので、都度対応していこうと思います!!

爆速対応できた秘訣

今まで紹介してきたものの一部は、プレスリリースにも出ています!

bakuraku.jp

インボイス制度開始1ヶ月でこのような爆速対応が可能だった背後には、以下のような理由があります!!

OCRの技術選定が優れていた

高精度な文字認識を利用していたため、文字の向きが異なっていても読み取れ、想定外のパターンにも対応ができていました!また、登録番号の読み取りをルールベースで実装していたため、アドホックな対応を低コストで行えました!

インボイス制度開始直後の対応準備が整っていた

インボイス制度開始後、予期しないパターンによる対応する必要があると予測できたため、あらかじめリソースを確保しておきました。そのおかげで、最優先で登録番号の読み取り対応を行うことができました!

ファクトベースで優先度付けをした

インボイス制度開始直後は混乱もあって問い合わせ数が急増し、問い合わせに一つづつ対応するのが困難な状態でした。そのため、まずお問い合わせ内容やアップロードされた書類のデータを分析しました。それにより、どれだけのお客様に影響があるのか、どの程度事象が発生しているのかを分析し、重要度と優先度を判断しました。

この優先度順の対応により、よりインパクトの大きい問題から解決し、多くのお客様により早くバクラク体験を提供できました!

LayerX行動指針 FactBase
LayerX行動指針 FactBase

凡事徹底でやり切った

単体で見れば、単純な修正で改善できる事象が多かったのですが、とにかく量を捌く必要がありました!大量の問い合わせにもめげず、凡事徹底でやり遂げました!

リリースまでのフローが多く自動化されていた

開発から単体テスト、リグレッションテスト、デプロイまで、多くのフローが自動化されていました。そのため、素早く安全にデリバリーできる環境が整っていました!

まさに、LayerXの開発速度が速い #とは ですね!!

speakerdeck.com

ありがたいことに、アップデートの度に「読めるようになった!感動した!」「待ってました!」といった喜びの声をたくさんいただきました。

それほど多くのお客様がインボイス制度に非常に苦労されているのだなと実感し、引き続き爆速開発でバクラク体験を提供していこうと思います!!

さいごに

今回は様々な登録番号パターンの紹介をしてきましたが、いかがでしたでしょうか。

登録番号に関してはルールベースでアドホック対応をごりごりと進めましたが、タスクや項目によっては機械学習モデルが動いています。

お客様が真に求めるのはどのような体験なのか、そのためにはどういう手法でどのように開発したらお客様にバクラク体験が提供できるのか、それらを考え抜いて技術選定を行なっています。

一緒にお客様にバクラク体験を提供していきませんか?

We are hiring!!

LayerXでは一緒に働く仲間を募集しています!!

私個人でもカジュアル面談フォームを作っていますので、お気軽にどうぞ!!

jobs.layerx.co.jp

layerx.co.jp

Engineering Career Ladderを作るときに気をつけたこと 其の一

この記事はLayerXテックアドカレ2023の4日目の記事です。 昨日は@shun_takさんが「バクラクのデータは難しくて面白い」を書いてくれました。 明日は機械学習チームのyakipuさんの記事が公開予定となっています。楽しみですね!


こんにちは、すべての経済活動をデジタル化し、ハタラクをバクラクにしたいmakogaです。

私のチームであるEngineering Officeは「人とチームの観点からエンジニアリング組織のパフォーマンスを最大化する」というミッションを持ち、組織の仕組みの設計や運用改善を行っています。その1つにEngineering Career Ladder*1の策定があり、10月から一部のRoleで仮運用を開始しています。

Engineering Career Ladderは上手に運用すれば強力なツールとなりますが、下手をすると生産性の悪化や成長の妨げになる可能性があります。

このエントリでは策定・仮運用の過程で気をつけたことを紹介します。全てを書ききれなかったのでタイトルに其の一を付けました。特に「背景・課題についてもっとしっかり書いたほうがいいのでは?」という声が社内からあったのですが、長くなりすぎるので今回は割愛しました。読んでくれた人からの声があれば、背景・課題、進め方、3つの軸、項目の詳細、議論が分かれたところ、仮運用の結果、他の職種への広げ方など、其の二、其の三も近いうちに書いていきたいと思います。

本当に今必要なのかを考え、共有する

LayerXはコンパウンドスタートアップとして、コンパクトなチームで爆速開発してきました。バクラク事業においてはサービス開始から2.5年で6つのサービスをリリースし、利用社数は7,000社を超え、事業としてもSaaSの成長モデルである「T2D3」を大きく超える成長率で拡大を続けています。*2

https://assets.st-note.com/img/1698248230119-OIui7RoaMN.png?width=2000&height=2000&fit=bounds&format=jpg&quality=85

この急成長期にEngineering Career Ladderを策定し、運用を開始する必要はあるのでしょうか?Engineering Career Ladderは策定だけでも軽いものではありませんし、運用が始まると従業員の時間を取られます。本当に今必要なのでしょうか?

結果として私たちはプロジェクトを進めることを選択しました。そして、ベータ版を社内に公開しコメントを受け付けるときには、下記を共有しました。

  • プロジェクト発足時に抱えていた課題・背景
  • 現状
    • 社員数と1年以内の入社者の割合
    • プロダクトロードマップとそれをもとにした人員計画
  • Engineering Career Ladderの意義

このプロジェクトを進めることを選択した理由は1つではありませんが、その中でも1番のリスクだと考えたのは組織の急拡大により今まで大事にしていた文化や習慣が薄れていくことでした。私が今年の4月に入社してからも新規プロダクトのためのチームが増えました。また、ここ半年でEMになった3人は、入社3ヶ月以内でした。このような成長を続けながら、良い文化を維持し、アップデートし続けるためのツールの1つとしてEngineering Career Ladderを運用および改善していきたいと思います。

詳細の前に大枠の考え方を示す

今回策定し仮運用を開始したEngineering Career Ladderには、各グレードごとに3つの軸があり、軸ごとに複数の項目があります。それにより各グレードで求められる考え方や行動、スキルが理解しやすくなっています。

しかし、現時点では全ての項目が詳細に記載されているわけではなく、項目を読むだけで完全に理解することは難しい状態です。

そこで、もう一段大枠の考え方を示すために高いグレードに求めることを記載することにしました。

私は過去の職場でも同じようなグレード定義を策定したことがありましたが、高いグレードに求めることを一言で説明できていませんでした。

今回は代表取締役CTO兼LayerX LLM Labs所長の松本、事業部執行役員(Enabling)の名村、事業部執行役員(CTO/CPO)の榎本などとディスカッションしていくなかで「高いグレードに求めるのは不確実性が高いことを任せられること」と定義できました。

もともと私は下記「不確実性が高いとは」のような考え方をしており、それとも合致し、とてもスッキリしました。

この高いグレードに求めることという大枠の考え方があることで、理解しやすくなったと思います。

不確実性が高いとは

不確実性の高さは「対象の大きさ・複雑さ x 時間軸 x コミュニケーションおよび影響の範囲」で決まる。

Grade 対象の大きさ・複雑さ 時間軸 コミュニケーションおよび影響の範囲
5 会社 複数年 経営メンバー、会社全体、業界
4 事業 3年 事業責任者、事業全体、社外コミュニティ
3 プロダクト 1年 チーム内外のマネージャ、プロダクトに関わる全メンバー
2 ユースケース 半年 プロダクトに関わる全メンバー
1 機能 3ヶ月 プロダクト系メンバー

業績評価を保証するものではない

社内のドキュメントには「ここに記載されているスキルを身につけ、記載されているとおりに行動することがそのまま高い評価に直結するわけではありません」と記載しています。

Engineering Career Ladderはあくまでも目標達成と能力開発の道しるべであり、高い業績評価や昇給を約束するものではありません。チームのアウトカムを増やすためにはどうすればいいか、個人のパフォーマンスを向上させるにはどうすればいいか、これらをEMとメンバーが検討するときのコミュニケーションツールとして活用することが重要です。

評価の際には、Engineering Career Ladderを共通言語として利用し、評価会議での議論や評価フィードバックを向上させていきたいと思います。

また、アンチパターンの一つに星取表のようになってしまうことがあると思います。グレードごとにスキルとコンピテンシーの表があり、そのマス目を埋めていくと自動的に昇給していくというような仕組みです。これは、顧客やチームではなく自分のスキルアップに目が向いてしまい、今必要なことやアウトカムを出すことに集中できないリスクがあるので個人的にはオススメできません。

そして、このような制度やツールを組織内での変化の速さや外部環境の変動にリアルタイムに追随することは現実的ではありません。それもあり、評価と直結させず、柔軟に運用しながら進化させていき、中長期のキャリアを支援していくことがよいと考えています。

最後に

このエントリはEngineering Career Ladderを作るときに気をつけたことを3つほど紹介しました。他にもっとこういうことが知りたいということがあればSNSなどで発信してもらえると嬉しいです。

また、自社のLadderと比較しながら具体的に話したい方はカジュアル面談を開いてますのでこちらから応募ください。

jobs.layerx.co.jp

【積極採用中です】 急成長しているプロダクト・組織を、真っ当なエンジニアリングで加速させることに興味がある方はウェルカムです!

open.talentio.com

*1:そもそもEngineering Career Ladderとは?という人は、DropboxやCircle CIなど多くの企業が公開していますので、”Engineering Career Ladder”もしくは”Engineering Career Ladder”+社名で検索してみてください。

*2:コンパウンドスタートアップの理解を深めたいという方は事業部長の牧迫のnoteがオススメです→コンパウンドスタートアップにおけるケイパビリティ・マネジメント|maki@LayerX

Web系ソフトウェアエンジニアが機械学習エンジニアに囲まれて働く面白さ

この記事はLayerXテックアドカレ2023の2日目の記事です。 昨日はconvtoさんが「つくってまなぶ静的解析のすすめ」を書いてくれました。 次回はData&ML部の部長のgiwaさんが渾身の記事を書いてくれます。

こんにちは、未来の希望を実装したいTomoakiです。

今回は、機械学習チームで働くソフトウェアエンジニアの魅力について書きたいと思います。※ 注)ここでソフトウェアエンジニアとは、「機械学習エンジニアではない」という意味で使ってます。

私は機械学習チームでWebのソフトウェアエンジニア的な仕事とMLOpsエンジニア的な仕事を担っています。

Webのソフトウェアエンジニアの側面としては、リクエストに対して前処理、推論、後処理をするAPIサーバーの開発であったり、データセット作成のために内製しているアノテーションツールの開発をしています。

また、MLOpsエンジニアの側面としては、ログを集めて精度モニタリングできるようにデータバッチやダッシュボードを開発したりと、モデルの改善に必要なこと全般をしています。ルールベースなどの簡易的なモデルで対応する場合、ソフトウェアエンジニアがパイプライン全部を担うこともあります。

tech.layerx.co.jp

バクラクについて

バクラクは、企業の支出管理を起点にバックオフィス業務を効率化するSaaSを展開しており、企業取引の前段となる「稟議の統一」と「債権・債務の一元管理」を通じてなめらかな業務連携により企業経営を加速させます。

具体的には、請求書の受け取りから仕訳作成をサポートするサービスや稟議のサービス、法人カードなどを展開しています。 bakuraku.jp

バクラクでの機械学習チームの取り組み

AI-OCR機能

バクラクは複数のプロダクトを展開していますが、そのほとんどのサービスにOCRが組み込まれており、ファイルをアップロードすると必要な項目の読み取りが行われます。

例えば、バクラク請求書というサービスでは、請求書のOCRによって経理の方が請求書の内容を手入力する手間を削減しています。 この手間は決して軽視できるものではなく、転記作業の専任の部隊を設けている会社様も多いです。

実際の事例でいうと、月間1,900枚の請求書処理をされているお客様の場合、1人あたり平均月10時間の工数削減につながっています。(もちろんOCRだけのおかげではないですが) エンタープライズ企業になると経理担当者だけで100人を超えてくるので、市場の大きさを感じます。 bakuraku.jp

証憑マッチング機能

世の中の経理担当者は、法人カードが利用された後の証憑の回収に苦労しています。

月末になると、経理担当者が各所を走り回って証憑回収しているのを見たことがある人も多いでしょう。 弊社でも、この機能がリリースされる前には「Datadogの領収書、〇〇さんに届いてないですか?」みたいなやり取りをslackで何度も見てきました。

経理担当者は証憑を集めて終わりではありません。証憑を受け取った後、経理担当者は膨大なカードの決済履歴の中から証憑に紐付け決済を見つけ出し紐付けをする必要があります。

証憑マッチング機能では、証憑の写真を撮るだけで内容を読み取り、カード取引に自動で紐付けを行います。 デモ動画がこちら。

layerx.storylane.io

その他

上記に挙げた例以外にも、公開していないものも含めて推薦系やLLM系など、さまざまなプロジェクトがあります。

ソフトウェアエンジニアとして面白い点

私自身はもともとWeb系の開発を中心にやってきた人間で、機械学習についての知識は全くありませんでしたが、実際にバクラクの機械学習チームで働いていると、今までにない面白さを感じます。 それらをいくつか紹介していきたいと思います。

機械学習がプロダクト価値のど真ん中にある

これは決してソフトウェアエンジニアの視点に限った話ではありませんが、非常に重要なことだと思います。

バクラクを利用されているお客様の導入理由や導入事例を見ていると、AI-OCR機能が特に気に入っていただけているところが非常に多いです。

また、機能単体だけでなく、プロダクト全体としてもAI-OCR機能をはじめとした機械学習をフルに活用できるように体験設計しています。

例えば、経費精算のプロダクトでは、どうやったら迷わず楽に申請ができるかを考えた結果、AI-OCR機能を起点としたUI・UX設計に辿り着きました。 まさに機械学習がプロダクト価値の中心にあると感じています。

弊社の行動指針に「Bet Technology」というものがありますが、プロダクト作りもまさに技術にbetしており、プロダクト作りに取り組んでいます。

Bet Technologyは5つある行動指針の一つ

機械学習がプロダクト価値の中心にあるからこそ、ソフトウェアエンジニアが果たすべき責務は大きいです。 各プロダクトから呼び出されるAPIの保守運用や精度のモニタリング用のデータ整備、アノテーション用のアプリケーション作成など、多くの作業がありますが、これらのどこかで問題が発生するとお客様の体験に直結してしまいます。

また、お客様の体験だけを見ていても不十分で、その場しのぎの対応で乗り切っても、負債になるような対応だと未来の自分たちをどんどん苦しめることになります。 構成も複雑でデータ量も多いため、少しの負債が積もって収拾のつかない事態につながりかねないので、日々頭を悩ませながら開発していますが、開発者としては非常に面白いです。

また、アノテーションやデータセットの作り方などでもモデルの精度が変わってくるため、機械学習エンジニアでなくてもモデルの精度向上に貢献できることも魅力の一つです。

半年に1プロダクトリリース故に新機能の要望が溢れている

バクラクでは半年に1プロダクトのペースで新しいプロダクトをリリースしています。コンパウンドスタートアップとして、複数のプロダクトの立ち上げに注力しています。

note.com

新しいプロダクトの企画が始まるたびに、PMから機械学習チームへさまざまな相談が寄せられます。 ブログを書いている今もちょうど、新機能のMVPを作り終えたばかりでお客様の反応を早く見たくてワクワクしています。

機械学習がプロダクト価値のど真ん中にあるプロダクトがどんどんリリースされるのは最高ですね!

全員MLOpsエンジニア、全員機械学習エンジニア

機械学習チームは、機械学習バックグラウンドのあるメンバーと私のようにソフトウェアエンジニア出身のメンバーで構成されていますが、垣根はかなり低いと感じます。

たとえば、Kaggle GrandMasterのshimacosさんがAPIの開発やアノテーション基盤のフロントエンドの開発をたまに担当していたり、私自身も毎週開催されている機械学習勉強会に参加し、発表もしています。

layerx.notion.site

機械学習系の機能は構成が複雑なため、チームで協力することが不可欠ですが、お互いが自分の領域を越境する姿勢があるからこそ、開発が回っていると感じます。

ソフトウェアエンジニアとしても、機械学習エンジニア寄りの仕事をするときは、未知の領域を開拓している感じがあり、ワクワクします!

ちなみに全員〇〇はLayerXの羅針盤の一つです。

最後に

機械学習チームでは、ソフトウェアエンジニアを大大大募集しています。MLOpsの経験がある方もない方も大歓迎です!興味がある方は、ぜひカジュアル面談しましょう!

jobs.layerx.co.jp

また、関係ないですが、昨日コーポレートサイトがリニューアルされて、とてもカッコよくなりましたので、ぜひご覧ください layerx.co.jp

つくってまなぶ静的解析のすすめ

はじめに

こんにちは、LayerXの id:convto です。

そしてこれは LayerX アドベントカレンダー (概念) の1日目の記事です。

アドベントカレンダー盛り上げていくぞ〜ということで11月から始まるらしいです。だいぶフライングしてるけど枠もかなり埋まっててすごい。

せっかくなのでお祭り参加したいぞ〜ということで一発目です。よろしくお願いします。

静的解析つくろうとしたきっかけ

ちょうどつい最近記事になった下記の輪読会がきっかけでした。

tech.layerx.co.jp

このなかで、mapのrange accesssについて、元mapのcopyを取らないから破壊するとループ挙動が壊れる可能性がある旨が言及されていました。

そのときは雑談で「range accessしてるmapに再代入してたら怒る!みたいな考え方で整理したら静的解析できそうっすよね〜」みたいな話をしたんですが、そういえば Go で静的解析ツール作ったことなかったなと思いちょうどいいので手を動かしてみた次第です。

Go はこのあたりのエコシステムがかなり良くできていて、標準/準標準で以下のようにかなりのパッケージがホストされています。(まだまだほかにもある)

また、go/typesについてはめちゃめちゃよいドキュメントもあります。
イメージ的にはgo/astよりさらに抽象的なAPIというかんじで、実際のASTからより利用しやすい形でいろいろ提供してくれてるやつです。

github.com

エコシステムも整っていて、かつちょうどいいモチベーションもあったので、それはやるしかないでしょうということでやってみました。

つくったもの

github.com

やりたかったこと

発想としてはmapに対してfor rangeしてるときに、ループ内で自身のmapを書き換えてるところを見つけたら怒る!という感じです。

mapのサイズが変わるようなコードでなければloopの挙動自体には影響出ないかもしれませんが、今回は簡単のために代入時点で警告を出すロジックで考えます。

典型的には以下のようなケースですね。

m := map[string]string{"a": "item a", "b": "item b"}
for k, v := range m {
    m[k] = "reassigned: " + v // want detection
}

基本は hoge[key] = val の形になってるとき hoge がrangeでぶん回してるmap自身だったら危険性のある書き込みということで警告すればよいはずです。

この書き方だけではarray/sliceへの代入か判断できないので、うまく静的解析して型情報を取り出してmapのみに限定した処理としたいです。

また、パッケージを跨いだ以下のようなパターンでも検出も行いたいので、必要があれば依存関係の解決もする必要があるのが面倒なところです。

-- go.mod --
module maptest

go 1.21

-- pkg/map.go --
package pkg

var M = map[string]string{"a": "item a", "b": "item b"}

-- main.go --
package main

import (
    "maptest/pkg"
)

func main() {
    for k, v := range pkg.M {
        pkg.M[k] = "reassigned: " + v // want detection
    }
}

x/tools/go/analysis を使って側をつくる

x/tools/go/analysis は静的解析の結果をモジュール化して依存関係を明言しながら解析処理を作ったり、 go vet に食わせられるコマンドをシュッとつくれたりするえらいやつです。

pkg.go.dev

過去の事例もめっちゃあるのでここの紹介はほどほどにしますが、以下みたいなノリでAnalizerを作れます。

package mapbreak

import (
    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
    Name: "mapbreak",
    Doc:  Doc,
    Run:  run,
    Requires: []*analysis.Analyzer{
        inspect.Analyzer,
    },
}

const Doc = "mapbreak detects if there is map reassignment in the range access"

func run(pass *analysis.Pass) (interface{}, error) {
    inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

    nodeFilter := []ast.Node{
        (*ast.RangeStmt)(nil),
    }

    inspect.Preorder(nodeFilter, func(n ast.Node) {
        // ここに君だけの最強の静的解析処理を書こう
    }
  }
}

あいたところに君だけの最強の処理を書けばOKです。

"golang.org/x/tools/go/analysis/passes" には便利げなAnalizerがいろいろ準備されてて、今回使ってるpasses/inspectはよしなにASTのinspectorを返してくれます。

pkg.go.dev

今回つかってる inspcet.Preorder() は特定のノード種別を指定して探索してくれるやつで、まあ静的解析やりたい時は何かしら目的があるだろうから大体これ使っとけばじゃないかという感じがあります。

go vet に渡せるコマンドはこういう感じで作れます。

package main

import (
    "github.com/convto/mapbreak"
    "golang.org/x/tools/go/analysis/singlechecker"
)

func main() { singlechecker.Main(mapbreak.Analyzer) }

main関数に突っ込んで準備されてるやつに食わせるだけです。便利!

これで以下の感じで go vet に食わせられます。

$ go vet -vettool=$(which mapbreak) ./... 

テストの準備をする

よしななテストの機能も提供されています。それが以下です。

pkg.go.dev

testdata 配下によしなに対象となるコードを配置できて、いい感じに探索してくれる優れものです。
ディレクトリ名でpatternもわけられるので、以下のようにテストが書けます。

func Test(t *testing.T) {
    testdata := analysistest.TestData()
    patterns := []string{
        "target_pattern",
    }
    for _, pattern := range patterns {
        pattern := pattern
        t.Run(pattern, func(t *testing.T) {
            t.Parallel()
            analysistest.Run(t, testdata, mapbreak.Analyzer, pattern)
        })
    }
}

また go.modがあるとgo modulesを利用する挙動 なので、パッケージをimportするようなテストも書けます。(めちゃundocumentedでもう少しよくしたい〜というコメントもあるのでそのうちやり方が変わる可能性あり)

testdata 配下にpatternに一致するようなディレクトリをつくりテストで解析対象としたい Go コードを書けばよいです。

そのさい、検出を期待する場合は // want: "message" のように書くことでテストできます。

package main

func main() {
    m := map[string]string{"a": "item a", "b": "item b"}
    for k, v := range m {
        m[k] = "reassigned: " + v // want "detected range access to map and reassigning"
    }
}

これでテストの準備は万端!ではやっていこう!

ASTを眺めて実装のイメージを固める

以下を使うと実際のCLIで実際のコードのASTが見れます。

pkg.go.dev

ためしに以下のコードで確認すると

m := map[string]string{"a": "item a", "b": "item b"}
for k, v := range m {
    m[k] = "reassigned: " + v
}

こういう感じの出力が出ます。(長いので畳みます)

gotype -ast の実行結果

$ gotype -ast singlefile.go
     0  *ast.File {
     1  .  Package: singlefile.go:1:1
     2  .  Name: *ast.Ident {
     3  .  .  NamePos: singlefile.go:1:9
     4  .  .  Name: "main"
     5  .  }
     6  .  Decls: []ast.Decl (len = 1) {
     7  .  .  0: *ast.FuncDecl {
     8  .  .  .  Name: *ast.Ident {
     9  .  .  .  .  NamePos: singlefile.go:3:6
    10  .  .  .  .  Name: "main"
    11  .  .  .  .  Obj: *ast.Object {
    12  .  .  .  .  .  Kind: func
    13  .  .  .  .  .  Name: "main"
    14  .  .  .  .  .  Decl: *(obj @ 7)
    15  .  .  .  .  }
    16  .  .  .  }
    17  .  .  .  Type: *ast.FuncType {
    18  .  .  .  .  Func: singlefile.go:3:1
    19  .  .  .  .  Params: *ast.FieldList {
    20  .  .  .  .  .  Opening: singlefile.go:3:10
    21  .  .  .  .  .  Closing: singlefile.go:3:11
    22  .  .  .  .  }
    23  .  .  .  }
    24  .  .  .  Body: *ast.BlockStmt {
    25  .  .  .  .  Lbrace: singlefile.go:3:13
    26  .  .  .  .  List: []ast.Stmt (len = 2) {
    27  .  .  .  .  .  0: *ast.AssignStmt {
    28  .  .  .  .  .  .  Lhs: []ast.Expr (len = 1) {
    29  .  .  .  .  .  .  .  0: *ast.Ident {
    30  .  .  .  .  .  .  .  .  NamePos: singlefile.go:4:2
    31  .  .  .  .  .  .  .  .  Name: "m"
    32  .  .  .  .  .  .  .  .  Obj: *ast.Object {
    33  .  .  .  .  .  .  .  .  .  Kind: var
    34  .  .  .  .  .  .  .  .  .  Name: "m"
    35  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 27)
    36  .  .  .  .  .  .  .  .  }
    37  .  .  .  .  .  .  .  }
    38  .  .  .  .  .  .  }
    39  .  .  .  .  .  .  TokPos: singlefile.go:4:4
    40  .  .  .  .  .  .  Tok: :=
    41  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
    42  .  .  .  .  .  .  .  0: *ast.CompositeLit {
    43  .  .  .  .  .  .  .  .  Type: *ast.MapType {
    44  .  .  .  .  .  .  .  .  .  Map: singlefile.go:4:7
    45  .  .  .  .  .  .  .  .  .  Key: *ast.Ident {
    46  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:4:11
    47  .  .  .  .  .  .  .  .  .  .  Name: "string"
    48  .  .  .  .  .  .  .  .  .  }
    49  .  .  .  .  .  .  .  .  .  Value: *ast.Ident {
    50  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:4:18
    51  .  .  .  .  .  .  .  .  .  .  Name: "string"
    52  .  .  .  .  .  .  .  .  .  }
    53  .  .  .  .  .  .  .  .  }
    54  .  .  .  .  .  .  .  .  Lbrace: singlefile.go:4:24
    55  .  .  .  .  .  .  .  .  Elts: []ast.Expr (len = 2) {
    56  .  .  .  .  .  .  .  .  .  0: *ast.KeyValueExpr {
    57  .  .  .  .  .  .  .  .  .  .  Key: *ast.BasicLit {
    58  .  .  .  .  .  .  .  .  .  .  .  ValuePos: singlefile.go:4:25
    59  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
    60  .  .  .  .  .  .  .  .  .  .  .  Value: "\"a\""
    61  .  .  .  .  .  .  .  .  .  .  }
    62  .  .  .  .  .  .  .  .  .  .  Colon: singlefile.go:4:28
    63  .  .  .  .  .  .  .  .  .  .  Value: *ast.BasicLit {
    64  .  .  .  .  .  .  .  .  .  .  .  ValuePos: singlefile.go:4:30
    65  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
    66  .  .  .  .  .  .  .  .  .  .  .  Value: "\"item a\""
    67  .  .  .  .  .  .  .  .  .  .  }
    68  .  .  .  .  .  .  .  .  .  }
    69  .  .  .  .  .  .  .  .  .  1: *ast.KeyValueExpr {
    70  .  .  .  .  .  .  .  .  .  .  Key: *ast.BasicLit {
    71  .  .  .  .  .  .  .  .  .  .  .  ValuePos: singlefile.go:4:40
    72  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
    73  .  .  .  .  .  .  .  .  .  .  .  Value: "\"b\""
    74  .  .  .  .  .  .  .  .  .  .  }
    75  .  .  .  .  .  .  .  .  .  .  Colon: singlefile.go:4:43
    76  .  .  .  .  .  .  .  .  .  .  Value: *ast.BasicLit {
    77  .  .  .  .  .  .  .  .  .  .  .  ValuePos: singlefile.go:4:45
    78  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
    79  .  .  .  .  .  .  .  .  .  .  .  Value: "\"item b\""
    80  .  .  .  .  .  .  .  .  .  .  }
    81  .  .  .  .  .  .  .  .  .  }
    82  .  .  .  .  .  .  .  .  }
    83  .  .  .  .  .  .  .  .  Rbrace: singlefile.go:4:53
    84  .  .  .  .  .  .  .  .  Incomplete: false
    85  .  .  .  .  .  .  .  }
    86  .  .  .  .  .  .  }
    87  .  .  .  .  .  }
    88  .  .  .  .  .  1: *ast.RangeStmt {
    89  .  .  .  .  .  .  For: singlefile.go:5:2
    90  .  .  .  .  .  .  Key: *ast.Ident {
    91  .  .  .  .  .  .  .  NamePos: singlefile.go:5:6
    92  .  .  .  .  .  .  .  Name: "k"
    93  .  .  .  .  .  .  .  Obj: *ast.Object {
    94  .  .  .  .  .  .  .  .  Kind: var
    95  .  .  .  .  .  .  .  .  Name: "k"
    96  .  .  .  .  .  .  .  .  Decl: *ast.AssignStmt {
    97  .  .  .  .  .  .  .  .  .  Lhs: []ast.Expr (len = 2) {
    98  .  .  .  .  .  .  .  .  .  .  0: *(obj @ 90)
    99  .  .  .  .  .  .  .  .  .  .  1: *ast.Ident {
   100  .  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:5:9
   101  .  .  .  .  .  .  .  .  .  .  .  Name: "v"
   102  .  .  .  .  .  .  .  .  .  .  .  Obj: *ast.Object {
   103  .  .  .  .  .  .  .  .  .  .  .  .  Kind: var
   104  .  .  .  .  .  .  .  .  .  .  .  .  Name: "v"
   105  .  .  .  .  .  .  .  .  .  .  .  .  Decl: *(obj @ 96)
   106  .  .  .  .  .  .  .  .  .  .  .  }
   107  .  .  .  .  .  .  .  .  .  .  }
   108  .  .  .  .  .  .  .  .  .  }
   109  .  .  .  .  .  .  .  .  .  TokPos: singlefile.go:5:11
   110  .  .  .  .  .  .  .  .  .  Tok: :=
   111  .  .  .  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
   112  .  .  .  .  .  .  .  .  .  .  0: *ast.UnaryExpr {
   113  .  .  .  .  .  .  .  .  .  .  .  OpPos: -
   114  .  .  .  .  .  .  .  .  .  .  .  Op: range
   115  .  .  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   116  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:5:20
   117  .  .  .  .  .  .  .  .  .  .  .  .  Name: "m"
   118  .  .  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 32)
   119  .  .  .  .  .  .  .  .  .  .  .  }
   120  .  .  .  .  .  .  .  .  .  .  }
   121  .  .  .  .  .  .  .  .  .  }
   122  .  .  .  .  .  .  .  .  }
   123  .  .  .  .  .  .  .  }
   124  .  .  .  .  .  .  }
   125  .  .  .  .  .  .  Value: *(obj @ 99)
   126  .  .  .  .  .  .  TokPos: singlefile.go:5:11
   127  .  .  .  .  .  .  Tok: :=
   128  .  .  .  .  .  .  Range: singlefile.go:5:14
   129  .  .  .  .  .  .  X: *(obj @ 115)
   130  .  .  .  .  .  .  Body: *ast.BlockStmt {
   131  .  .  .  .  .  .  .  Lbrace: singlefile.go:5:22
   132  .  .  .  .  .  .  .  List: []ast.Stmt (len = 1) {
   133  .  .  .  .  .  .  .  .  0: *ast.AssignStmt {
   134  .  .  .  .  .  .  .  .  .  Lhs: []ast.Expr (len = 1) {
   135  .  .  .  .  .  .  .  .  .  .  0: *ast.IndexExpr {
   136  .  .  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   137  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:6:3
   138  .  .  .  .  .  .  .  .  .  .  .  .  Name: "m"
   139  .  .  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 32)
   140  .  .  .  .  .  .  .  .  .  .  .  }
   141  .  .  .  .  .  .  .  .  .  .  .  Lbrack: singlefile.go:6:4
   142  .  .  .  .  .  .  .  .  .  .  .  Index: *ast.Ident {
   143  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:6:5
   144  .  .  .  .  .  .  .  .  .  .  .  .  Name: "k"
   145  .  .  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 93)
   146  .  .  .  .  .  .  .  .  .  .  .  }
   147  .  .  .  .  .  .  .  .  .  .  .  Rbrack: singlefile.go:6:6
   148  .  .  .  .  .  .  .  .  .  .  }
   149  .  .  .  .  .  .  .  .  .  }
   150  .  .  .  .  .  .  .  .  .  TokPos: singlefile.go:6:8
   151  .  .  .  .  .  .  .  .  .  Tok: =
   152  .  .  .  .  .  .  .  .  .  Rhs: []ast.Expr (len = 1) {
   153  .  .  .  .  .  .  .  .  .  .  0: *ast.BinaryExpr {
   154  .  .  .  .  .  .  .  .  .  .  .  X: *ast.BasicLit {
   155  .  .  .  .  .  .  .  .  .  .  .  .  ValuePos: singlefile.go:6:10
   156  .  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
   157  .  .  .  .  .  .  .  .  .  .  .  .  Value: "\"reassigned: \""
   158  .  .  .  .  .  .  .  .  .  .  .  }
   159  .  .  .  .  .  .  .  .  .  .  .  OpPos: singlefile.go:6:25
   160  .  .  .  .  .  .  .  .  .  .  .  Op: +
   161  .  .  .  .  .  .  .  .  .  .  .  Y: *ast.Ident {
   162  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: singlefile.go:6:27
   163  .  .  .  .  .  .  .  .  .  .  .  .  Name: "v"
   164  .  .  .  .  .  .  .  .  .  .  .  .  Obj: *(obj @ 102)
   165  .  .  .  .  .  .  .  .  .  .  .  }
   166  .  .  .  .  .  .  .  .  .  .  }
   167  .  .  .  .  .  .  .  .  .  }
   168  .  .  .  .  .  .  .  .  }
   169  .  .  .  .  .  .  .  }
   170  .  .  .  .  .  .  .  Rbrace: singlefile.go:7:2
   171  .  .  .  .  .  .  }
   172  .  .  .  .  .  }
   173  .  .  .  .  }
   174  .  .  .  .  Rbrace: singlefile.go:8:1
   175  .  .  .  }
   176  .  .  }
   177  .  }
   178  .  FileStart: singlefile.go:1:1
   179  .  FileEnd: singlefile.go:8:3
   180  .  Scope: *ast.Scope {
   181  .  .  Objects: map[string]*ast.Object (len = 1) {
   182  .  .  .  "main": *(obj @ 11)
   183  .  .  }
   184  .  }
   185  .  Unresolved: []*ast.Ident (len = 2) {
   186  .  .  0: *(obj @ 45)
   187  .  .  1: *(obj @ 49)
   188  .  }
   189  .  GoVersion: ""
   190  }

このASTを眺めると

  • *ast.RangeStmt が range 文ぽい
  • *ast.RangeStmt.X に range access 対象がはいってそう
  • *ast.RangeStmt.Body.List にloop内の文がありそう
  • ループ内で *ast.AssignStmt になっているのが代入そう
  • *ast.AssignStmt.Lhs が左辺ぽいので、そこからよしなに値を眺めて range access 対象と比較すればよさそう
  • 同一objectだったら怒れば勝ちそう!

みたいなことがわかります。

実装してみる

このあたりのドキュメントをみると値の比較には go/types.Object が利用できそうです。

example/gotypes at master · golang/example · GitHub

ふつうに == で比較するだけでいいらしい。便利〜

実行ターゲットになってるパッケージのtype infoについては analysis.Pass に突っ込まれており、識別子から go/types/Object を引っ張り出す ObjectOf() が生えてるので *ast.Ident さえあればよしなに取り出せます https://cs.opensource.google/go/x/tools/+/refs/tags/v0.14.0:go/analysis/analysis.go;l=99

ポインタとかの場合も考慮してよしなに書くと以下のかんじです。

func getObject(pass *analysis.Pass, x ast.Expr) types.Object {
    switch x.(type) {
    case *ast.Ident:
        return pass.TypesInfo.ObjectOf(x.(*ast.Ident))
    case *ast.StarExpr:
        return getObject(pass, x.(*ast.StarExpr).X)
    case *ast.ParenExpr:
        return getObject(pass, x.(*ast.ParenExpr).X)
  }
    return nil
}

実行ターゲットに含まれてないパッケージでExportedなmapをもってる可能性もあるのでそのあたりもよしなに考慮したいので以下も追加します。 *ast.SelectorExprfmt.Println() みたいなimportしたパッケージへのアクセスなどに使われます。あとメソッドへのアクセスにも使われます。

analysis.Pass にはまたまた types.Package とかいう便利なやつが突っ込まれており、importしてるpackageなどが取れます。 https://cs.opensource.google/go/x/tools/+/refs/tags/v0.14.0:go/analysis/analysis.go;l=98

パッケージの名前 と *ast.SelectorExpr.X.Name ( fmt.Println() でいう fmt) の値が一致してたら、該当のパッケージのscopeとして types.Object をさがしにいく感じにすればよいです。

func getObject(pass *analysis.Pass, x ast.Expr) types.Object {
  ...
  case *ast.SelectorExpr:
        sel := x.(*ast.SelectorExpr)
        id, ok := sel.X.(*ast.Ident)
        if !ok {
            return nil
        }
        pkg := id.Name
        call := sel.Sel.Name
        imports := pass.Pkg.Imports()
        for i := range imports {
            if pkg == imports[i].Name() {
                return imports[i].Scope().Lookup(call)
            }
        }
    }
    return nil
}

ちなここでつかってる Lookup() は賢くて、中で既にloadずみのやつをなんども読み取らないように sync.Once をちゃんと使ってくれているので気にせずしばいてOK

https://cs.opensource.google/go/go/+/refs/tags/go1.21.3:src/go/types/scope.go;l=251-274

あと、今回はmapだけ判断したいので以下みたいな感じでとってきたObjectをmapにできると良さげな感じがします。

rangeTarget := getObject(pass, rstmt.X)
if rangeTarget == nil {
    return
}
_, ok = rangeTarget.Type().Underlying().(*types.Map)
if !ok {
    return
}

ここで rangeTarget.Type() が pointer のことも考慮すると、型をderefする処理もあると良さそうなので書く

func run(pass *analysis.Pass) (interface{}, error) {
  ...
  inspect.Preorder(nodeFilter, func(n ast.Node) {
    ...
    _, ok = deref(rangeTarget.Type()).Underlying().(*types.Map)
        if !ok {
          return
        }
    ...
  }
  return nil, nil
}
...
func deref(typ types.Type) types.Type {
    if ptr, ok := typ.Underlying().(*types.Pointer); ok {
        return deref(ptr.Elem())
    }
    return typ
}

これでよしなに動くやつになった!

全体像は以下

package mapbreak

import (
    "go/ast"
    "go/types"

    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
    Name: "mapbreak",
    Doc:  Doc,
    Run:  run,
    Requires: []*analysis.Analyzer{
        inspect.Analyzer,
    },
}

const Doc = "mapbreak detects if there is map reassignment in the range access"

func run(pass *analysis.Pass) (interface{}, error) {
    inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

    nodeFilter := []ast.Node{
        (*ast.RangeStmt)(nil),
    }

    inspect.Preorder(nodeFilter, func(n ast.Node) {
        rstmt, ok := n.(*ast.RangeStmt)
        if !ok {
            return
        }

        rangeTarget := getObject(pass, rstmt.X)
        if rangeTarget == nil {
            return
        }
        _, ok = deref(rangeTarget.Type()).Underlying().(*types.Map)
        if !ok {
            return
        }

        for _, stmt := range rstmt.Body.List {
            assign, ok := stmt.(*ast.AssignStmt)
            if !ok {
                continue
            }
            idx, ok := assign.Lhs[0].(*ast.IndexExpr)
            if !ok {
                continue
            }
            assignee := getObject(pass, idx.X)
            if assignee == rangeTarget {
                pass.Reportf(stmt.Pos(), "detected range access to map and reassigning it")
            }
        }
    })

    return nil, nil
}

func deref(typ types.Type) types.Type {
    if ptr, ok := typ.Underlying().(*types.Pointer); ok {
        return deref(ptr.Elem())
    }
    return typ
}

func getObject(pass *analysis.Pass, x ast.Expr) types.Object {
    switch x.(type) {
    case *ast.Ident:
        return pass.TypesInfo.ObjectOf(x.(*ast.Ident))
    case *ast.StarExpr:
        return getObject(pass, x.(*ast.StarExpr).X)
    case *ast.ParenExpr:
        return getObject(pass, x.(*ast.ParenExpr).X)
    case *ast.SelectorExpr:
        sel := x.(*ast.SelectorExpr)
        id, ok := sel.X.(*ast.Ident)
        if !ok {
            return nil
        }
        pkg := id.Name
        call := sel.Sel.Name
        imports := pass.Pkg.Imports()
        for i := range imports {
            if pkg == imports[i].Name() {
                return imports[i].Scope().Lookup(call)
            }
        }
    }
    return nil
}

さいごに

Go の静的解析、エコシステムが成熟してるのは知ってたけど試したことはなかったのでちょうどよく作業できて良かった!

外部パッケージを含むケースでもよしなにできたりするのはめっちゃ便利で、よくできてるなと思いました。

今回みたいな型を知りたいケースは機械的な文字列一致でやるのは難しいので、高度な判定ができるのはかなりありがたかったです。

また、 https://github.com/golang/example/tree/master/gotypes をみて表現力かなりあることがわかったのでまた遊んでみたいですね!

LayerX アドベントカレンダー (概念) は今日から始まるらしいので引き続き他の記事も楽しみにしててください〜概念概念

LayerXで実践中の「ゆるい輪読会」をご紹介します

皆さんこんにちは!バクラク共通管理画面プロダクトをメインに担当している、EMのあらたまです。9月に入社して、1ヶ月ほどが経ちました。温かく迎え入れてくれたメンバーたちのおかげで、毎日いきいき楽しく働いています。

さて、LayerXには多様なバックグラウンド、多様な強みを持つエンジニアが集まっています。例えば、バクラク事業部のプロダクト開発におけるメインの言語はGoですが、入社するまでプロダクションにおいてGoでの開発経験のないメンバーもいます(私もそのうちの一人です)。それぞれがフルサイクルで価値を届けるために、お互いの得意をシェアし合うような取り組みが多数進んでおり、今日はその中でも「ゆるい輪読会」についてを取り上げたいと思います。

LayerXと輪読会

輪読会とは「同じ本を読んで議論する過程でそれぞれが学びを得る」会の総称です。自分ひとりではなかなか理解までにジャンプがある、単純に内容が難しいなど、積読になりやすい本をコツコツと読めるチャンスにもなるので、私もできるだけ参加または主催をするようにしています。

会社で輪読会を行うと、実際に自分たちのプロダクトやコードに適用するとしたら?という目線で議論ができるのもいいですね。なおLayerXでも、過去にはこんな勉強会を開催していました。
tech.layerx.co.jp

輪読会の形式は、概ね三種に大別されます。

  1. 担当を決め、持ち回りでまとめてきて、発表されたものを聞く
  2. 事前に読んできて、感想のみをディスカッションする
  3. 事前準備なくその場で読み、まとめたりディスカッションしたりする

それぞれにメリット・デメリットがありますが、どの論点もざっくりとまとめると「単位時間あたりの得られる学び」と「拘束時間」とのトレードオフが焦点になるのではと思います*1

また、概して勉強会は、モチベーションの観点からも「立ち上げる」より「続ける」ほうが大変ですよね。なので今回は、最近私たちが実践している「ゆるい輪読会」=「続けるのがつらくない、でもしっかりと学びにつながる輪読会」の方法をご紹介します!

ゆるい輪読会

輪読会のルール。事前準備なしで音読形式でリレーします。途中で質問やディスカッションを挟んでもOK
輪読会のトップページより

基本的に事前準備は不要です。週によっては業務負荷が集中することもあるため、長続きするポイントにもなっていると思います。私は事前に読んでおいて、よくわからなかったところ・議論したいところなどをまとめておくこともありますが、オンボード期間中ということもあり、あまり気負いすぎないようにしています。

現在はこのルールで「Go言語 100Tips ありがちなミスを把握し、実装を最適化する」を読んでいます。この本は章あたりの分量もさほど多くなく、1回あたり2章ずつ程度で進められています。

LayerXにはGoに明るいメンバーも多数在籍しており、書籍の内容だけでなく、例えば「この仕様はこういった議論を経て決まった」などと関連情報を補完し合ったり、標準ライブラリの実装を随時リファレンスしたりしながら読み進めています。いち参加者として、こういった自力ではアクセスしにくい情報を関連して得られることが本当にありがたいです。福利厚生!

デメリットとしては、毎回音読のターンを取ることで、既に読んだ人にとっては冗長に感じる点があることでしょうか。それでもサンプルコードを皆と読み解きながら読み進めたり、派生議論の呼び水になったりで、結果知見の総量が増えることも多く、音読があるからこそのメリットもありそうです。

またこの形式の読み進め方が合う本・合わない本というのもあるかもしれません。読む対象、チームの状況によって柔軟に工夫していきたいですね!

おわりに

今回は、今LayerXでホットな輪読会の形式についてご紹介しました。これからもチームの状況に合わせて形式や対象を柔軟に変えながら、社内外で知識が循環する仕組みを皆で作っていけたらと思っています*2。一緒に輪読してみたい!という方、他にどんな取り組みをしているんだろう?と興味を持ってくださった方は、ぜひ一度カジュアルにお話ししましょう!

jobs.layerx.co.jp

*1:参考までに、筆者の前職では、業務時間内に読むことを原則とした上で2を選択し、ディスカッションに全振りした輪読会を行っていました

*2:個人的にはABD®の形式も、まとめとして形が残るので好みです

SaaS開発者がSaaSを導入してみて感じたこと

こんにちは!2023年に新卒としてLayerXに入社したhideです! 現在はバクラク共通管理基盤のエンジニアとして働いています。そしてGatherの伝道師でもあります。この記事はUXにこだわるSaaS(バクラク)開発者がUXにこだわるSaaS(Gather)を導入する際に感じたことを書いていきます。

Gatherとは?

Gatherとは、レトロ x 遊び心が特徴的なバーチャルオフィスです。 独自に装飾できることをはじめ、個人の机にメモをおける機能や、呼び鈴機能などバーチャルの良いところを取り入れつつ、オフィス感も残していくれている非常にUXの良いSaaSです。

Gatherオフィス

LayerX, Gatherはじめました

なにはともあれ、LayerXは1ヶ月間の無料トライアル期間を経て10月よりGatherを本格導入しました。LayerXは元々チームごとにZoomのブレイクアウトルームに分かれてバーチャルオフィスとして利用していましたが、トライアル期間の社員からのフィードバックは概ね良かったこと、Zoomで感じていた課題をGatherが良い感じに解決してくれたことが主な理由です。

Zoomで抱えていた課題とは?

Gatherが導入されるまで4~5人のチームごとにZoomのブレイクアウトルームに常に集まって適宜相談しながら仕事を進めていました。そんな運用でどんな課題があったかというと、例えばこんなやつです。

Zoomの悩み
このような課題感から、トライアル開始時に我々がGatherに期待していたこととしては

  • New Comerのメンバーの声かけのハードルを下げること
  • チーム内に閉じず、チーム横断したコミュニケーションを活発にすること
  • 偶発的な会話をオンラインでも産むこと

でした。

とはいえ!!!!

Zoomでの運用に課題はありましたが、社員全員がそれを強く感じているわけではありません。またGatherはZoomの全てのユースケースをカバーするわけではないので追加でお金がかかることになり、個人としてはGatherのことはめちゃくちゃ好きだけど、組織にとってわかりやすいメリットを提示することができず「導入されるかは微妙だな〜〜」という肌感でした。 そう、好きだけじゃお金が絡む判断はなかなかできないわけです。

そうだ、Factを見よう

LayerXには行動指針の1つとして、Fact Baseがあります。 組織にとって本当に良いものかを自分自身も、組織も納得感を持つためにFactは重要な要素の一つになると思います。

悲しい!けど正しいけど仕方ない!

Gatherの場合は、コミュニケーションのハードルを下げることが最も期待するアウトカムなため、それを測定できるFactとして、Gather上で社員ごとの会話時間と会話相手、1ヶ月間でそれらがどんな遷移をしたのかを知ることができると、最も納得感のある結論を出せると思いました。

みたいなことを、Gatherの担当者の方に問い合わせた結果…

  終
制作・著作
━━━━━
ⓁⒶⓎⒺⓇⓍ

結果的にクリティカルなFactを取ることができませんでした。ただどうしても実態を知りたかったため、今回はトライアル参加者全員にアンケートを実施しました。総勢100名の回答が集まり、その結果を見てみたところ、確かに当初の課題は解決している可能性が高いことがわかりました。

アンケート結果

あれ、Gatherって何かに似てね?

こうしてGatherを導入が決定し、ひと息ついていたところ、ふと「Gatherってバクラクに似てね?」と思うようになりました。 バクラクシリーズは現在、6つのサービスを提供しています。これらに全て共通することはプロダクト間のシームレスな情報連携による圧倒的な使いやすさです。

今はここに請求書発行も追加されています!無料体験もあるのでお試しください

特にSaaS領域では生産性を向上させるため、使いやすさにこだわったプロダクトが多い印象があります。使いやすさは非常に重要な指標である一方、その効果が非常に定量化しづらい側面を持っています。バクラクを含めたこのような全てのプロダクトにおいて、導入担当者には強く推していただけているのにも関わらず、決済者にそれを伝える指標がないために苦労されている方はいらっしゃるのではないかと妄想しています。

妄想

バクラク伝道師の皆様の力になりたい!

今回のGather導入を担当してみて、共通管理基盤のエンジニアとしてはバクラクの体験をより伝わり易くしたいと思いを馳せるようになりました。 そもそも共通管理画面は、バクラクのどのサービスを契約していてもお客様に触っていただけるバクラクの玄関のようなサービスです。玄関に求められるのは安心・安全であることと、全員が触るサービスだからこそバクラクの良さが分かりやすく伝えることがあると思います。 バクラクを安心・安全に使ってもらうことを念頭に置きながら、今後はバクラクを使った効果を色々な人に伝えるために機能開発していきます!!!!!!!!!

バクラクのデータセットを用いたLayoutLMv3による事前学習

機械学習エンジニアの吉田です。本記事では、LayoutLMv3*1というモデルをバクラクで取り扱っている帳票で事前学習を行い、それをファインチューニングして項目推定タスクに取り組んでいる話をご紹介します。

背景

LayerXで提供しているバクラクでは帳票をアップロードするだけで支払金額や支払期日などを自動で読み取り補完してくれるOCR機能があります。このOCR機能には大きく2つの処理があります。

  1. 帳票に書かれている文字列を認識し検出すること
  2. 検出された文字列から支払金額や支払期日などの項目を推定すること

2つ目の項目推定において現在はRoBERTa*2というモデルを使っています。RoBERTaでも精度高く推定することができるのですが、複雑なレイアウトの場合に誤って推定してしまうケースがどうしても発生してしまいます。RoBERTaはOCRで検出したテキストだけを使ったモデルであるためこのような複雑なレイアウトの場合に弱いのではないか、という仮説があります。しかし、バクラクにおいてOCRはコアな機能であるためどのような帳票であっても高い精度で読み取れることが期待されます。

このような背景から現在、LayoutLMv3というモデルを検証しています。LayoutLMv3はテキストだけでなく画像やbounding boxを取り込んだモデルであるため複雑なレイアウトの場合でも精度高く推定することができるようになるのではないか、という期待があります。

実は以前にもLayoutLMv3の検証をしたことはありましたが、そのときはRoBERTaの方が精度が高かったためRoBERTaを採用したという経緯があります。 そのあたりの話は以下の記事に詳しく書かれています。

tech.layerx.co.jp

しかし以前はbaseモデルでしか検証されておらず、より性能の高いlargeモデルで検証できていなかったことや、その他にも改善の余地があったため改めて検証を行いました。

LayoutLMv3

まず、LayoutLMv3とはどのようなモデルであるか簡単に説明します。

LayoutLMv3のアーキテクチャは下図のようになっています。 画像とテキストからそれぞれ埋め込みを取得し、連結したものをTransformer Encoderに入力しています。 Transformer Encoderは事前学習済みのRoBERTaで初期化します。

LayoutLMv3: Pre-training for Document AI with Unified Text and Image Masking

テキストに関してはまず文書画像に対してOCRを適用しテキストとbounding boxを取得します。 LayoutLMv3では単語レベルではなくセグメントレベルのbounding boxを採用しています。 セグメントレベルのbounding boxでは以下の図 (StructuralLM*3から引用) のように同じ意味を表している複数の単語 (背景色が同じ単語) を包含するbounding boxを用います。 論文にはセグメントレベルのbounding boxの作成方法については書かれていませんでしたが、Issuesを見るとOCRで検出した行単位のセグメントを使っているようでした*4

StructuralLM: Structural Pre-training for Form Understanding

OCRで取得したテキストをトークナイズし、事前学習済みのRoBERTaで初期化されたEmbedding Layerからトークンの埋め込みを取得します。
その後、1D, 2D position embeddingsを加算します。 1D position embeddingsはpaddingを除くトークンに対するシーケンシャルなインデックスの埋め込みです。 2D position embeddingsはbounding boxの4点と幅と高さの合計6個の埋め込みを連結しています。

次に画像に関してです。 画像はまず前処理で224 x 224にリサイズ後、パッチサイズ16 x 16のパッチに分割されます。(画像パッチの総数は14 x 14) それぞれのパッチをLinear Embeddingを通して埋め込みを取得し、先頭にCLSトークンを連結後、最後にposition embeddingsを加算します。

以上の処理はhuggingface/transformersでは LayoutLMv3Model に実装されています*5

事前学習

LayoutLMv3の事前学習はMasked Language Modeling (MLM), Masked Image Modeling (MIM), Word Patch Alignment (WPA)の3つのタスクがあります。
現在公開されているLayoutLMv3のコードはファインチューニングだけで事前学習のコードは公開されていないのでこれらのタスクは自前で実装する必要があります。

Masked Language Modeling (MLM)

BERT*6のMLMと同様に一部のトークンをマスクして元のトークンを予測します。 BERTではマスクするトークンを選択する際に一様なランダムサンプリングを用いますが、LayoutLMv3ではspan maskingでマスクしています。
SpanBERT*7で用いられているspan maskingはまず幾何分布からスパン長をサンプリングします。(LayoutLMv3ではポアソン分布からサンプリング) 次にランダムに選んだ開始点からスパン長分マスキングします。 ランダムサンプリングだとサブワードだけマスクされてしまいタスクが容易にになってしまうのに対してspan maskingだとひとつづきの単語をマスクされることでタスクの難易度が上がり性能向上に繋がるとあります。
MLMに関してはBERTの実装*8が、span maskingに関してはSpanBERTの実装*9が参考になります。

Masked Image Modeling (MIM)

MIMはBEiT*10で提案された画像に関する自己教師あり学習であり、224 x 224にリサイズされた画像をdVAEに通して取得した14 x 14の画像トークンを正解ラベルとします。 一方で画像をパッチサイズ16 x 16のパッチに分割 (画像パッチの総数は14 x 14) し、画像パッチの一部をランダムにマスクして破損した入力をTransformerに与えて画像トークンを復元するように学習します。

BEiT: BERT Pre-Training of Image Transformers

LayoutLMv3で使っているdVAEはDiT*11で事前学習されたものを使っています。BEiTのdVAEとの違いは、BEiTではDALL-E*12のencoderを使っているのに対して、DiTではDALL-Eと同様のアーキテクチャで大量の文書画像で学習されている点です。

以下の図は一番左が元画像で続いてDiT、DALL-EのdVAEで再構成した画像となっており、DALL-Eと比較してDiTの方がより鮮明に文書画像が復元できていることが分かります。

DiT: Self-supervised Pre-training for Document Image Transformer

しかし、DiTのdVAEは公開されていないので、事前学習済みのencoderを使う場合はMITライセンスのDALL-Eのencoderが候補となります。

マスクする画像パッチを選択する際はblockwise maskingを用いています。 blockwise maskingでは複数のパッチの集合が矩形となるようにマスキングを行います。 BEiTの実装*13を参考にマスクすると以下のようになります。

blockwise masking

MIMの実装に関してはBEiTの実装*14が参考になります。 画像パッチのマスキングは正規分布で初期化された学習可能なパラメータを用いてマスク位置の埋め込みと差し替えています。

Word Patch Alignment (WPA)

WPAはテキスト位置の画像パッチがMIMによってマスクされているかどうかを予測します。 MLMとMIMではテキストと画像をそれぞれ単独でしか学習することができないので、テキストと画像のモダリティ間のアライメントを学習させるのが目的です。
テキストがマスクされているかどうかの判定については論文には言及されていませんでしたが、Issuesによると98%の領域がマスクされているかどうかを閾値としているようです*15

検証

事前学習には時間もお金もかかります。極力手戻りが発生しないように不確実性を下げつつ検証を進めていきました。
まずは実装した事前学習にバグが無いか切り分けたかったので、事前学習以外にバグを埋め込まないように以下のようにミニマムな実装としました。

  • バクラクデータセットではなく、オープンデータセット IIT CDIP 1.0 dataset*16 を使用
  • テキストと画像の前処理は LayoutLMv3Processor を使用
    • 画像のOCRは LayoutLMv3ImageProcessor に組み込みのTesseractを使用
    • テキストのtokenizerやToken Embeddingは事前学習済みのRoBERTaを使わずにデフォルトの構成を使用
  • 軽量なbaseモデルを使用

この時点で数千ステップ程度は継続して事前学習が回ること、各タスクのlossが減少することで致命的なバグが無いことを確認しました。

次に事前学習にどの程度のコストが必要となるか見積もりました。 論文ではデータセットのサイズ1100万、バッチサイズ2048で50万ステップの事前学習を行ったとありますが、Issues*17には15万ステップでもほぼ同等の精度が出るとのコメントがありました。
一旦15万ステップを目安としてA100(40GB) x 16で見積もったところ、1回の実験で$1800~$7000程度かかりそうということが分かりました。見積もりに大きな幅がありますが、これはGradient Accumulationを利用するかどうかで大きく変動します。 (この時点ではVRAMの効率化ができていなかったため少し過大な見積もりになっていたかとは思います。)

一方で過去のRoBERTaでの事前学習の経験からそこまで大きなデータセットやバッチサイズで学習させなくても下流の項目推定タスクの性能が出るのではないかという仮説はありました。 一度の事前学習でうまくいく保証はないので、まずは1回あたりのコストを下げて試行回数を増やす方向で考えました。

ここ最近はGCPでA100のスポットインスタンスを確保することは困難なので、最初はV100(16GB)のスポットインスタンスで検証することを考えました。 AMP, Gradient Checkpointing, DeepSpeed ZeROを活用することでbaseモデルであれば、GPU1個あたりバッチサイズ24まで積めたので8GPUであれば戦えそうな感じになりました。
V100 x 8のスポットインスタンスであれば、1日中トレーニングしても$200程度で回すことができ、baseモデルであれば15万ステップを2日で学習できる見込みとなりました。

このタイミングで検証用のデータセットからバクラクのデータセットに切り替え、OCRもTesseractからVision APIに切り替えて事前学習を行いました。ファインチューニングして精度を検証してみたところ、RoBERTaの精度までは到達できないまでも数ポイントの差しかなかったためいけそうな感触を得ました。

ここまでで不確実性はある程度潰せたので、largeモデルへの切り替えと、Transformer EncoderとToken Embeddingに事前学習済みのRoBERTa (nlp-waseda/roberta-large-japanese-seq512) *18 を用いて事前学習を行いました。
A100(80GB)を用いて、データセットのサイズ約50万、バッチサイズ150、50万ステップで事前学習を回しており、実はこのブログを書いている時点でもまだ事前学習は終わっていないのですが、途中でファインチューニングして精度を出してみたところすでにいくつかの項目でRoBERTaの精度を上回っています!

まとめ

本記事では、LayoutLMv3をバクラクのデータセットで事前学習を行い、それをファインチューニングして項目推定タスクに取り組んでいる話をご紹介しました。直近の実験ではRoBERTaの精度を上回るなど良い結果も見えてきています。 RoBERTaとLayoutLMv3の推論結果を比較することで、複雑なレイアウトの場合に改善しているか検証してみたいと思います。 また、さらなる改善に向けて以下の実験にも取り組んでいきたいと考えています。

  • 画像トークナイザーとしてDALL-EのdVAEを使っているが、DiTのように自前のデータセットでdVAEを学習させる
  • MLMはBERTの実装を用いているが、span maskingによるマスキングを行う
  • より大量のデータセットを用いた事前学習
  • テキストの多言語対応

続報があれば改めてブログにしたいと思います!

最後に

LayerXにはOCR以外にも機械学習で解きたい課題がいっぱいあります!興味を持たれた方は是非カジュアル面談からでもお気軽にどうぞ!

jobs.layerx.co.jp jobs.layerx.co.jp