TECH PLAY

MNTSQ

MNTSQ の技術ブログ

86

MNTSQ( モンテスキュー )株式会社 ソフトウェアエンジニアの沼井です。 普段は Rails でのバックエンド開発をしつつ、Elasticsearchによる 全文検索 処理やインデクシングまわりの開発にも取り組んでいます。 私は現在、 Thinkpad X1 Carbon (2021年版)に Ubuntu 20.04をインストールして開発を行なっています。MNTSQ社以前の経験も含めると、業務での Ubuntu 使用経験は3年以上あります。 テック系スタートアップの、とりわけ Webサービス ・ スマホ アプリの開発シーンでは、 macOS ユーザーが99%(※個人の感想です)ということもあり、 macOS 以外の環境を(使いたくても)使うことが難しいと思っている人も多いと思います。 本記事では、業務での Ubuntu 利用の実情・課題・メリットなどを共有したいと思います。 TL; DR テック系スタートアップにおけるソフトウェア開発という部分に限れば、 Ubuntu 環境も十分実用的なラインだと思う 会社における普段の業務、という広い観点では、思わぬ落とし穴はやはりある macOS 以外の環境での開発可能性があることで、多様性の観点で中長期的なメリットはあるかもしれない そもそもなぜ Ubuntu なのか 弊社ではリーガルテック分野の SaaS を開発しており、Webのバックエンド・フロントエンドを担うエンジニアは基本的に macOS を使用しています。 そんな中で Ubuntu を使っているというのは、よっぽど思い入れやこだわりがあるから...というわけではなく、私自身が Ubuntu に慣れていて扱いやすいから、という理由が大きいです。 私はいくつかのソフトウェア開発会社を経験してきたのですが、.NETアプリを開発するチームでは Linux サーバとの連携機能のための シェルスクリプト 開発を担当し (他にやる人がいなかったので)、 Rails バックエンドを開発するチームではサブで動いている VB.NET システムを開発する (他にやる人がいなかったので) 等、エッジケースを担当することが多くありました。 結果として「 macOS でWeb系開発する」という典型ケースの経験が少なく、 Windows or Linux のいずれかを使用する経験を多く積んできたため、可能なら自分が慣れていて生産性の高いOSを選択したいという気持ちがありました。 とはいえ、チーム全体の環境統一による生産性向上のほうが優先されるべきという判断がされれば、それに従って macOS を使っていたと思います。結果的には、弊社も含めていままでに所属した会社では「 Ubuntu を使ってもいいよ」と受け入れてもらえたので、いまに至っています(ありがたい)。 Ubuntu で会社の業務をおこなえるか? : 実情と課題 最近は「 Ubuntu で開発環境構築してみた」系のテックブログ記事も増えていますが、「実務で」「継続的に」使えているのか? あるいはやっぱり無理だったので macOS (あるいは Windows ) に結局戻すことになってしまうのでは? 等気になる人はいると思います。 以下では自分の経験に沿って、 Ubuntu の普段の業務利用のあれこれを解説していきます。主にMNTSQ社での経験を中心としますが、以前の職場での経験も踏まえて書きます。 PCの選定 業務用PCは、一貫して Thinkpad X1 Carbonを使用しています。 ThinkPad X1 Carbon (2021年版) + Ubuntu 20.04 スペック: CPU: Intel Core i7 -1185G7 メモリ: 32GB ディスク: 1TB これまで複数世代のX1 Carbonを使ってきましたが、 Ubuntu のインストールでうまく行かなかったことはなく、安定して使えています。 Lenovo が公式で互換性を確認している Linuxディストリビューション 一覧を出してくれているので、それを確認するのがよいでしょう。 support.lenovo.com リリース直後の機種は、上記リストにすぐには掲載されないので、リリース直後の機種で Ubuntu をインストールするのはリスクが伴うため注意です。 (過去の実績から、X1 Carbon含むメジャーな機種で Ubuntu に対応しないことはほぼない、と思っていますが) 開発用にそこそこのスペックのCPU、メモリサイズを選びやすいことや、軽量で持ち運びやすいことも、X1 Carbon選定の理由です。 開発まわり以外 社内で使うシステム・サービスの多くはWeb化されている SaaS や スマホ アプリの開発をしているテック系スタートアップの場合、業務で使用するシステム・サービスの多くはWebに対応したもの(概ね SaaS 利用)が多いと思います。 そもそも社内のITエンジニアはほぼ macOS を使うこともあり、 Windows を使用するBiz側との混在状態を考えると、 Windows デスクトップ環境が必須となるようなシステム・サービスの選定自体がなされにくいと思われます。 弊社でも同様で、ほとんどの業務システムはWebに対応しています(下記例)。 弊社の利用サービスでWebに対応しているもの: 人事総務システム 勤怠管理システム 採用管理システム ビジネスコラボレーション ( Google Workspace) ビデオチャット ( Google Meet、Zoom、 Microsoft Teams 等) など (Web版もあるが) デスクトップアプリケーションを使用しているもの: Slack Webでまかなえない部分に多少のリスクあり とはいえ、Webでまかなえない部分というのも少なからずあり、それらについては「入社/チーム配属時は気づかなかったけど、あとから直面して問題があることに気づく」リスクが存在するように思います。 典型的には以下です: デ バイス 関連 (ネットワークプリンタ、 Bluetooth 機器、 指紋認証 など) VPN / リモートデスクトップ サービス デ バイス 接続については、典型的なデ バイス への接続は、そこまで大きな問題にはならない印象です。 ネットワーク上のプリンタへの接続は、さすがに現代の Linux では簡単に ( Ubuntu 20.04であれば「設定」アプリから) 行えます。 印刷オプションの詳細設定や印刷の質にこだわらなければ、利用も問題ありません。 Bluetooth 機器についても、マウスやスピーカーへの接続などを行なうことはもちろん可能です。 Bluetooth マネージャーとしてはBluemanを使用しています。 Ubuntu 20.04ではだいぶ安定してきた印象ですが、それでもたまに接続がうまく行かず(検出はされるが、ペアリングが失敗する等)、試行錯誤をすることもあります。 私が使用している Bluetooth 機器: マウス: エレコム M-XGM10BBBK スピーカーフォン(会議室設置): Jabraシリーズ また、 指紋認証 についても、 X1 Carbon 2021年版とUbuntu20.04 の組み合わせでは、「設定」アプリの「ユーザー > 認証とログイン」から簡単に行えるのは驚きでした。じつはこの 指紋認証 をセットアップしたのが最近なので、最初からできたのか、最近のドライバやOSのアップデートでできるようになったのかは未確認です。 設定 > ユーザー > 指紋認証 ログイン VPN / リモートデスクトップ サービスについては、入社直後には不要だったけど、いざ必要になったとき(たとえばリモートワークのとき等)に設定する、ということが少なからずあると思います。その段になって「 Linux は対応していません」ということになると辛いです。 幸いなことに、いままでの会社では、LinuxOSでも使用できるサービス(例: VPN なら、 OpenVPN 互換) を使っていたため、ここはなんとかクリアできました。ただし、 Linux 向けの手順書が会社で用意されていることはまず無いので、自分で公式マニュアルを読んで設定することは必須です。 そういう意味では、このあたりはチーム・会社に入る前に事前に確認するのがよいと思います。(リスクになりえる箇所を事前にすべて洗い出して確認するのは難しいですが...) 社外との相互運用性は課題 社外との相互運用性において一番大きいのはやはり、顧客と Microsoft Office (Word、 Excel 、 PowerPoint )のファイルをやりとりする場合でしょう。 Ubuntu での代替案としては、Office365 (Web版) を使用するか、 LibreOffice あるいは Google Workspace (Document、 Spreadsheet、 Slide)での互換表示を使用する、あたりでしょうか。 私は社内での開発がメインの業務であり、顧客と直接Officeファイルをやり取りすることがほぼないため、今のところ大きな問題にはなっていません。 ブラウザはOSよりもブラウザ間の差異のほうが問題になる ブラウザについては、 Google Chrome を使っていれば、OSの差異でトラブルになることはほぼない印象です。 私がメインで利用するブラウザは Firefox なのですが、 Firefox を使っているせいで問題になることのほうが多いです。近年は Firefox のサポートを明示的に、あるいは暗黙的に行わない Webサービス も増えているため、 Chrome を使うほうが安心でしょう。といいつつ、 Firefox の「ツリー型タブ」拡張に10数年慣れ親しんでいることもあり、私は Firefox を使っています(作者のPiroさん、いつもありがとうございます)。 余談ですが、 Ubuntu 上の Firefox については、直近では snap版で不具合がいろいろ発生している という逆風もあるようです。 (そういえばSlackクライアントも、snap版だと過去に日本語入力に問題があったので、 deb パッケージからインストールしています。 参考記事: Ubuntu18.04LTSでSlack日本語入力が出来ないときの対処方法 - Qiita ) なお、 Microsoft Edge にも Linux 版があり、2021年末に正式リリースされています(併用しています)。Edge for Linux を使っている人はかなりの数奇者かもしれません。 開発まわり 標準的な開発ツールはどのOSでも概ねカバーされている 普段は以下のようなツールで開発を行なっています: IDE : RubyMine Editor: Visual Studio Code Terminal: Hyper その他: Git、docker、docker-compose、 aws - cli など 標準的な開発ツールについては、 macOS 、 Windows 、 Ubuntu それぞれに対応しているか、あるいは各OS専用だけれど十分に使い物になるものがあるため、特に問題はないと思います。 エッジケースでは各OS専用ツールが有用な場合も Visual Studio Code は標準機能も 拡張機能 も充実しているため、たとえばテキストファイルのdiffの表示やバイナリ表示、 CSV のテーブル表示、Git リポジトリ の GUI 管理など、ひところは専用のツールがあったようなものが概ね Visual Studio Code でまかなえるようになってきています。そのため、 Ubuntu も含めてOSを変えても同じように利用できるのは便利なところです。 私が使っている Visual Studio Code 拡張の例: バイナリ表示: hexdump for Visual Studio Code Git: GitLens、Git History CSV のテーブル表示・編集: Edit csv とはいえ、エッジケースでは「あるOSでしか動かないけど便利なもの」もあります。たとえば大容量テキストファイル(なんらかの CSV やログファイル等) を扱いたい場合は、 Windows 向けである EmEditor などが有用だと思います。 一般に、大容量テキストを扱う場合に、 Visual Studio Code や IntelliJ 系 IDE の 拡張機能 などだとつらくなるケースが多いように思います。その場面では、あるOS上での専用ツールが勝る場合がありそうです。 一番大事なのは、開発している ソースコード を扱えるか 一番大事なのは、チームで開発している ソースコード やその周辺 スクリプト ・ツールが Ubuntu で動作するのかどうかであり、Web系の開発においては、経験的には以下がポイントとなります: (1) docker、 docker-composeで開発環境を構築する手順になっているか dockerを使用してれば、コンテナ内での動作については基本的には問題になることは少ないです。 数少ない問題としては、 Docker for Mac に依存する箇所でひっかかる部分(下記例)があり、これについては個別に対応する必要があります。 コンテナ内で作成されたファイルがroot権限になる場合、ボリュームマウント時のホスト側でもファイルがroot権限になるため、編集・削除などに支障がでる問題 対処法はいくつかあり。参考記事: dockerでvolumeをマウントしたときのファイルのowner問題 - Qiita host.docker.internal を開発環境で使っている 上記のような課題はあるのですが、 Docker for Mac に比べると、ボリュームマウント時のI/O速度が問題になることが少ないため、性能面で苦痛に感じることは少ないです。 数年前に経験した Rails アプリ開発 プロジェクトでは、 macOS 環境より私の Ubuntu 環境のほうが、 rspec の実行時間が4倍速いということもありました。(ただし、 Docker for mac + virtiofs という最近登場した構成に対しては、この差はいくらか縮まっているでしょう) (2) コンテナの外(ホスト側)で動作する スクリプト がどれくらい多いか、どれくらい macOS 依存になっているか macOS で開発しているチームが書いた bash シェルスクリプト は、当然ながら各コマンドのオプションの指定などが macOS 依存になる( BSD 由来のコマンドオプション) ため、 Ubuntu で動かないことは「まれによく」あります。 これについては シェルスクリプト を眺めて気付けることはほぼないため実行 => エラー発生 => 修正 を繰り返す必要があります。 修正は、 uname でOS判定して分岐するか、どちらでも動くようにオプション指定の仕方や利用コマンドを変える、の2つが典型的な方法です。 このような macOS およびDocker for Mac 依存の箇所の調査・対応でつまづいたり時間を浪費するのは、チームにとって新しい価値を生み出せていない時間になります。そのため「 macOS 利用者しかいないエンジニアチームに最初に入る時」は、ここの対応は緊張感をもってスピード対応をすることを心がけています。 (最初に完璧に直すより、開発にクリティカルに影響する部分から優先順位をつけて対応するのがよいでしょう。いずれにせよ、追加/変更に継続的に追従する必要はあるので) まとめ、あるいは語りきれなかったこと 現時点での私の状況をいうと、 Ubuntu で普段開発していてとくに問題になる所はなく、慣れているOSでスムーズに開発できており、その(個人的な)メリットを享受できています。その点では実用的なラインに達していると言えます。 とはいえ、改めてまとめてみると、インストールしてから業務が軌道に乗るまでにいろんな課題を解決し、乗り越えていく必要があるなあ、というのを再認識しました。 最後に、ここまでで触れてこなかったいくつかのトピックについて取り上げてみます。 Windows11 + WSL2 Ubuntu 構成の可能性について Windows11 + WSL2 には、弊社SREの二宮同様に可能性を感じており、開発以外( VPN とかデ バイス 接続とか)のトラブルを Windows 側で回避しつつ、開発はWSL2 Ubuntu 側で行なうというハイブリット構成は魅力だなと思います。 note.com Visual Studio Code にはRemote Development拡張があり、 IntelliJ 系 IDE にもJetBrains Gateway というリモートOS上で開発する機能があるため、 Windows 側の GUI を使いつつWSL2 Ubuntu 側で開発をすることはかなり現実味を帯びてきているように感じます。 とはいえ、同僚のエンジニア曰く、 JetBrains Gateway で Ubuntu に IDE バックエンドを入れたときの GUI ( Windows 側)の応答速度はまだまだ厳しいとのことでした(逆に Visual Studio Code のRemote Developmentのほうは十分イケてる、とのこと)。 RubyMine派の私は JetBrains Gateway を使いたいですが、 まだ軽く触っただけで本格的な開発には使ってないので、今後の検証課題としたいと思います。 なお、WSL2 Ubuntu 側にRubyMine本体をインストールし、WSLg で接続する、またはWSL2側へ リモートデスクトップ 接続をする形も試したことがあるのですが、こちらも動作がまだまだ重かったなという所感です(マシンスペックにも依存するでしょうが)。 性能問題については(ハード・ソフトともに) 時間が経てば改善していく可能性が高いので、今後に期待というところです。 macOS が使えない場合の代替手段になるというメリット(多様性の担保) 以前の職場でのWeb開発の経験になるのですが、あるとき社外ベンダーの多数のエンジニアに開発プロジェクトに参加いただいたことがあり、ベンダーのエンジニアの開発用マシンがほぼ Windows だったことがありました。その結果、私が開発環境を(自分のために) Ubuntu 対応させておいたことにより、ベンダーのエンジニアの方々にもスムーズにWindows10 + WSL2 Ubuntu で開発してもらうことができました。 結果論ではあるのですが、複数の選択肢(多様性)があったことによるメリットの享受といえるかもしれません。 弊社においても、一部の開発メンバーの MacBook のスペックの問題(メモリ容量が8GB)で開発が厳しい状況だったのを、メモリサイズの大きい (16GB) Windows マシン (WSL2 Ubuntu ) に乗り換えて開発している、という事例があります。 この記事を書いた人 沼井裕二 MNTSQ( モンテスキュー )社のソフトウェアエンジニア。「要はバランス」おじさん。 Mac を使おうとした時期が一瞬だけあり、そのときに GitHub のユーザーアイコンを MacBook にしたまま放置している。
アバター
新しい仲間 前回、MNTSQのSlackにいるいくつかの bot を紹介した。 tech.mntsq.co.jp 今日、そこに新しい仲間が加わったので紹介しよう。その名も "Hot Docs" だ。 Hot Docsとは Hot Docsとは、 Google Drive のファイルを 再帰 的に探索し、短時間にたくさんコメントがついたDocsをSlackに通知する bot である。こんな感じだ。 なぜ作ったか MNTSQでは Google Docs を使って非同期でコミュニケーションを取ることがある。誰かが提案やログをDocsで作り、他のメンバーがそこにコメントしまくる、というスタイルだ。 しかしこれでは議論がDocsに閉じてしまい、Docsの存在を知らないメンバーから見えないというOpenness観点の問題がある。 そこで議論が活発なDocsをピックし、Slackに通知する仕組みを作った。 導入してみて 好評だった。 筆者自身、異なる ドメイン のHot Issueを知ることで事業理解に役立っていると実感する。 Hot Docsの仕組み Hot Docsは Google App Scriptで作られており、30分おきにDriveを探索する。 ソースコード は次のとおり。 再帰 Generator listFiles で取得したファイルを2users以上かつ5comments以上という条件でフィルタしSlackに投稿する。 function notifyHotDocs() { notifyHotFiles( "application/vnd.google-apps.document" ); } function notifyHotSlides() { notifyHotFiles( "application/vnd.google-apps.presentation" ); } function notifyHotFiles(mimeType) { const updatedMin = new Date ( new Date ().getTime() - 30 * 60 * 1000); const files = Array .from(listFiles(FOLDER_ID, mimeType, updatedMin), file => { const comments = Drive.Comments.list(file.getId(), { maxResults: 100, updatedMin: updatedMin.toISOString() } ).items; return { name: file.getName(), url: file.getUrl(), comments: comments, authors: [ ... new Set(comments.map(x => x.author.displayName)) ] , commentsLength: comments.length + comments.reduce((n, x) => n + x.replies.length, 0) } ; } ) .filter(x => x.authors.length >= 2 && x.comments.length >= 5) .sort((a, b) => b.comments.length - a.comments.length); if (files.length === 0) return ; const colors = [ "#fcc800" , "#f3981d" , "#ea553a" ] ; // コメント数に応じて色を変える const attachments = files.map(x => { let colorIndex = Math.floor(x.comments.length / 10); colorIndex = colorIndex < 0 ? 0 : colorIndex > 2 ? 2 : colorIndex; return { author_name: x.authors.slice(0, 3).join( ", " ) + (x.authors.length > 3 ? ", etc." : "" ), title: x.name, title_link: x.url, color: colors [ colorIndex ] } ; } ); postMessage(CHANNEL_ID, "なんか盛り上がってるみたい!" , attachments, ":hotdog:" , "Hot Docs" ); } function *listFiles(parentFolderId, mimeType, updatedMin) { const folder = DriveApp.getFolderById(parentFolderId); const files = folder.getFilesByType(mimeType); while (files.hasNext()) { const file = files.next(); if (file.getLastUpdated() > updatedMin) { yield file; } } const folders = folder.getFolders(); while (folders.hasNext()) { const folder = folders.next(); yield* listFiles(folder.getId(), mimeType, updatedMin); } } function postMessage(channel, text, attachments, icon_emoji, username) { const url = "https://slack.com/api/chat.postMessage" ; let options = { "method" : "post" , "contentType" : "application/x-www-form-urlencoded" , "payload" : { "token" : SLACK_BOT_POST_TOKEN, "channel" : channel, "text" : text, "attachments" : JSON.stringify(attachments), "icon_emoji" : icon_emoji, "username" : username } } ; let response = UrlFetchApp.fetch(url, options); let data = JSON.parse(response.getContentText()); return data; } おしまい あなたの会社でもぜひ試してほしい。
アバター
こんにちは、MNTSQでサーバーサイドエンジニアのようなものをやっている西村です。今回は比較的簡単に Ruby on Rails のアプリを高速化する方法を書いてみようと思います。 内容的にはタイトルのとおり、平易なものが多いのですが、頻度高く見かけるものをまとめてみました。 preload/include/eager_loadを利用してN+1を回避する Rails では紐づくレコードが芋づる式になりがちで、N+1問題がよく発生します。弊社では grape というgemを利用して API を作成していますが、レスポンスを組み立てるタイミングでN+1問題がよく発生します。例えば、以下のような ActiveRecord があったとします。 class Document < ActiveRecord :: Base has_many :sections , dependent : :destroy end class DocumentEntity < Grape :: Entity expose :id , documentation : { type : ' Integer ' , required : true } expose :sections , documentation : { is_array : true , required : true }, using : SectionEntity end class SectionEntity < Grape :: Entity expose :section_type , documentation : { type : String , required : true } end それに対して以下のようなコードを書くと documents = Document .where( type : :pdf ) present documents, with : DocumentEntity 以下のようなクエリが発行されてしまいます。 Document Load ( 0 .8ms) SELECT `documents`.* FROM `documents` ORDER BY `documents`.`updated_at` DESC LIMIT 1 Section Load ( 11 .6ms) SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 3 Section Load ( 2 .6ms) SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 5 Section Load ( 1 .9ms) SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 8 Section Load ( 2 .3ms) SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 20 Section Load ( 0 .6ms) SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` = 21 その場合は includes/preload/eager_load を利用することで、N+1問題を抑制することができます。 documents = Document .preload( :sections ).where( type : :pdf ) present documents, with : DocumentEntity すると以下のようなクエリが発行されるようになります。IN句でまとめてクエリで取得されているのがご確認いただけますでしょうか。 Document Load ( 0 .8ms) SELECT `documents`.* FROM `documents` ORDER BY `documents`.`updated_at` DESC LIMIT 1 Section Load ( 11 .6ms) SELECT `sections`.* FROM `sections` WHERE `sections`.`document_id` IN ( 3 , 5 , 8 , 20 , 21 ) 余談ですが、筆者は「N+1」という表現が分かりにくいのであまり好きではありません。 参考: qiita.com 変数にキャッシュする 結果を変数にキャッシュして高速化する手法です。弊社ではフォルダ構造を表現するために ancestry というgemを利用していますが、このgemから提供されているメソッドをそのまま利用すると上記のN+1問題が発生します。また、この問題は上述の includes や preload 等では解決できません。この場合には一度呼ばれたフォルダのデータを二度呼ばないように変数にキャッシュしていきます。 dir_cache = {} document_scope.in_batches( of : 100 ).each do | documents_batch | doc_ids = documents_batch.pluck( :id ) directory_doc_id_pair = :: Directory .where( document_id : doc_ids).pluck( :document_id , :ancestry ).group_by(& :first ).transform_values { | val | val.first.second } dir_ids = directory_doc_id_pair.values.map { | ancestry | ancestry&.split( ' / ' )&.[]( 1 ...)&.map(& :to_i ) }.flatten.compact.uniq - dir_cache.keys dir_cache.merge!(:: Directory .where( id : dir_ids).pluck( :id , :node_name ).to_h) if dir_ids.present? documents_batch.each do | doc | path_names = directory_doc_id_pair[doc.id]&.split( ' / ' )&.[]( 1 ...)&.map(& :to_i )&.map { | dir_id | dir_cache[dir_id] } || [] p path_names.join( ' / ' ) end end 上記はどちらかというと込み入った例で、変数キャッシュのやり方としてよくあるのは、以下のようなやり方です: def document_count @document_count ||= user_document.count end これは document_count が何度も呼ばれることを想定し、その結果を インスタンス 変数に記憶しています。 この使い方で気をつける点としては、 インスタンス 変数に記憶しているので、 ActiveRecord を更新した際などに人力で変数をリセットする必要があることです。また、結果が判定falseのものの場合(falseや nil など)、キャッシュが効かないので、その点も注意が必要です。 SQL を一本化する ループ処理で大量のクエリが発行される場合、それらを1つのクエリで済ませるようにすると大幅に高速化できることがあります。例として、以下のとおり1ヶ月分の日々の商品ごとの売上高を計算する処理があったとしましょう。 prev_month_end = Time .zone.now.beginning_of_month - 1 .second prev_month_start = prev_month_end.beginning_of_month dat = [] (prev_month_start.to_date..prev_month_end.to_date).each do | date | dat << Recipient .where( created_at : (date.beginning_of_day..date.end_of_day)).group( :product_code ).pluck( ' sum(price) ' , ' count(*) ' , :product_code ) end これでももちろん動くのですが、売上が伸びるとかなり時間がかかるようになってきます。これを以下のように1クエリで取得するように変更することで、スピードアップが見込めます。 Recipient .where( created_at : (date.beginning_of_month..date.end_of_month)).group( :product_code , ' date(created_at) ' ).pluck( ' sum(price) ' , ' count(*) ' , :product_code , ' date(created_at) ' ) こういった問題は経年によってデータが溜まることにより顕在化することがあるため、継続的な速度計測をしておくと良いでしょう。 この手法は内容次第でメ モリー 不足でデータベースが死ぬことがあり、データベースにクエリを処理するために十分なメ モリー があるかを確認すると良いでしょう。 メ モリー ストアへのキャッシュ これは対策としては最も単純な部類です。Redisや memcached 等に ActiveRecord をキャッシュします。実装イメージとしては以下のようになります: # 最新10件をキャッシュする Rails .cache.fetch( CACHE_KEY , expires_in : CACHE_EXPIRE ) do order( updated_at : :desc ).limit( 10 ).to_a end 対応する際の注意点としては、列が増えたり減ったりした時にエラーを吐きがちなので、手元で以前のデータのキャッシュのままデプロイしてエラーにならないか、逆に新形式のデータのキャッシュがデプロイ前のサーバーでエラーにならないか等、綿密にチェックする必要があります。 いわゆるマスターデータは複数のテーブルにまたがったデータをキャッシュしたり、特定の条件のものを絞り込んでキャッシするということをよくやりますが、キャッシュキーをがたくさんできてしまい、どのキャッシュをどのレコードを更新したタイミングで消せば良いのかが煩雑になりがちで、それに起因した問題を踏みがちです。キャッシュに関連するテストは厚めにしておいたほうが良いでしょう。 実態をキャッシュできているか確認することも重要です。よくやりがちなのが、.to_aを忘れてしまって、キャッシュしたつもりが実は都度データベースへの問い合わせが発生しているというものです。これをやらかすと、なかなか気づきにくいです。 クラスキャッシュする toC 向けのサービスの場合、キャッシュをしてもキャッシュサーバーとのネットワークが ボトルネック になってしまうことがあります。その場合、 Rails サーバーの中でキャッシュをしてしまうことがあります。 ただし、設計に気をつける必要があります。そのサーバー上でその値で固定されてしまうことになるので、更新をかけるタイミングが難しくなります。 キャッシュ内容がメ モリー にそのまま乗るので、メ モリー に乗せられる量かつ単純な呼び出しのものが対象になります。メ モリー についてはこれらがサービスに対する インパク トが強ければ、逆に大量のメ モリー を抱えたサーバーを準備する方向もあるでしょう。 やり方としては以下のようにします: # 1分間データをクラスにキャッシュする def fetch_cache if @@cached_time && @@cached_time > 1 .minute.before return @@recently_updated unless @@recently_updated .nil? end @@cached_time = Time .zone.now @@recently_updated = order( updated_at : :desc ).limit( 20 ).to_a end 消費メ モリー 量は ObjectSpace.memsize_of() を利用して計測することができます。文字列であれば文字数によって変化するので、注意が必要です。20件キャッシュするのであれば、1つあたりのだいたい20倍ということになります。 > ObjectSpace .memsize_of( Project .first) => 120 データベースのインデックスをちゃんとする MySQL へのクエリが重くて、実はindexが効いてないということは稀によくあります。重いクエリを見かけた際は実際のクエリを吐き出してexplainで中身を見てみると良いでしょう。EXPLAINについて説明するとそれだけで立派なブログ記事が一本書けてしまうので、軽い紹介だけにとどめます。 > Project .all.explain => EXPLAIN for : SELECT ` projects ` .* FROM ` projects ` +----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+ | 1 | SIMPLE | projects | NULL | ALL | NULL | NULL | NULL | NULL | 2 | 100.0 | NULL | +----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+ 1 row in set ( 0.00 sec) レコードの存在確認で件数を数えない 細かなチューニングとしては、レコードの存在を確認する時に以下のようなコードを書きがちです。 > User .where( suspended : true ).count > 0 SELECT COUNT(*) FROM ` users ` WHERE ` users ` . ` suspended ` = TRUE それを以下のようなかたちに書き換えます: > User .exists?( suspended : true ) SELECT 1 AS one FROM ` users ` WHERE ` users ` . ` suspended ` = TRUE これも結構ありがちで、件数を取得するための SQL 負荷は意外と高いので、見つけ次第修正してデータベースのリソースを有効に使いましょう。 お役立ちgemのご紹介 以下にスピードアップのために役に立つgemをいくつかご紹介します。 github.com ローカル環境でテストする時に、その画面で呼ばれている API や SQL 、それらにかかっている時間などを把握することができます。 github.com N+1を発見してくれるgemです。どう直したら良いかもアド バイス してくれます。 github.com 大量のデータを一度にインサートできるgemです。1万件のデータを1件ずつsaveしていたらかなりの時間がかかりますが、バルクインサートすることで大量のデータのインサート時間を短縮することができます。なお Rails 6以降であればinsert_allを利用すれば足りるでしょう。 github.com gemというよりサービスの紹介になりますが、Datadogの APM を利用することで遅いクエリをあぶり出すことができます。特に本番環境のデータ量でないと再現しない問題も多いので、継続的に監視することで問題を早期に発見し、未然に対処することができます。 重要なのは「不要不急のスピードアップをしないこと」 実はこれが最も重要です。すべてを最適化していたら相当な開発 工数 を消費するので、結果としてアプリのスピードの上昇率と比べて開発スピードが大幅に減速します。 一方で適切なタイミングでスピードアップする必要があります。そこのバランスはわりと職人芸になります。なぜ職人芸になってしまうかというと、プロダクトによって負荷のかかり方、組織の状況が異なるため、一概にこれが正解ということが言いづらいからです。 例えば、ITエンジニアリソースに余裕がある組織で toC 向けのサービスで多くの通信が発生するサービスであれば、N+1を撲滅し、すべてのデータをキャッシュし、キャッシュの削除部分の丁寧なテストを書いても良いでしょう。また創業したてでITエンジニアの人数も2-3名の組織では、スピードアップに割くリソースも無いため、問題が起きた部分だけチューニングをするという選択肢をとることもあるでしょう。 この記事を書いた人 Yuki Nishimura 雑食系エンジニア
アバター
こんにちは、MNTSQ( モンテスキュー )でSREをやっている中原です。しばらくコロナで帰省することができなかったのですが、つい最近久しぶりに帰るとともに、実家に放置してあった自分の車を関東に持ってきました。「これで夢のドライブライフだー!!」と思っていましたが、借りた駐車場が狭すぎて出し入れが大変しにくく、結局乗る機会は少なそうです。 ドライブや自動車競技をする場合、機能性の面からステアリングやシートを入れ替えたりしますが、やはり自分にあったものを選ぶと疲れにくかったりします。かつてレカロのシートを使っていたときは確かに身体は楽でした *1 。 さて、ITシステムに関わる企業に所属するものとして、作業の大半はディスプレイの前でキーボードやマウスを使っての作業になります。 ドライブなどと同じで、それぞれの身体や入力スタイルにあった機器でないと 疲労 や最悪の場合ケガなどにつながっていくのは必然です。 MNTSQでは、 入社時に椅子の専門店で実際にいろいろな椅子と試してみて、自分に合う椅子を注文して使うことが可能 であったり、ディスプレイについても自分の好みに合う画面サイズや解像度のものを選択可能です。 当然のことながら、キーボードやマウスについても、予算の範囲内で選択が可能です *2 。 そんな中で、MNTSQ社員がどういったインプットデ バイス で仕事をしているのかまとめてみました! SRE 中原: Lenovo ThinkPad TrackPoint Keyboard II, Logicool MX ERGO ( Mac ) まずは執筆者の机はこんな感じです。家ではIIではない トラックポイント キーボードを使っているのですが、当初はIIの方のクリック・右クリックの浅さにビックリしました。ワイヤレスのUSBと Bluetooth の両対応ですが、基本はUSB接続で使っています。 Bluetooth だとファンクションキーなどで干渉したりしてうまく使えないことがあるのですよね。 MX ERGOは角度を時々切り替えながら使ってます。また、たまに TrackPoint も使っていますよ。 今後の展望としては、同じキーボードをもう1台買って、デュアルキーボードにしてみたいな、というのは考えています。 PdM Kさん: Apple Magic Keyboard II (英字配列) + Logicool MX Master 3 ( Mac ) Kさんの机はあっさり。MX Master 3よりも、本当は Magic Mouse がよかったとのことですが、それはそれで平たすぎると言うことでこの選択に。概ね不満は無いそうです。 エンジニア Mさん: PFU Happy Hacking Keyboard Professional HYBRID Type-S + Kensington BladeTrackball ( Mac ) HHKB、そして ケンジントン の トラックボール というのはエンジニアにとっては一種の王道感がありますが、MNTSQでは珍しい感じです。 お話を聞くと、できるだけ入力機器に迷いたくないという意図もあるのか、MBPの英字キーボードか、HHKB以外は触りたくないなぁ、ということをおっしゃっていました。たしかに、慣れた機器以外使うと疲れたりするし、その気持ちはわからなくはない…。 リーガル Sさん: ThinkPad 標準キーボード + エレコム M-HT1DRBK ( Windows ) キーボードは ThinkPad でよしとのこと。自宅では エレコム の Bluetooth キーボードを使われているそうですが、最近お子さんに破壊され、Keychronに興味津々とのことです。 トラックボール はとりあえず人差し指型を値段で考えて選んだものの、使ってみたら ケンジントン の方がよかったなあ、とのこと。このあたりは普段使っているものとの慣れなどもありそうですね。 リーガル Iさん: ThinkPad 標準キーボード + Lenovo のマウス + Lenovo のテンキー( Windows ) 今回とりあげた方で唯一のテンキー追加派。いつ使うのか聞いたら「数字を打ち込む時」。 具体的には、契約書の アノテーション 中、分類を打ち込む時に QWERTY配列 で 0 or 1 を打ち込むのと比べると効率が段違いとのこと。 それ以外は至って標準的な構成ですが、自宅では Realforce 派とのことです。 カスタマーサクセス Mさん: PFU HHKB Professional + Logicool M575 ( Windows ) キーボードは今は徐々に数を減らしつつある有線のHHKBをあえて選択したそうです。オフィスではワイヤレスにする意味が薄く、乾電池がなくなったときのイラッとした感じを避けるためとのこと。白は汚れが目立つため黒系で、スミではなく刻印のあるタイプを選択されていますね。 ポインティングデバイス については、MX ERGOにしなかったんですか?と尋ねたら、「(価格的に)ちょっと遠慮した」とのこと。M575は、MX ERGOと異なり角度の変更や重さがありませんが、素材がプラスチックなため 加水分解 しないという特長がありますね。 セールス Oさん: 東プレ Realforce + エレコム EX-G ( Windows ) 2021年10月入社のOさんは、社内初の Realforce ユーザでした。選んだ決め手は「同居人のおすすめ」とのこと。まだ使い始めたばかりで、良いのはわかるけど手放せないほどではない、ということでしたが、これから先抜け出せなくなっていくことを期待しています! また、マウスについてもこれがいいというこだわりのものとのことです。 エンジニア Mさん: Kinesis Freestyle 2 for Mac (9インチ) + Apple Magic TrackPad II ( Mac ) Freestyle 2をつかっているのは下の安野と同じなのですが、間をつなぐケーブルが安野のものより短い9インチのモデルで、これ以上は間が広げられないとのこと。長い(20インチ)ケーブルのモデルがあることを知らなかったとのことで、次はそちらを買いたいとのことでした。 Mac はクラムシェルにされていました。なお、 トラックパッド の位置は右側で「右側じゃない人は邪道ですよ!」とのこと。なお…。 取締役 安野: Kinesis Freestyle 2 for Mac (20インチ) + Apple Magic TrackPad II ( Mac ) 取締役、またエンジニアとしても現役の安野は上記の通り、 トラックパッド は真ん中派です! 安野の場合、Freestyle 2でもケーブル長が20インチのため開いている幅は広めで、これについて彼は「猫背を買うか( 原文ママ )、まっすぐな背骨を買うかだから」という名言を残していました。 ディスプレイは一枚ですが、タイルUIを駆使して中でのウインドウの整理はバッチリです。 代表取締役 板谷 : Lenovo ThinkPad 標準 代表取締役 ・ 板谷 の元相棒PCのキーボードです。弁護士たるもの、またスタートアップ ベンチャー の経営者たるもの、やはり力強い打鍵が必要なのか、Bキーが吹っ飛び、エンターキーに至っては真っ二つになっていました。もしかしたら、常にタフな意思決定を求められるスタートアップの経営者というロールが、自然と打鍵を強くするのかもしれませんね。恐るべき指力です。 これ以外の人は、割と支給ノートPCである MacBook Pro や ThinkPad のキーボードや トラックパッド を標準で使っている方が多い印象が強いです。 以前の職場にも「良い道具を使っちゃうとそれ以外使えなくなっちゃう気がするから」という理由で大変粗悪なキーボードを使っていた同僚がいますが、そういう意識の方もいるかも知れません。 そもそも、 MacBook Pro や ThinkPad のキーボードの打鍵感がいいというのはありそうです。 MNTSQでは、様々なキーボード・ ポインティングデバイス を社内に布教しちゃうような、エンジニア、セールス、リーガル担当を広く募集しています! この記事を読んで「お前らこだわりが足りてないんじゃないか」「メ カニ カル派や自作派はいないのか!」ということを思われた方は、ぜひカジュアル面談にて 宗教戦争 お話しできればと思います! なお記事と全く関係ないですが愛車です。 愛車近影 *3 この記事を書いた人 中原大介 MNTSQ社でSREをやってます。ついに地元から愛車をもってきましたが、駐車場でマニュアルの坂道バック発進を強いられるため、乗る機会は控えめです。 *1 : 純正レカロのついた シャレード・デトマソ という車だったのですが、いろいろな思い出詰まっていて、機会があればまた乗ってみたい車だったりします *2 : セキュリティ的な観点から持ち込みは不可。自作キーボードの場合は要相談 *3 : 筒石駅 にて
アバター
はじめに テキスト情報から 自然言語処理 の 機械学習 モデルを構築する際には文字列データのみが解析の対象になりますが、文書全体から情報を抽出するモデルを構築する際には、文書レイアウト情報が重要になります。 通常の 自然言語処理 とは異なり、文書レイアウト情報は画像も入力の対象として想定されるため、文字の位置を表すBounding Box等が アノテーション として想定されます。 このように、文書に含まれる文字情報だけではなくレイアウトに関する情報も扱うタスクをDocument Analysisと呼んだりします。 本記事ではDocument Analysisタスクに関わるデー タセット の作成について考える一助とするため、 LayoutLM の論文で用いられたデー タセット を見ていきます。 IIT CDIP 1.0 dataset 原論文: Building a Test Collection for Complex Document Information Processing タバコ産業のドキュメントライブラリ:Legacy Tobacco Documents Library (LTDL) から取得したデータ データは ここで 公開されている 非公式では [D]Where can I find IIT CDIP 1.0 dataset? : datasets のスレッドで別の場所に ミラーサイト についての議論もある 研究利用は可。商用利用については明示的には書かれていない(コピーの配布を商用利用のために行うのはNG) XML の メタデータ も用意されており、以下のような属性が取得可能 タイトル ボディテキスト 書類の形式 日付 組織名 メーリングリスト の断片が属性の読み取りに役立つ [Trec-legal] 17-May-07 update of description of IIT CDIP v. 1.0 / TREC 2007 data RVL-CDIP Dataset 原論文: Evaluation of Deep Convolutional Nets for Document Image Classification and Retrieval 論文自体は画像からDNNをつかって文書分類するというもの 新しいデー タセット がContributionの一つになっている IIT CDIP 1.0 dataset を元にして各文書の画像に対して手紙、Eメール、フォームなどのカテゴリを アノテーション し、分類問題としてのタスクを想定している デー タセット 公開サイト: Evaluation of Deep Convolutional Nets for Document Image Classification and Retrieval ライセンスについて明示的な言及なし FUNSD 原論文: FUNSD: A Dataset for Form Understanding in Noisy Scanned Documents フォーム形式の文書に特化したデー タセット RVL-CDIP Datasetを元にしてフォームデータのテキスト位置のBounding Boxが アノテーション されている デー タセット 公開サイト: FUNSD 研究目的のみ利用可能 アノテーション データの形式は Json で、以下の情報を含む 意味のある文字のグループ フォーム内文字の意味を表したラベル(Question, answerなど) 単語一つ一つに対するBounding Box 文字のグループ同士に関係があるか SROIE 原論文: ICDAR2019 Competition on Scanned Receipt OCR and Information Extraction デー タセット の公開に合わせてコンペを開催した模様、コンペで成績の良かった手法も紹介されている レシート画像のデー タセット : Overview - ICDAR 2019 Robust Reading Challenge on Scanned Receipts OCR and Information Extraction ライセンスについて明示的な言及なし 各レシートについて、Bounding Boxと内部の文字情報が入った列が CSV 形式で アノテーション されている レシート全体から抽出できる メタデータ が Json 形式でまとめられている 以下 メタデータ のフィールド一覧 company date address total おわりに 簡単ではありましたが、今回は文書レイアウトに関連したタスクとデー タセット の紹介をしました。文書レイアウトを考慮したモデルの開発に本記事が少しでも役に立てば幸いです。 参考 [1912.13318] LayoutLM: Pre-training of Text and Layout for Document Image Understanding LayoutLM (Layout Language Model)を試したら精度がめっちゃ上がった件について - Cinnamon AI Blog この記事を書いた人 yad ビリヤニ 食べたい
アバター
いろいろな bot 組織をスケールさせる上でコーポレートエンジニアリングは非常に重要である。MNTSQではissue-drivenで誰でも気軽に bot を作ることができる。現在MNTSQのSlackにいるいくつかの bot を紹介しよう。 施錠と消灯を催促する bot 観葉植物の水やりを催促する bot (ガイド付き) 社員8名ぶんのサラダを社長に取りに行かせる bot (実際には当番制) 詳細は この記事 で紹介している。 にゃーんと言うと何かを返す bot デイリーの共有会でグループ分けをする bot セキュリティチェックの案内を出す bot 以下では「デイリーの共有会でグループ分けをする bot 」の仕組みを紹介する。 デイリーの共有会でグループ分けをする bot MNTSQでは毎日14:00-14:15でDaily-syncという共有会を行っている。Daily-syncは異職種間コラボレーションの質を高めるために、互いが何を何故やっているか共有しfeedbackしあう場である。以前はメンバー全員の状況を共有していたが、社員数の増加に伴いグループ分けする運びとなった。 この bot はDaily-syncのグループ分けを自動化するために作られた。 グループ分けの流れ 1. [ bot ] Daily-syncの1時間前にリマインドを送る 「参加します」スタンプを押すよう催促しているが、ダークテーマだと可視性がとても低い。 2. [参加者] 共有内容をSlackに投稿しスタンプをつける Daily-syncではこの内容をベースに議論する。 3. [ bot ] グループごとに場所と参加者が通知する スタンプをつけ忘れると抽選から漏れてしまうので注意が必要だ。 bot の仕組み Google Apps Script (GAS)を使う。私は普段 Python を使うことが多いので、行末の セミ コロン付け忘れを克服するのに苦戦した。GASの基本的な使い方は他の記事に譲るとして、ここではポイントだけ記しておく。 使用するSlack API これらの API が使えるように、事前に トーク ンの発行や権限設定を済ませておく。 conversations. history : 当日の参加者の投稿を集める chat.postMessage: リマインドやグループ分けの結果を投稿する 当日の投稿を集める conversations.history の oldest パラメータを使えば当日の投稿を集められる。 oldest にはSlackのタイムスタンプ形式で渡す。例えば2021-08-26は 1629903600 である。 let now = new Date (); let y = now.getFullYear(); let m = now.getMonth(); let d = now.getDate(); let ts = ( new Date (y, m, d).getTime() / 1000).toString(); let options = { "method" : "get" , "payload" : { "token" : token, "channel" : "C0123456789" , "limit" : 1000, "oldest" : ts } } ; let response = UrlFetchApp.fetch( "https://slack.com/api/conversations.history" ,options).getContentText(); let data = JSON.parse(response); 特定のスタンプがついた投稿を集める conversations.history の結果をfilterする。 let participants = data [ "messages" ] .filter(message => { return ( "reactions" in message) && (message [ "reactions" ] .filter(reaction => { return reaction [ "name" ] === "sankashimasu" ; // :sankashimasu: がついてる人 } ).length > 0); } ); グループ分けする なるべく職種でバラけるようにランダムにグループ分けする。 参加者を職種で バブルソート する 上からA,B,C,A,B,C,A,...のようにグループに割り当てる(この例だと3グループできる) グループ内でシャッフルする(共有の順番をランダムにするため) participants.map(v => v [ "team" ] = shainTeams [ v [ "user" ]] ); // 職種を割り当てる participants.sort((a, b) => ((a [ "team" ] < b [ "team" ] ) ? -1 : ((a [ "team" ] > b [ "team" ] ) ? 1 : 0)) // バブルソート ); const nGroups = Math.ceil(participants.length / 6); // 最大6人のグループを作る let groups = new Array (nGroups).fill().map((_, i) => shuffle(participants.filter((_, j) => (j + i) % nGroups === 0)) ); 特定の時刻に投稿する GASの GUI で設定できる時間ベースのトリガーは1時間単位で分は選べない。GASの組み込みモジュール ScriptApp を使えば任意の時刻で実行できる。例えば当日の13:58に main 関数を実行するには、次の関数をトリガーに設定しておく。 function setTrigger() { let now = new Date (); now.setHours(13); now.setMinutes(58); now.setSeconds(0); ScriptApp.newTrigger( 'main' ).timeBased().at(now).create(); } おしまい MNTSQはプロダクトだけでなく組織も育てがいのあるフェーズだ。様々な領域でエンジニアリング能力を発揮できるエンジニアを募集している。
アバター
こんにちは。MNTSQの堅山です。 去る8/10に、Ubieさんと共同で「Vertical AI Startup Meetup」というイベントを開催しました。 connpass.com 弊社MNTSQはいわゆるリーガルテックという領域で、企業法務に携わる方々を相手にプロダクトを提供しています。 Ubieさんも主に医療従事者の方々を対象にプロダクトを提供されており、以下のような共通点があるなぁと勝手に親近感を持っておりました。 ubie.life ドメイン の深い領域に取り組んでいる プロフェッショナルに対してプロダクトを通じてサービスを提供している ドメイン のプロフェッショナルがエンジニアと協働して製品を開発している 創業者に ドメイン のエキスパートがいる(弁護士、医師) UbieさんもMNTSQも、医療・法律といった特定の ドメイン (=Vertical)を扱うスタートアップです。 こういったスタートアップを表す表現として「Vertical AI Startup」というものが2017年頃に提唱されています。そして、うまく業界の課題を解決するためには上記のような特徴が必要だよね、ということが提案されています。まさに簡潔に我々のやりたいことを伝えられる概念だなと思っているのですが、日本ではあまり言及している人がいませんでした。そこでこのイベントは「Vertical AI Startup」概念を多くの方々に知ってもらい、また ドメイン の深い業界での問題解決に興味を持ってもらえればと思いまして、Ubieさんのご協力の下開催いたしました。 当日は質問もたくさん出まして、思っていた以上の反響をいただきたいへん盛り上がるイベントになりました。発表スライドは以下をご覧ください Vertical AI Startupとはなにか わたしからは、「Vertical AI Startup」ってなに、ということについて話しました。 Vertical AI製品の品質管理 Speaker: MNTSQ 稲村 @kzinmr 概要: ドメイン 知識の重要度が高いVertical AI製品の開発では、モデルの改善に先行してデータ中心の品質管理を行う費用対効果が高いことを説明し、 機械学習 製品開発におけるデータ品質保証の姿について議論する。 speakerdeck.com 医者の言葉、患者の言葉、エンジニアの言葉 Speaker: Ubie 奥田さん @yag_ays 概要:「テク ノロ ジー で人々を適切な医療に案内する」をミッションとするUbieでは、様々な領域で 自然言語処理 の技術を活用しています。 医療言語処理という括りの中にも、医師や患者といった話者の違いや、カルテ文書から 話し言葉 といった表現の違いに至るまで、様々な課題や研究対象が存在します。 ドメイン ならではの複雑さに対してどのような切り口で課題に向き合い、 自然言語処理 の実応用に向けた開発を経験するなかで感じた難しさや面白さを、実例とともにご紹介します 。 speakerdeck.com おわりに 今後とも、Vertical AI Startupの面白さを発信していければと思います。 MNTSQ、Ubieさんともに絶賛採用中ですので、ぜひご興味をお持ちいただいた方はカジュアル面談にいらしてください! www.mntsq.co.jp recruit.ubie.life また、MNTSQでは一緒に勉強会などを実施してくださる企業を募集しております!ぜひ ご連絡 ください この記事を書いた人 堅山耀太郎 MNTSQ社で取締役として 機械学習 ・ 自然言語処理 に関わるもろもろをやっています。好きな食べ物は担々麺です。
アバター
Webアプリケーションやバッチジョブを運用していくにあたって、エラーの影響範囲の調査のため、APIへのアクセスIDやバッチのジョブIDのついたログは欠かせないです。 このような類のIDをログとして残す場合には、そのIDの影響下にある全部の処理に対して該当のIDを渡したいです。 この類の処理をフルスクラッチで書こうとする場合、下記事項を考慮する必要があります。 ログのために既存のコードの引数を変えることはしたくないため、IDをロガー経由で渡す必要がある IDを渡されるロガーは処理の間はIDを保持してほしい、それ故ロガーはシングルトンインスタンスか、それに相当するモジュールになる asyncの入ったコードに対応するにはasyncio コルーチン間の割り込みを考慮する必要がある また、ログレベル、ログの出力時刻等の個々のログに付随する属性を解析する際にmachine readableになっていてほしいという要望もあります。 これらを考慮した処理を実現してくれるライブラリが今回紹介する structlog になります。 www.structlog.org Tomcatライクなロゴが印象に残ります。 導入 毎度おなじみpipです pip install structlog 処理IDが絡んだログのサンプル structlogを用いて処理IDをロガーにどのように出力させるか見ていきます。 まずユーザIDをログに出力するサンプルコードとして以下を考えます。 from structlog import get_logger log = get_logger() def some_other_function(): log.info('other event') def main(): some_response = {'user_id': 12, 'user_agent': 'Chrome'} # some_response = {'user_agent': 'Chrome'} if 'user_id' in some_response: log.info('some event', user_id=some_response['user_id']) some_other_function() else: log.info('exceptional prec') some_other_function() if __name__ == '__main__': main() structlogはロガーのdebug, info等のメソッドに任意のフィールドを追加して出力することができます。(l.14) この実装の場合だと以下のようなログ出力になり、冒頭で想定した「IDの影響下にある処理のログにすべてIDがついてほしい」という要件を満たせません。 2021-05-25 19:04.28 [info ] some event user_id=12 2021-05-25 19:04.28 [info ] other event ログコンテキストの構築 IDの影響下にある処理のログすべてにIDを持たせるには、次のように書きます。 import structlog from structlog.threadlocal import ( bind_threadlocal, clear_threadlocal, merge_threadlocal ) from structlog import configure configure( processors=[ merge_threadlocal, structlog.processors.add_log_level, structlog.processors.StackInfoRenderer(), structlog.dev.set_exc_info, structlog.processors.format_exc_info, structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S", utc=False), structlog.dev.ConsoleRenderer() ] ) log = structlog.get_logger() def some_other_function(): log.info('other event') def main(): some_response = {'user_id': 12, 'user_agent': 'Chrome'} # some_response = {'user_agent': 'Chrome'} if 'user_id' in some_response: bind_threadlocal(user_id=some_response['user_id']) log.info('some event') some_other_function() else: log.info('exceptional prec') some_other_function() clear_threadlocal() if __name__ == '__main__': main() structlog.threadlocal モジュールはスレッド単位でログに出力される属性を管理します。 merge_threadlocal : ログの設定のprocessorにかませることでスレッドに固有の変数をログに出力するよう設定が行われます bind_threadlocal : スレッドに固有の変数のロードを行います clear_threadlocal : スレッドに固有の変数をクリアします このケースでは以下のログが出力され、user_id が見つかった後の処理すべてにIDが付与されることがわかります。 2021-05-25 20:30.50 [info ] some event user_id=12 2021-05-25 20:30.50 [info ] other event user_id=12 Python3.7 より導入されたcontextvarsモジュール関連の機能で structlog.contextvars モジュールもあります。これは asyncio を用いるようなサーバサイドAPIにおける、ID情報を付加したロギングに有効です。 構造化ログについて もう一点、structlogがサポートする構造化ログについても見ていきます。下記のログはpythonを開発していればよく出力する形式のログです。 2021-04-02 10:47:02 [INFO] some precessing log この類のログはhuman readableではありますが、ログの解析を行う際にはmachine readableではないので不便です。 構造化ログとはログの出力として頻繁に出される時刻、ログレベル、ログ本体等の情報がmachine readableな構造化データ(Json, TSV)にされたものを指します。 { "message": "some processing log", "lineno": 1590, "pathname": "/app/job/src/apiclient.py", "job_id": 20, "timestamp": "2021-04-02 10:47:02", "level": "info" } このような形式のログもstructlogはサポートしています。 Json形式のログ設定サンプル Jsonのログ出力用設定のサンプルです import sys import structlog from structlog.threadlocal import ( bind_threadlocal, clear_threadlocal, merge_threadlocal ) import logging structlog.configure( processors=[ merge_threadlocal, structlog.stdlib.filter_by_level, structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.TimeStamper(fmt="iso"), structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), structlog.processors.JSONRenderer() ], context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) logging.basicConfig( format="%(message)s", stream=sys.stdout, level=logging.INFO, ) log = structlog.get_logger() def some_other_function(): log.info('other event') def main(): some_response = {'user_id': 12, 'user_agent': 'Chrome'} # some_response = {'user_agent': 'Chrome'} if 'user_id' in some_response: bind_threadlocal(user_id=some_response['user_id']) log.info('some event') some_other_function() else: log.info('exceptional prec') some_other_function() clear_threadlocal() if __name__ == '__main__': main() ロガーの出力を標準入出力に吐く設定がstructlog経由で行えないのは謎ですが、標準ライブラリのロガーを生成するようにstructlog経由で設定でき(l.25)、標準ライブラリのロガーに対して標準入出力に出力するように設定しています(l.29) 以下出力です {"user_id": 12, "event": "some event", "logger": "__main__", "level": "info", "timestamp": "2021-05-26T03:16:44.301541Z"} {"user_id": 12, "event": "other event", "logger": "__main__", "level": "info", "timestamp": "2021-05-26T03:16:44.301945Z"} 構造化されたログが出力されました。 参考記事 標準ライブラリの範囲で構造化ログで出力するようにしてみる - podhmo's diary この記事を書いた人 yad ビリヤニ食べたい
アバター
たくさんの文字列(や離散的な符号列)をメモリに載せないといけないんだけど、いろんな制約があって通常のList[str]では載らない…ということありませんか?(まぁあんまりなさそうですね) たまたまそういうことがあったので、その際に検討した内容をまとめておきます TL;DR メモリをもっと増やしましょう 富豪的 に解決できるならいつでもそれが最高です しかし、世の中それでなんとかならんこともたくさんあります 用途があうのであれば専用のデータ構造を採用する 例えばもし共通のprefixやsuffixが存在し、順序に興味がなければtrie treeなどが使えます 例えば、 弊社 であれば、法人名をメモリに持ちたいなんてときもあります。そういうときに法人名の辞書をtrieで持ったりすることがあります 「株式会社」「 一般財団法人 」や「銀行」といった共通語がたくさんでてくるのでtrie treeでごりごり削れます pygtrie とか marisa-trie とかが簡単に使えます 他にも様々なデータ構造はあるので、そういうものが使える場合もあるかもしれません 短い文字列が大量にある場合は、とりあえず結合し、各文字列の始点のindexをnumpyのint arrayで持つ Python では1つobjectを作るために50byte程度が消費されます。そして Python の配列はobjectのポインタの配列であり、配列の要素の数だけメモリ上の別の場所にobjectが存在しています したがって、短い文字列を大量に配列で抱えていると、配列内のobjectのポインタ(8byte) + objectを作るためのオーバーヘッド(50byte程度) で大量のメモリが浪費されます 長大な1つのstringのobjectと、indexを持つnumpy array配列であれば、これらの浪費は起きません numpy arrayは要素も含めて1つのobjectになっています 固定長であれば、numpyのmatrixで持つ 固定長の文字列であれば、numpyの2次元配列が活用できるかもしれません。 この場合、固定長であることを利用して、indexの配列が不要になります str型/bytes型の範囲で工夫する Python3.3以降のstr は含まれる文字の範囲によって、1,2,4byte/文字を使い分けてくれます たとえば、ASCIIであらわせる範囲の文字列だけしか含まれない場合、すべての文字が1byte/文字になりますし、ひらがな等の範囲の文字が含まれれば、すべての文字が2byte/文字になります。絵文字が含まれると4byte/文字になるイメージですね したがって、絵文字などを削除してstrを作り直せば、日本語が含まれていても多くの文字列は2byte/文字にはできる可能性があります さらに、bytes型にencodeする手法でもっと圧縮できる場合があります strの エンコーディング は UTF-8 であると思いがちですが、strは unicode のコードポイントをそのまま保存しているので UTF-8 /16/32のどれにも直接対応しているわけではありません。 UTF-8 や UTF-16 は可変長 エンコーディング であり、strのように1文字でもASCIIでない文字が入ったら全部2byte or 4byte / 文字になります、みたいなことはなくいい感じに圧縮して出してくれます 扱う文字列にあったencodingを選択することで、圧縮が可能です 使われる文字が決まっている場合は、bit単位での表現を用いることでメモリを削減できる さらに、出現する文字列の範囲を制限すればメモリを削減できるというア イデア は、1byte単位ではなくbit単位で適用することができます このような場合には bitarray を使えます。例えば、 塩基配列 であれば2bitで1文字を表すことができますが、bitarrayでは(変換処理が入るために)CPU時間を犠牲にコレを実現できます この記事では、ここのコードを載せます 細かく見ていくと… さて、まとめとしてはこれくらいなのですが、以下、2,3,4,5のところについて詳しくみていきます モチベーション: Python のList / numpyの文字列型は意外とメモリを食う まず、いろいろな形式で保存した際のメモリサイズを測定してみましょう。 突然ですが、 Python において以下の文字列のメモリサイズは何byteでしょうか?ナイーブに考えると、char型で表現するとして、”a”は1byte、[“a”,”b”]は2byteですね クイズ "a" ["a","b"] np.array(["a","b]) np.array(["a","bc"]) こたえ 1: 50byte >>> import sys >>> sys.getsizeof("a") 50 でっかいですね!これは Python には GC などもついているので、各オブジェクトにそのための参照カウンタなどがついていたりするからです 2: 172byte >>> sys.getsizeof(["a","b"]) + sys.getsizeof("a") + sys.getsizeof("b") 172 超でかいですね! なぜこの計算になるかというと、 Python のListはそれぞれのオブジェクトへのポインタの配列となっているからです。 以下のように、普通にgetsizeofをすると72byteに見えるのですが、先述の通り、 ポインター の配列なので、これには”a”や”b”といった中身のメモリが含まれてないんですよね。 なので、文字を増やしても同じ長さです >>> sys.getsizeof(["a","b"]) 72 >>> sys.getsizeof(["alphabet","beta"]) 72 要 素数 に合わせて、PyObjectへの ポインター (8byte)が増えていっているのがわかりますね >>> sys.getsizeof([]) 56 >>> sys.getsizeof([0]) 64 >>> sys.getsizeof([0,1]) 72 3: 112byte numpyのarrayは1つのobjectとしてまるっと測定できます >>> import numpy as np >>> sys.getsizeof(np.array(["a","b"])) 112 1つの要素を増やすごとに4byte = 32bitを使ってますね >>> sys.getsizeof(np.array(["a","b","c"])) 116 >>> sys.getsizeof(np.array(["a","b"])) 112 >>> sys.getsizeof(np.array(["a"])) 108 numpyの配列は、要 素数 を増やせばほとんど Python のobjectのオーバーヘッドは考えなくて良くなりそうです 4: 112byte かといってなんでもnumpyにstrをつっこめばいいかというとそうではありません。 1文字増やしただけなのに、8byte = 64bit増えました。 >>> sys.getsizeof(np.array(["a","bc"])) 120 なぜかというと、実は、dtypeが”<U1”から”< U2 ”に変わっているからです。Uは Unicode という意味で、1は文字数です。numpyは行列演算ライブラリですから、各要素のメモリ上のサイズは一緒になるようにレイアウトすることが前提になっています。今までは Unicode を1文字扱えるメモリを確保して各要素を扱っていたのが、2文字のデータが入ってくるようになったので、すべての要素を2文字でようにしているわけですね。結果として、4byte x 2要素分 = 8byte確保されたメモリが増えたのでした。ASCIIの文字列の範囲でも1文字が4byteなのは、冒頭に述べたようなPython3.3以降の工夫などを採用せず、numpyではすべての文字をコードポイントそのまま、つまりint32的に扱っているからのようですね。行列演算ライブラリとしての設計上の要請だと思いますが。 データによって型が決まるのはこのときだけで、この後に長い文字列を追加しても自動的に型が変わるわけではありません。以下のように U2 の配列だと3文字目は落ちてしまいますね >>> arr = np.array(["a","b","cd"]) >>> arr[0] = "aaa" >>> arr array(['aa', 'b', 'cd'], dtype='<U2') さて、長々書いてきましたが、こういう感じなので、すっと思いつくコンテナはこういう欠点がありそうなことがわかります Listで持つと Python のObjectのオーバーヘッドが大きいため、要 素数 が多いとメモリを浪費する(1objectあたり50byteとかそういうオーダー) numpyの文字列型は文字列の最大長x4byteのメモリを確保する Listに関しては、長文であればまぁ別に良いとも言えますが、文ごとに配列に持っているみたいな場合だと、実は数十パーセントが Python 関連のメモリに使われてしまいます。心理学の日本語論文の1文は平均72字らしいですが、例えばこの記事の冒頭の75字の文は224byteになりますが、このうちの50byte近くが Python のわちゃわちゃなわけで、20%以上のメモリが浪費されていそうです >>> sys.getsizeof("学生の作文を添削した際、僕が「1文ずつが長い」と指摘すると、彼は「『何文字程度』というような基準はあるのでしょうか?」と尋ねて(反駁して?)きました。") 224 numpyの場合は、文字数が揃ってないと相当メモリ量が厳しそうです。また、 ナチュラ ルにやると1文字4byteになってしまいます。コレを防ぐには、文字列表現の型をちゃんといじればよさそうですね。 適切な型やencodingを選択する str型の1文字が何byteなのかというのは実は1,2,4byteの3通りがあります 。含まれる文字の unicode コードポイントの最大値が1,2,4byteのどの範囲に入るかによって変わります。そのため、こんな挙動になります >>> sys.getsizeof("a") # asciiのみなら1byte/文字 50 >>> sys.getsizeof("aa") 51 >>> sys.getsizeof("aaa") 52 >>> sys.getsizeof("あ") # ひらがなが入ると2byte/文字 76 >>> sys.getsizeof("ああ") 78 >>> sys.getsizeof("あああ") 80 >>> sys.getsizeof("ああa") # ascii範囲の文字も2byte/文字になる 80 >>> sys.getsizeof("💫") # 絵文字が入ると4byte/文字 80 >>> sys.getsizeof("💫💫") 84 >>> sys.getsizeof("💫💫💫") 88 >>> sys.getsizeof("💫💫a") # ascii範囲の文字も4byte/文字になる 88 ですから、絵文字みたいな文字を削除するとさくっとメモリ専有量が半分になるかもしれません。 さらに、 UTF-8 のような 可変長エンコーディング を使うとより圧縮できます。1文字にXbyte使う、ということを先に決めず、適応的に変更するためより効率よく表現できる場合があります。 >>> sys.getsizeof("今日はいい天気ですね。It's nice weather today.") 144 >>> sys.getsizeof("今日はいい天気ですね。It's nice weather today.".encode("UTF-8")) 90 >>> sys.getsizeof("今日はいい天気ですね。") 96 >>> sys.getsizeof("今日はいい天気ですね。".encode("UTF-8")) 66 この場合、型がbytes型になることに注意が必要です >>> "今日はいい天気ですね。".encode("UTF-8") b'\xe4\xbb\x8a\xe6\x97\xa5\xe3\x81\xaf\xe3\x81\x84\xe3\x81\x84\xe5\xa4\xa9\xe6\xb0\x97\xe3\x81\xa7\xe3\x81\x99\xe3\x81\xad\xe3\x80\x82' bitarrayを使う 特定の ユースケース では文字種別がもっと少ない場合がありそうです。 例えば、英語の大文字とスペースだけしか入ってこないテキストであれば、28種類しか トーク ンは存在しないわけで、これは2^5=32>28なので本当は5bitで表記できそうです。 塩基配列 のようなデータの場合、 トーク ンはATCGの4種類、つまり2bitで表せます。ここまでくると、いい感じの型が用意されてないですね。こういうときに bitarray が便利です。 github.com より頑張る方法として、複数の文字を1つの単位にpackする方法がありますが、そこまではだるいので今回はやりません。本当は例えば、5種類の トーク ンであれば、何も工夫しなければ、3bit (2^3=8>5) をそれぞれの トーク ンに割り当てることになりますが、5x6 =30 < 32 = 2^5なので、2つの トーク ンを3bit x 2 = 6bitではなく、5bitであらわせます(2 トーク ン目として6をかけているのは、2 トーク ン目が存在しない場合を表さないといけないので)。また、 トーク ンの出現頻度に応じてビット列を割当てる Huffman Codingもできる のですが、これも今回はやめときましょう。 では、ここではbitarrayを用いて大量の 塩基配列 データを持つことを考えましょう。bitarrayのobjectをたくさん持つと、 Python のobjectがいっぱい生成されてしまうので、長いbitarrayのobjectを用意し、各stringのindexを別の配列に持つようにします from random import choice, randint from bitarray import bitarray import numpy as np # 文字種をさだめます。塩基配列を例に取ります alphabets = [ "A" , "T" , "C" , "G" ] alphabet_size = len (alphabets) # 何bitを1文字に割り当てればよいかを決め、bit列(bitarrayクラスのインスタンス)と実際の文字の対応関係をdict型で作ります bits_per_char = int (np.ceil(np.log2(alphabet_size))) dictionary: Final[Dict[ str , bitarray]] = { ch: bitarray( bin (idx)[ 2 :].zfill(bits_per_char)) for idx, ch in enumerate (alphabets) } dictionaryの中身はこんな感じです >>> dictionary {'A': bitarray('00'), 'T': bitarray('01'), 'C': bitarray('10'), 'G': bitarray('11')} 変換するデータを用意します。ここでは1文字以上最大長100文字の一様分布の長さの 塩基配列 を10000シーケンス用意します max_len = 100 n = 10000 data_to_convert = [ "" .join([choice(alphabets) for _ in range (randint( 1 ,max_len)) ]) for x in range (n)] 変換しましょう。 indices に各シーケンスの開始地点を持っておきます converted_data = bitarray() indices = [] for seq in data_to_convert: indices.append( len (converted_data)) converted_data.encode(dictionary,seq) indices = np.array(indices,dtype=np.int32) # numpyのint32arrayに変換しておきましょう これで、変換ができました。bitarrayのindex accesorを直接叩くとbit列が得られますが、 >>> converted_data[:100] bitarray('1001011001011011100111000110110111100100101000010011000100101011010000111111000111110011000101001010') さきほどの辞書を用いて復号すると、きちんと文字列が得られます >>> seq = converted_data[indices[0]:indices[1]] >>> "".join(seq.decode(dictionary)) 'CTTCTTCGCTGATCGTGCTACCATAGATACCGTAAGGGATGGAGATTACCCCTATCGCGCACCTA' 一致しますね。 >>> data_to_covert[0] 'CTTCTTCGCTGATCGTGCTACCATAGATACCGTAAGGGATGGAGATTACCCCTATCGCGCACCTA' メモリ使用量を確認してみると… >>> sys.getsizeof(converted_data) + sys.getsizeof(indices) 170282 >>> sys.getsizeof(data_to_convert) + sum([sys.getsizeof(x) for x in data_to_convert]) 1082618 1,082,618byte - 170,282 = 912,336byte 20%弱くらいになってますね!うれしーい。 ここでは、冒頭で書いた2,5の工夫を両方取り入れていますが、 寄与分 を試算してみると、だいたいこんな感じっぽいですね 2bitで表現した分の寄与 (もともとのstr(ascii範囲)は1byte/文字=8bit/文字ぐらい) 10,000シーケンス x 平均50文字 x -6bit = -3,000,000bit = -375,000byte 10,000object -> 1objectになったことの寄与 減少分: -50byteぐらい x 10,000シーケンス = -500,000byte numpy配列の追加分: 4byte (int32) x 10,000シーケンス = +40,000byte 実際には、さまざまな ユースケース でどの工夫がどれくらいきくかはデータ依存かと思いますが、これくらい削減できることもあるということを知っておくともしかしたら役立つことがあるかもしれません MNTSQ株式会社( モンテスキュー )では大量の文字列を扱うエンジニアを募集しています!(が、実際あんまりこういうことはしませんね…) www.mntsq.co.jp この記事を書いた人 堅山耀太郎 MNTSQ社で取締役として 機械学習 ・ 自然言語処理 に関わるもろもろをやっています。好きな食べ物は担々麺です。
アバター
入社して3ヶ月が経った。事業戦略・組織文化・プロダクトに対する解像度はだいぶ高まった実感があるが、実はまだメンバー1人1人のことを良く知らない。 そうだ、Slackのログを分析しよう。 当社では多くのコミュニケーションをSlackで行う。また、情報のopennessを重視しており、Slackのpublic channelの割合は高い水準を維持している。 private channelもそれなりにあるが、弊社のprivate channelの多くはメンバーのロールに対応するように作られており、実際にはほとんどのメンバーが参加しているため、opennessは保たれていると言って良い。 Public and private channel Slackには アナリティクスダッシュボード がという機能がある。上のグラフはアナリティクス ダッシュ ボードの スクリーンショット だ。簡単な分析ならこれで十分かもしれない。しかし、メンバー1人1人のことを知るにはもう少しパーソナライズされた情報が欲しい。 Slack API を使ってメッセージを取得する Slack API を使えば全メッセージを取得可能だ。使用する API と取得するデータは次の通り。 users.list 各ユーザの名前を取得する。 bot やゲストは対象外とする。 conversations.list channelの一覧を取得する。ここではpublic channelのみとする。 conversations.history 各channelのメッセージを取得する。 conversations.replies 各メッセージのスレッドを取得する。 ユーザとchannelは一度に全て取得できるが、メッセージはサイズが大きいので分割して取得する必要がある。conversations. history とconversations.repliesは Tier 3 なので毎分50回となるように間隔を空けて API を呼び出す。 前処理 正規表現 でテキストに含まれるメンション・URL・コードブロックを除去する。 また、事務やテスト用途のchannelはノイズになるので除外する。個別にチャネル名を指定しても良いが、手作業でやるには数が多いので、ここでは異なり語数で 閾値 を下回るものを除外する。他にも トーク ン比(Type-Token Ratio)の 閾値 を使うことも検討したが、 トーク ン比の分母である延べ語数がchannelごとに大きく異なりそのままでは比較できないので見送る。 データの外観 期間: 2020/5/1 - 2021/4/30 ユーザ数: 25 channel数: 69 メッセージ数: 35200 リアクション数: 19678 ユーザ数の推移 メッセージの投稿ユーザを月毎にユニークカウントした。この1年で10人増えて25人になったようだ。めでたい。 ユーザごとのメッセージ数 ユーザごとにメッセージ数、上位10件をプロットした。1位はBoard4人を抑えてSEの西村だ。彼はMNTSQの サラダ技術顧問 でもある。メンバーは入社時期が異なるので単純な比較はできないが、それでもサラダ技術顧問の圧倒的存在感には恐れ入る。 人気のリアクション リアクション数、上位10件をプロットした。なんとポジティブなリアクション。メンバー同士の高いリスペクトが伺える。 ユーザごとのリアクション数 ユーザごとのリアクション数、上位10件をプロットした。リアクション最多は4月に入社したばかりであるはずの広報の高井だ。この勢いは凄まじい。社内のあらゆる場面でリアクションし、活性化を促している証拠だろう。 “口癖”を分析する メンバーごとにTF-IDFの総和を求め、値が高い上位10件を“口癖”とした。 トーク ナイザーには SentencePiece を使う。SentencePieceのパラメータは次の通り。 spm.SentencePieceTrainer.Train( input = './data/texts.txt' , model_prefix= 'models/sentencepiece' , vocab_size= 8000 , character_coverage= 0.9995 , ) TF-IDFのパラメータは次の通り。 vectorizer = TfidfVectorizer( strip_accents= None , token_pattern= r'(?u)\S\S+' , max_df= 0.01 , max_features= 10000 ) SentencePieceで1文字の トーク ンが大量に作られるが、それらは token_pattern=r'(?u)\S\S+' で除外する。また、 max_df=0.01 で「です」や「から」を除外する。代表してBoard4人の結果を次に示す。 率直に述べると、「わかる」という気持ちになる。 板谷 の「...!」や生谷の「あー」「はい」はものすごくよく見るし、安野の「done」「あざます」はSlackに限定されずリアルでもよく使われる。堅山は上から下まで全てそれっぽい。他のメンバーに対しても分析してみたが、非常に興味深い結果で、社内でも好評だった。 職種ごとの人気のリアクション 職種をBoard・Engineer・Legal・Sales/CS/PRの4つに分類する。SalesとCSとPRは、あまり詳細にすると人数が少なくなり統計的性質の観察が難しくなるので、便宜上同じカテゴリにした。単純なカウントでは前述した人気のリアクションと被るので、少し工夫をする。職種ごとの特徴を出したいのでTF-IDFで全員がよく使うリアクションが上位に来ないようにする。TF-IDFのパラメータは次の通り。 vectorizer = TfidfVectorizer( strip_accents= None , token_pattern= r'(?u)\S\S+' , stop_words=[ '+1' ], ) 職種ごとのTF-IDFの総和を求め、上位5件をプロットしたものが次の図である。 職種ごとに結構好みが分かれているように見える。Boardに女性はいないはずだが、なぜかwoman-gesturing-okが上位にある。Sales/CS/PRは状態管理のためにリアクションを使っているようだ。 “返信”の関係を可視化する “返信”とはチャンネル内またはスレッド内のあるメッセージと1つ前のメッセージの関係で、チャンネル内では2つのメッセージの時間間隔が1時間以内のものとする。“返信”の数を重みとする隣接行列をPCAで平面に写し、職種ごとに色分けしたものが次の図である。 図を見ると、なかなか綺麗に職種で分かれていることがわかる。 (0.05, -0.1)付近の緑は弁護士で、他のLegalとは仕事が異なりSalesチームと活動しているため、Salesの付近にあると考えられる (0.0, -0.05)付近の赤はCSで、顧客コミュニケーションのために他職種のメンバーと頻繁に情報共有しているため、他職種間の中心にあると考えられる (-0,05, 0.05)付近の橙3点はEngineerの中でも 機械学習 を主に扱うメンバーで、彼ら(私もその1人だが)は アルゴリズム 開発のためにLegalと活発に議論しているため、Legalの付近にあると考えられる 実際にはSlack以外でも GitHub Issueやオフラインでの議論も活発に行われており、この図に表れない関係は存在するものの、例えば平面上で距離が遠いLegalとSales/CS/PRのコミュニケーションに改善点はないか?といったフィードバックを得ることができる。
アバター
特許・契約書・ 有価証券報告書 ・企業関連ニュースなど、実応用上の 自然言語処理 では、会社名を認識したいという場面に非常に多く出くわす。 会社名らしい文字列をテキストから抽出することは、 形態素解析 器の辞書を用いたり固有表現抽出モデルを学習することである程度実現される一方で、抽出した会社名をレコード化して分析などに用いる際には、いわゆる 名寄せ の問題が発生する。 自然言語処理 における 名寄せ に似た問題は、エンティティリンキングや共参照解析といったアプローチで探求されており、実応用上は前者のアプローチが採られることが多い印象がある。 *1 名寄せ タスクをエンティティリンキング的に解くためには、帰着先の知識ベース・辞書が予め存在していることが必要だが、研究の文脈では知識ベースとして Wikipedia が採用されることが多い。 Wikipedia を用いる利点は多くあり、様々なエンティティ種に対してそこそこの カバレッジ がある点、単なる辞書に比べた場合にマッチングに使える素性がエンティティの文字表層以外にも豊富にある点、ページへのアクセス数などテキスト外の統計情報も使える点、データ処理のツールが充実している点などが挙げられる。 一方で、会社名のような特定エンティティ種に対してきちんと対応していきたい場合、 Wikipedia の カバレッジ や信頼性では満足できないこともある。 当記事では、そのような場合にクイックに利用を検討できる日本の会社名辞書を2つ紹介する *2 : 国税庁 法人番号データ 全件ダウンロード可能・商用利用可能 NISTEP *3 企業名辞書 全件ダウンロード可能・商用利用可(CC-BY-SA) 国や事業者が整備している企業名関連データは他にも存在し、たとえば以下のようなリソースについては当記事では触れていない: EDINET ( 金融庁 管轄): ダウンロード可能かつ商用利用可能であり財務分析に有用 事業所母集団データベース ( 総務省 管轄): 公的機関の統計調査用途;事業所の利用は不可(?) 登記情報提供サービス (法務局管轄):網羅性は高いが、閲覧有料(件数あたり課金) 特許情報標準データ , 整理標準化データ(2019年まで) ( 特許庁 管轄): 要ダウンロード申請、特許分析に有用 その他、企業経済・与信情報のような調査分析用途の有償企業情報サービス: 帝国データバンク ・ 東京商工リサーチ ・ 東洋経済 データサービス・各種与信情報、等 国税庁 法人番号データ あらゆる法人に対して振られる法人番号データと、その法人名が対応付けられたデータである。 法人番号とは 番号法 により定められる、税金や保険の手続きに用いられる番号のようだ。 登記手続きに用いられる 会社法 人等番号と並んで、あらゆる法人に紐づくID情報であるという意味で、企業名のマスタ情報に適した参照情報であるといえる。 https://www.houjin-bangou.nta.go.jp/setsumei/ 「 行政手続における特定の個人を識別するための番号の利用等に関する法 律」(以下「 番号法 」といいます。)に基づき、法人に対して法人番号を指定し、指定後速やかに、商号又は名称、本店又は主たる事務所の所在地及び法人番号を公表するとともに、対象の法人へ法人番号を通知しています。 抽出可能な情報は リソース定義書 から参照でき *4 、「法人番号」と「商号又は名称」や「英語表記」「フリガナ」、場合によっては「国内所在地」といったフィールドが分析の基本となるだろう。 また、以下で紹介するフィールドを用いることで、法人格を考慮した 名寄せ 処理や、合併等の組織再編や商号変更・組織変更などを考慮した企業の同一性認識を行うことができる: 「法人種別」:いわゆる法人格に相当する情報で、以下のような区分コードが入っている。数にしては株式会社がほとんどを占めるが、 法人格は多岐にわたる ので「その他の設立登記法人」も多く存在する。 "101": "国の機関", "201": "地方公共団体", "301": "株式会社", "302": "有限会社", "303": "合名会社", "304": "合資会社", "305": "合同会社", "399": "その他の設立登記法人", "401": "外国会社等", "499": "その他", 「承継先法人番号」「変更事由の詳細」:いわゆる商号変更履歴を追うのに役立つ。登録法人の商号変更履歴が 国税庁 データのサイトで閲覧できるが、おそらくこのフィールドの情報を使用していると推測される。参考のために、商号変更履歴の多い企業の例を挙げておく。 三菱ケミカル 合併対象の「承継先法人番号」の値の例: 6010001146760 合併対象の「変更事由の詳細」の値の例: 平成31年4月1日東京都千代田区丸の内一丁目1番1号三菱ケミカル株式会社(6010001146760)に合併し解散 「訂正区分」:商号変更・登記抹消・削除といった登録データの修正操作情報が入っている。新規追加以外の操作を持つ法人名は 重複登録 されることになるので、重複・バージョン管理が必要な場合に役立つ。 "01": "新規", "11": "商号又は名称の変更", "12": "国内所在地の変更", "13": "国外所在地の変更", "21": "登記記録の閉鎖等", "22": "登記記録の復活等", "71": "吸収合併", "72": "吸収合併無効", "81": "商号の登記の抹消", "99": "削除", 国税庁 法人番号データを使用している事例としては、TISが作成・公開されているJCLdicという企業名辞書がある。 企業名の重複除去に加えて、簡易的に表記ゆれパターンも生成していて、企業名の 名寄せ を試す用途には便利な辞書となっている。 www.tis.co.jp このデータを辞書に、BCCWJや 毎日新聞 データを コーパス にした固有表現抽出のデー タセット も生成されている。 ただし、企業名には一般的な名称が混じっていたり、 国税庁 データの企業名分布は特に非常にLong-tailであるという都合上、企業名の固有表現ラベルを辞書マッチで生成するのはなかなか難しそうだと推察される。 Long-tail性の参考として、 中小企業庁が公開している中小企業と大企業の数 (2016年)は以下のような内訳となっている *5 : 大企業:1万1157社(0.3%) 中小企業:357.8万社(99.7%) 中小企業の定義 また、 日本取引所グループが公開している 上場企業の総数は3,770社(2021/04/27)となっている。 資本金や法人格別などの企業数内訳に関する統計については、 国税庁 の統計調査結果でも公開されている: www.nta.go.jp NISTEP企業名辞書 国税庁 データの企業名分布は特に非常にLong-tailであるという点を指摘した。 これはあらゆる法人名を登録するという都合上避けられない問題であり、 名寄せ を文字表層ベースで行う場合には特に問題になる。 具体的には、非常に似通った企業名であっても、一方は上場企業、もう一方はほとんど知られていない会社というケースなどがそれにあたり、表記ゆれ対応泣かせの問題が 国税庁 データでは多く発生する。 こういった問題に対して関心のある企業群を絞りたい需要が発生するが、特許分析・産業 イノベーション 研究の一貫としてNISTEP企業名辞書というデータが公開・管理されている。 最新版のマニュアルは以下のページのダウンロードページから参照できる。 www.nistep.go.jp 管理対象となる企業名の収集基準を以下に項目で示す(ver.2020_2マニュアルより引用;詳細はマニュアル参照): 企業名辞書に掲載する企業は、原則、次の 5 つの条件の何れかを満足する企業の 論理和 で構成する。 ① 特許出願数累積 100 件以上 ② 株式上場企業 ③ 特許出願数の伸び率大 ④ NISTEP 大学・公的機関名辞書掲載企業 ⑤ 意匠・商標登録数 ⑥ 大学発ベンチャー 企業 (中略) その他、上記条件に該当しない企業として、次の事由による掲載企業がある。 ⑦ 持株会社 制移行に伴い設立された事業会社 ⑧一部事業の譲渡に伴い設立された会社 ⑨名称変更又は吸収合併した企業が登録事由に該当 ⑩その他 利用事例としては、特許情報標準データを加工した IIPパテントデータベース *6 に対して、特許申請人の 名寄せ に当辞書が用いられている事例が紹介されている。 www.nistep.go.jp ダウンロード形式としては、 RDB にインポート可能なテーブルテキスト形式と、xlsx形式とがある。 マニュアルより企業名辞書のERDを引用する: 企業名辞書ERD(NISTEP 企業名辞書 利用マニュアル (ver.2020_2 対応版)より引用) 件数を比較すると、 国税庁 法人番号データ(20200731)ののべ件数が4,874,674件(重複含む)に対し、NISTEP企業名辞書(ver.2020_2)ののべ件数が24,414件。 国税庁 データとの類似点・差分点をベースに列情報の比較を行うと、商号変更履歴情報に加えて、連結企業情報や財務・業種情報などがデフォルトで含まれるのは非常に便利な点である。 また、外部情報との連携も対応しているため必要に応じて参照されたい。 以下、分析に有用なテーブル・列情報をいくつか紹介する(「」で囲まれたものはマニュアル内に解説のあるテーブル・フィールド名): 商号変更履歴情報 「沿革テーブル」 / history , history_id 名称使用開始事象: {1:"設立", 2:"名称変更"} 名称使用終了事象: {1:"現存", 2:"名称変更", 3:"合併", 4:"破産", 5:"清算"} 連結企業情報(親子会社関係) 「連結企業テーブル」 財務・業種・ ベンチャー タイプ情報 「EDINET コードテーブル」 「 証券コード テーブル」 「企業規模テーブル」 「業種( 証券コード 協会)テーブル」 「業種(日本標準産業分類)テーブル」 「 大学発ベンチャー テーブル」 各種外部テーブル連携 IIP パテントデータベースとの接続テーブル NISTEP大学・公的機関名辞書との接続テーブル 東洋経済新報社 『日本の会社データ4万社』との接続テーブル 等(他詳細は HP参照 ) 「法人格コード」 前株・後株・中株が区別されてる点に注目 「法人番号」 国税庁 データと連携可能だが、法人番号に紐付けられていない事例も存在するため全てに対して単純なJOINはできない 法人番号を値に持つもの: 12365件 法人番号を値に持たないもの: 12049件 以上、企業名の解析に有用な公開辞書データを2種類紹介した。 この記事はMNTSQ株式会社の業務時間内に書かれた。 MNTSQ株式会社では業務 ドメイン 知識の深化と 自然言語処理 技術の適用による製品の高品質化にご協力いただける方を募集しています: hrmos.co この記事を書いた人 稲村和樹 自然言語処理 エンジニア。爬虫類が好き。 *1 : 抽出と 名寄せ (エンティティリンキング)をend-to-endで解くモデルも提唱されている: GitHub - facebookresearch/GENRE: Autoregressive Entity Retrieval *2 : ライセンス等 利用規約 の詳細は公式マニュアルを参照ください *3 : 文部科学省 直轄の研究機関: https://www.nistep.go.jp/ *4 : ダウンロードできる csv にはヘッダがないので対応付けは目視で頑張ってください *5 : 2020年の 国税庁 データでは重複排除前の総件数は約490万件 *6 : 商用利用不可
アバター
この記事では、 Google スプレッドシート で当番表を作り、 Google Apps Scriptで当番をSlack通知する機能を実装する。 この記事は以下の記事の続編である。 未読の方は先に読んでおくことをお勧めしたいところだが、実はあまり関係が無い。 note.com 西村、サラダ技術顧問に就任するってよ Googleスプレッドシートのサンプル Slack IDの取得方法 スクリプトを書く スクリプト エディタを開く Google Apps Scriptでスプレッドシートを読み込む Google Apps ScriptからSlackに通知する 「当番リスト」シートからSlack IDを取得する SlackのIncoming Webhook URLを発行する Slack通知を実装する スクリプトの全体像 定期実行を仕掛ける 機能拡張編 事前に通知する 複数人への通知に対応する スプレッドシートで当番を管理する際の問題点 このエントリーで対応していないポイント Slack Appへの移行 当番表で設定した日数を過ぎた場合の対応 当番を自動で決める処理を入れる 土日の通知を避ける 仲間募集中 この記事を書いた人 西村、サラダ技術顧問に就任するってよ さて、私はある日サラダ技術顧問に就任する運びとなった。 そこで任されたのが当番表の作成である。 弊社では「黙っていても健康が運ばれくる」という触れ込みの元、サラダの サブスクリプション の会員を増やし、サラダ宅配の労力の低減を図ろうとしている。 これまでは当日の朝にランダムに抽選されており、予定が読めずその時に予定がある人に債権が溜まっていく問題があった。 債権が溜まってもずっと解消されない状態が慢性化しており、抽選されても取りに行かなくても良くなってしまい、あまり精神衛生上良くなかった。 この当番制はそれを解決する試みである。 当番表を作ること自体は難しくはない。 おもむろに Google スプレッドシート を開き、日付と当番の列を作り、そこに土日祝を避けて当番を順番に入力していくだけである。 とは言え、 Google スプレッドシート を開いて誰が当番かなど誰も確認しないので、誰もサラダを取りに行かない事態が発生してしまう。 そこでSlack通知をすることになった。 Google Apps Script(GAS)の出番である。 developers.google.com Google スプレッドシート のサンプル サンプルとして、以下のような Google スプレッドシート を利用している。 「当番表」シート 「当番リスト」シート Slack IDの取得方法 SlackのIDについては、Slackで対象の人の名前をクリックする→「全プロフィールを表示する」→「その他」→「メンバーIDをコピー」から可能だ。 これが結構めんどくさいのだが、Slackの仕様変更によりSlack IDを利用しないと通知できないようになっている。 スクリプト を書く スクリプト エディタを開く 通知を導入したい スプレッドシート を開く。 そこから スクリプト エディタを開く。 メニューから「ツール」→「 スクリプト エディタ」から開くことができる。 ちなみにここの画面遷移は微妙で、 複数アカウント で利用している場合はうまく遷移できない事がある。 その場合は https://script.google.com/d/hogehoge... の https://script.google.com/ と hogehoge の間に u/0/ や u/1/ など入れてみると良い。 すると以下のような画面が開いたはずだ。 ここからは一応テックブログらしく、コードらしきものを貼っていきたい。 以下の箇所にコードを入力していって欲しい。 function myFunction() { // この部分 } Google Apps Scriptで スプレッドシート を読み込む まずシートを読み込む。 const spreadsheet = SpreadsheetApp.openById( 'シートのID' ) 「シートのID」は、URLの https://docs.google.com/spreadsheets/d/12345asdfghj-ASDFGHJK2345678hjklasdfg/edit#gid=0 となっている「12345asdfghj-ASDFGHJK2345678hjklasdfg」の部分だ。 ハイフンなど挟まっていてダブルクリックで選択できないのが難点である。 次に、読み込んだ スプレッドシート から「当番表」シートを開く。 const sheet = spreadsheet.getSheetByName( '当番表' ) シートから入力のある最後の行を取得する。 const lastRow = sheet.getDataRange().getLastRow() とりあえず一覧をコンソールに出力してみるとしよう。 for ( var i = 2; i <= lastRow; i++) { console.log(sheet.getRange(i, 1).getValue()) console.log(sheet.getRange(i, 2).getValue()) } ここまでの全体像は以下のようになる。 function myFunction() { const spreadsheet = SpreadsheetApp.openById( 'シートのID' ) const sheet = spreadsheet.getSheetByName( '当番表' ) const lastRow = sheet.getDataRange().getLastRow() for ( var i = 2; i <= lastRow; i++) { console.log(sheet.getRange(i, 1).getValue()) console.log(sheet.getRange(i, 2).getValue()) } } ここまで書き終えたら、上の「実行」ボタンをクリックしてみよう。 実行時に小難しい権限確認のウィンドウが表示されるが、「許可」すればOKだ。 すると以下のような実行ログが表示されるはずだ。 これを参考に、今日の日付の担当者を取得しよう。 JavaScript なので、Date()クラスが利用できる。 const nowDate = new Date () ただ、出力してみると、現在時刻なので00:00:00ではなくてマッチできない事が分かる。 Sun Apr 12 2021 11:39:44 GMT+0900 (Japan Standard Time) これをマッチさせる方法はいくつかあるが、ここでは文字列にしてしまう事でマッチさせる事にする。 const nowDateStr = Utilities.formatDate(nowDate, 'JST' , 'yyyy/MM/dd' ) すると以下のような形式で取得できる。 2021/04/12 それでは先ほどのforループを更新して、当日の当番を取得しよう。 for ( var i = 2; i <= lastRow; i++) { const dateVal = Utilities.formatDate(sheet.getRange(i, 1).getValue(), 'JST' , 'yyyy/MM/dd' ) if (dateVal === nowDateStr) { console.log(sheet.getRange(i, 2).getValue()) break } } 実行すると当日の当番が取得できたはずだ。 山田 Google Apps ScriptからSlackに通知する 続いてSlack通知を実装していこう。 「当番リスト」シートからSlack IDを取得する まず先に、先ほどの当番を変数に格納しておく。 var toubanUser = null for ( var i = 2; i <= lastRow; i++) { const dateVal = Utilities.formatDate(sheet.getRange(i, 1).getValue(), 'JST' , 'yyyy/MM/dd' ) if (dateVal === nowDateStr) { toubanUser = sheet.getRange(i, 2).getValue() break } } 次に、当番を取得するのと同じ方法でSlack IDを取得しよう。 const slackIDSheet = spreadsheet.getSheetByName( '当番リスト' ) const slackIDLastRow = slackIDSheet.getDataRange().getLastRow() for ( var i = 2; i <= slackIDLastRow; i++) { const cellVal = slackIDSheet.getRange(i, 1).getValue() if (cellVal === toubanUser) { console.log(slackIDSheet.getRange(i, 2).getValue()) break } } これで当番が取得できたはずだ。 取得できたら、先ほどと同様に変数に格納するように スクリプト を更新する。 var todayToubanID = null for ( var i = 2; i <= slackIDLastRow; i++) { const cellVal = slackIDSheet.getRange(i, 1).getValue() if (cellVal === toubanUser) { todayToubanID = slackIDSheet.getRange(i, 2).getValue() break } } SlackのIncoming Webhook URLを発行する 取得した情報をベースに、Slackに通知していくのだが、そのためにはIncoming Webhook URLを発行する必要がある。 Slackの左上から、「その他管理項目」→「アプリを管理する」 するとSlack App Directoryが表示されるので、「カスタムインテグレーション」をクリック。 ここでIncoming Webhookを追加するのだが、弊社は追加設定済みのため、以下を参考に追加いただきたい。 slack.com なお、Incoming Webhookは現時点で非推奨で、Slack Appの利用が推奨されているのだが、まだ移行ができていないため、ご了承いただきたい。 「Slackに追加」をクリックする。 投稿したいチャンネルを選択して、「Incoming Webhookインテグレーションの追加」をする。 すると以下のようにWebhook URLが発行されるので、それを利用する。 Slack通知を実装する 必要情報が揃ったので、Slackへの通知を実装していこう。 まず先ほど取得したWebhook URLと投稿先のSlackチャンネルを定数として宣言する。 const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/HOGE/FUGA/PIYO const SLACK_CHANNEL = '#touban' Slackに送信する文字列を組み立てていく。 まず表示する文字列を作る。 var msgStr = '' if (todayToubanID !== null ) { msgStr = "今日の当番は <@" + todayToubanID + "> です! \n " } ポストするチャンネルを含んだ JSON を組み立てる。 const jsonData = { "text" : msgStr, "channel" : SLACK_CHANNEL } const payload = JSON.stringify(jsonData) Google Apps Scriptでメソッド渡す際に必要な諸情報を含めたハッシュを作る。 const options = { "method" : "post" , "contentType" : "application/json" , "payload" : payload } それをWebhook URLにPOSTする。 UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options) 書けたら実行してみよう。 実行する際に追加の権限を求めるダイアログが表示されるので、「許可」する。 すると、以下のような投稿がSlackに表示されたはずだ。 おめでとう、これで実装は完了だ。 スクリプト の全体像 全体としては以下のような スクリプト になったと思われる。 function myFunction() { const spreadsheet = SpreadsheetApp.openById( 'シートのID' ) const sheet = spreadsheet.getSheetByName( '当番表' ) const lastRow = sheet.getDataRange().getLastRow() const nowDate = new Date () const nowDateStr = Utilities.formatDate(nowDate, 'JST' , 'yyyy/MM/dd' ) var toubanUser = null for ( var i = 2; i <= lastRow; i++) { const dateVal = Utilities.formatDate(sheet.getRange(i, 1).getValue(), 'JST' , 'yyyy/MM/dd' ) if (dateVal === nowDateStr) { toubanUser = sheet.getRange(i, 2).getValue() break } } const slackIDSheet = spreadsheet.getSheetByName( '当番リスト' ) const slackIDLastRow = slackIDSheet.getDataRange().getLastRow() var todayToubanID = null for ( var i = 2; i <= slackIDLastRow; i++) { const cellVal = slackIDSheet.getRange(i, 1).getValue() if (cellVal === toubanUser) { todayToubanID = slackIDSheet.getRange(i, 2).getValue() break } } const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/HOGE/FUGA/PIYO' const SLACK_CHANNEL = '#touban' var msgStr = '' if (todayToubanID !== null ) { msgStr = "今日の当番は <@" + todayToubanID + "> です! \n " } const jsonData = { "text" : msgStr, "channel" : SLACK_CHANNEL } const payload = JSON.stringify(jsonData) const options = { "method" : "post" , "contentType" : "application/json" , "payload" : payload } UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options) } 定期実行を仕掛ける 最後に忘れてはならないのが、定期実行である。 毎朝手動で誰かが実行ボタンを押すのは避けたいからだ。 まず スクリプト エディタの左メニューから「トリガー」をクリックする。 続いて右下の「トリガーを追加」をクリック。 以下のように設定した: 実行する関数を選択: myFunction デプロイ時に実行: Head イベントのソースを選択: 時間主導型 時間ベースのトリガーのタイプを選択: 日付ベースのタイマー 時刻を選択: 午前9時〜10時 エラー通知設定: 今すぐ通知を受け取る 動作確認はメソッドの日付やトリガーの時刻を編集して行うと良いだろう。 機能拡張編 事前に通知する 初期実装としては上記のとおりだが、以降も地道な改善が成されている。 当日の朝に当番が判明したところで、予定が入ったなどの場合に対応ができないため、前日に通知されることになった。 部分のみだが、次の日を取得するには、以下のようにする。 var dt = new Date () dt.setDate(dt.getDate() + 1) 複数人への通知に対応する この スクリプト は様々な当番がある箇所に流用されている。 弊社ではブログポストを当番制で回しており、来週は誰が書くのかという通知に利用している。 また、隔週でデプロイを行っており、その担当の通知にも利用している。 デプロイは複数人で担当して行っているため、複数人に対してメンションをする必要がある。その場合は、以下のように配列などに代入すれば良い。 toubanUser = sheet.getRange(i, 2).getValue() // ↓ slackIDLastCol = slackIDSheet.getDataRange().getLastRow() const toubanUsers = new Set() for ( var j = 2; j <= slackIDLastCol; j++) { const val = sheet.getRange(i, j).getValue() if (val !== null && val !== '' ) { toubanUsers.add(val) } } Slack IDの検索も同様にループ処理をする。 そしてSlackに送信するメッセージ文字列の組み立てをループ処理に変更すると良い。 var msgStr = '来週のデプロイ担当は' for (i = 0; i < nextWeekToubanIDs.length; i++) { msgStr += " <@" + nextWeekToubanIDs [ i ] + "> " } msgStr += 'です!' スプレッドシート で当番を管理する際の問題点 色々便利な Google Apps Script(GAS)と スプレッドシート だが、問題が無いわけではない。 スプレッドシート は別の スプレッドシート のシートを参照できないので、このような使い方をした場合、Slack IDのシートが通知したい スプレッドシート の分だけ増殖してしまう。 この問題に対する解決策は現状見つけられていない。 このエントリーで対応していないポイント このエントリーで解説していない部分があるので、まとめておく。ぜひともトライしてみて欲しい。 Qiitaなどで記事にしていただいても良いだろう。 Slack Appへの移行 先にも述べた通りだが、カスタムインテグレーションは非推奨となっており、そのうち利用できなくなる可能性がある。 それを回避するためにはSlack Appへの移行をする必要がある。 当番表で設定した日数を過ぎた場合の対応 設定した最後の日の担当まで来るとサイレントに通知が来なくなる。これを回避するためには、最後の日を検知して「そろそろ設定が無くなりそうだ」と通知すると良い。 当番を自動で決める処理を入れる 基本的に順番に割り振っていくことが予想されるので、切れれば自動的に補充されるような処理も検討できるだろう。 そこで問題になってくるのが祝日の処理だろう。 土日の通知を避ける 1日前の通知だと、つまり月曜日のサラダ当番は日曜日に通知されてしまう。 これを回避するためには、土日を判定してその次の日をピックする必要があるだろう。 仲間募集中 弊社ではこのように、日常の些細な作業にもITエンジニア的なアプローチで改善を試みている。 ローマは一日してならず、運用改善も同様に地道な努力の積み重ねなのである。 弊社はこういった地道な改善ができるエンジニアを募集中である。 hrmos.co hrmos.co hrmos.co hrmos.co なお#saladチャンネルで利用しているBROS TOKYOのサラダもオススメだ。 1ヶ月契約して毎日食べ続けると800円弱というお手頃価格で、結構ボリュームのあるサラダを頼めて、好きなトッピングを3点選べて、体にも良いのでオススメだ。ちなみに私は契約していない。 BROS TOKYO この記事を書いた人 Yuki Nishimura 雑食系エンジニア
アバター
背景 久しぶりに NFS を触るかもしれないということで、ちょっと素振りをしてみました。 NFS を最後に触ったのは10年くらい前、まだあの頃は学生だったと思います。そんなわけで、久々なのであらためて、手順を頭に入れなおしてみました。 今回試す条件は緩いものなので、プロダクトのなかで使う場合やより高い要求がある場合には、ここの手順だけでは圧倒的に足りませんが、大まかな流れをつかめればいいかなと思います。 NFS とは ここでは簡単に NFS について説明します。 ネットワークをまたいでストレージをマウントさせるための技術 最新の プロトコル のバージョンは4 実質的な最初のバージョン、2が発表されたのは 1984 年のこと。私などよりも年上の歴史ある技術 構築や設定は少し面倒な部類 運用も大変なイメージ 歴史のある技術とは書きましたが、複数のマシンから共有ストレージとして利用する場合には今でも有力な選択肢の一つです。また、物理的なサーバや AWS のEC2などで、自分でストレージを増設できる、あるいはEBSなどのストレージをアタッチできるということであればあまりありがたみはないのですが、ストレージの増設のできない VPS や、共有サーバなどで自分の判断で気軽な増設が出来ない場合にも、選択肢となり得ます。 NFS を動かしてみよう Linux 環境で簡単に試してみましょう。 今回は、 Windows 上に Vagrant / VirtualBox でCentOS7の環境を二つ用意し、ファイルの共有をしてみます。 それぞれ IPアドレス は、サーバ側を192.168.33.150、クライアント側を192.168.33.151で作っています。 NFS の環境構築(サーバー側) 必要なパッケージをインストールしていきます。 $ sudo yum install nfs-utils 本来はここで、各種の設定が必要となりますが、今回はおもむろにサービスを起動していきます。 $ sudo systemctl start nfs 本来はここまででパケットフィルタなどの対応が必要ですが、今回は割愛します。 NFS で共有する ディレクト リについて ストレージを共有すると言うことは、サーバに付属するストレージの一部を他のマシンと共有すると言うことです。まずは、その共有する部分を決定する必要があります。 NFS ではこの部分を exports というコマンドを使って管理を行います。 exportsの設定と反映 exportsの設定は /etc/exports ファイルに記載することになります。 今回は簡単化のために、接続元ホストの設定にクライアント1台のみを指定していますが、ここはネットワークセグメントをまるごと指定する( 192.168.33.0/24 や 192.168.33.0/255.255.255.0 )なども可能です。 今回は、 /export/share_dir を共有してみます。あらかじめ、 ディレクト リは作っておきましょう。 $ sudo mkdir -p /export/share_dir $ sudoedit /etc/export /export/share_dir 192.168.33.151(rw,async,no_root_squash) exportfs コマンドを使って、このファイルの設定を反映させます。 $ sudo exportfs -ra 設定が反映されたか確認してみましょう。 $ sudo exportfs -v /export/share_dir 192.168.33.151(async,wdelay,hide,no_subtree_check,sec=sys,rw,secure,no_root_squash,no_all_squash) どうやらされているようです。 NFS の環境構築(クライアント側) サーバに接続してくる側もパッケージを入れましょう。 $ sudo yum install nfs-utils 次に、マウントしてしてみましょう。今回は、 /mnt/ にマウントしてみます。 $ sudo mount -t nfs4 192.168.33.150:/ /mnt/ $ cd /mnt/export/share_dir/ $ pwd /mnt/export/share_dir というわけで、到達できました。 クライアント側でファイルを作ってみましょう。 $ sudo touch /mnt/export/share_dir/hoge $ ls -l /mnt/export/share_dir/hoge -rw-r--r--. 1 root root 0 Apr 7 09:47 /mnt/export/share_dir/hoge サーバ側で確認してみましょう。 $ ls -l /export/share_dir/ total 0 -rw-r--r-- 1 root root 0 Apr 7 09:47 hoge 同期されました。 今回は、サーバ側で no_root_squash という設定を入れています。このため、クライアント側のroot権限でサーバ側に書き込むことができてしまいます。ここは 潜在的 にセキュリティ上の懸念となるため、設計時の注意が必要です。 大きく NFS 導入の流れはこういった形になります。 EFSを NFS として扱う さて急に話は変わりますが、 AWS のサービスの一つであるEFSというものがありまして、これは NFS のフルマネージドなサービスです。 この場合でいえば、サーバ側の設定については AWS がいい感じに設定してくれますし、クライアント側にしても、専用のクライアントを導入すれば AWS のリソース名(ARN)でマウントすることができます。 AWS にロックインする形で利用するのももちろん便利なのですが、 NFS のフルマネージドサービスですので、当然 NFSの形式でマウントして利用することができます 。 EFSには直接 グローバルIP を付与することはできませんが、適切なセキュリティグループ設定をしてEC2経由で SSH ポート フォワ ーディングを利用すれば、 ローカルのマシンからも接続が可能 になります。 大きなファイルなどを直接流し込みたいときには使ってみてもいいかもしれません。 最後に さまざま運用上、セキュリティ上で考慮すべきの多い NFS ですが、EFSという形でフルマネージドなサービスが登場するほどに需要のあるものです。 使い方と使いどころを考慮して、長く付き合っていけたらいい技術ですね。 NFS に限らず、セキュアなインフラの構築や運用を進めたいMNTSQでは現在もエンジニアの募集をしています。ご興味があれば、 Wantedly にさまざま記事などを掲載していますので、ぜひアクセスしてみてください!! この記事を書いた人 中原大介 MNTSQ社でSREをやってます。最近は 乗り鉄 してることが多いですが、コロナ禍で実家に帰れず、実家においてきた愛車に乗れないことへの腹いせです。
アバター
MNTSQで検索エンジニアをしている溝口です。 今回はElasticsearchでハイライト処理を行う際に利用するUnifiedHighlighterの挙動について簡単に調べる機会があったので、それを簡単に記事にしました。 ハイライト処理とは 検索結果一覧が表示された際に、以下のようにヒットした該当箇所が強調表示される機能のことです。(以下のスクリーンショットだと検索キーワードの「ハイライト」が太文字になっていると思います。) この機能を実現するために、Elasticsearchでは以下の3種類のハイライターが利用可能です。 PlainHighlighter FastVectorHighlighter UnifiedHighlighter それぞれ特徴は異なりますが、今回はハイライトするにあたってそれぞれのハイライターが必要とするリソースにフォーカスします。 PlainHighlighter かつてデフォルトだったハイライターです。ハイライト処理をするためにテキストを再解析します。 必要なリソースは少ないですが、テキストの再解析を行うため、ハイライト処理にかかる時間は長くなりがちです。 FastVectorHighlighter 名前の通り高速なハイライト処理が可能ですが、ハイライト処理をするためにTermVectorという属性を必要とします。TermVectorはディスクに保存する必要があるので、必要なディスク容量は大きくなります。 UnifiedHighlighter 最も新しいハイライターで現在のデフォルトです。かつて存在したPostingsHighlighterというハイライターを改善して作られました。ハイライト処理をする際に以下を適切に選択して処理の高速化をはかります。 POSTINGS (posting listを利用する) TERM_VECTORS (FastVectorHighlighterと同じく、TermVectorを利用する) ANALYSIS (PlainHighlighterと同じく、ハイライト時にテキストを再解析する) POSTINGS_WITH_TERM_VECTORS (posting listとTermVectorの両方を利用する) さて、前置きが長くなりましたが、ElasticsearchのReferenceを読むと、上記のようにそれぞれのハイライターを使う場合にどのようなフィールドのオプションを指定すれば良いかが書いてあります。 https://www.elastic.co/guide/en/elasticsearch/reference/7.11/highlighting.html#offsets-strategy しかしながら、上記のうちUnifiedHighlighterのPOSTINGS_WITH_TERM_VECTORSの記述が見当たらないことに気づきます。 そのため、今回はElasticsearchからUnifiedHighlighterを使うときのOffset strategy(ハイライトを高速化するときに必要な追加の情報)で、 そもそもPOSTINGS_WITH_TERM_VECTORSは利用可能なのか を明らかにし、それに加えて それぞれのOffset strategyを利用した場合のディスク使用量と性能の向上度合い を簡単に計測してみようと思います。 Setup 以下の条件でデータを登録して、indexサイズ、ハイライトの平均処理時間を簡単にみてみたいと思います。 MacBook Pro CPU: 2.4GHz 8Core Intel Core i9 RAM: 32GB DDR4 OS: BigSur Docker: Docker Desktop for Mac 3.2.2 CPUs: 4 Memory: 8GB Elasticsearch: 7.11.2 Elasticsearchは以下のDockerfileでanalysis-kuromojiだけ入れて、イメージをbuildし、そのイメージを元にしたコンテナを以下のコマンドで起動します。 Dockerfile FROM docker.elastic.co/elasticsearch/elasticsearch:7.11.2 RUN /usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-kuromoji 起動コマンド $ docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" 6571a46f9f37 検証に使うデータには、日本語Wikipediaのダンプデータから適当に10万件の本文情報のみを抜き取ったものを使います。 利用するindex名と、調査対象のフィールドの設定差分は以下の通りです。 共通 analyzer = kuromoji wiki_text_a (上述のANALYSISに相当) index_options = positions term_vector = no wiki_text_p (上述のPOSTINGSに相当) index_options = offsets term_vector = no wiki_text_t (上述のTERM_VECTORSに相当) index_options = positions term_vector = with_positions_offsets wiki_text_pt (上述のPOSTINGS_WITH_TERM_VECTORSに相当) index_options = offsets term_vector = yes 一例を挙げるとこんな感じです。 $ curl -XPUT localhost:9200/wiki_text_a -H 'Content-Type: application/json' -d '{ "mappings": { "properties": { "text": { "type": "text", "analyzer": "kuromoji", "index_options": "positions", "term_vector": "no" } } }, "settings": { "number_of_shards": 1, "number_of_replicas": 0 } }' なお、TermVectorを有効にする時の指定がwith_positions_offsetsとyesになっているなど、微妙な違いがありますが、これはElasticsearchではTermVectors/TermPositions/TermOffsetsという異なる情報をterm_vectorという属性にまとめていることに起因しています。 Offset strategyがPOSTINGS_WITH_TERM_VECTORSの場合には、TermVectorsのみを、TERM_VECTORSの場合にはTermVectors/TermPositions/TermOffsetsの3種を全て利用するので、上記のような設定になっています。 indexingが終わったら、以下の通りそれぞれのindexをforce mergeします。 $ curl -XPOST "localhost:9200/wiki*/_forcemerge?max_num_segments=1" $ curl -XGET "localhost:9200/_cat/segments?v" index shard prirep ip segment generation docs.count docs.deleted size size.memory committed searchable version compound wiki_text_a 0 p 172.17.0.2 _2 2 100000 0 389.1mb 1172 true true 8.7.0 false wiki_text_p 0 p 172.17.0.2 _2 2 100000 0 471.8mb 1172 true true 8.7.0 false wiki_text_pt 0 p 172.17.0.2 _3 3 100000 0 584.6mb 3004 true true 8.7.0 false wiki_text_t 0 p 172.17.0.2 _2 2 100000 0 638.7mb 3004 true true 8.7.0 false 結果の取りまとめ前ですが、上記でとりわけ目につくのは、wiki_test_t(term_vector: with_positions_offsets)を指定しているインデックスが、デフォルトのindexオプションのものの1.6倍にもなっていることです。 なお、wiki_test_pt(postings + term vector)は、上述の通りterm_vectorの一部の属性のみを追加しているので、それよりは大きくなっていないことが確認できます。 ハイライト性能は、jmeterを使って簡易に測ります。 今回は簡単に以下の設定で測りたいと思います。 スレッド数: 2 Ramp-Up期間: 1 ループ回数: 10000 ベンチマークに使う検索キーワードはindexに含まれているtermで出現回数が多い&3文字以上のものを10000件ピックして使うことにしました。 クエリには以下を使います。(${text}の部分は10000件のtermが代入されます) { "query": { "match": { "text": "${text}" } }, "size": 10, "_source": ["id"], "highlight": { "fields": { "text": {} }, "type": "unified" } } また、日本語検索ではmatchクエリが使われることはあまりないので、上記の他にmatch_phrase_prefixクエリでも計測してみます。 結果 結果をまとめると以下のようになりました。 matchクエリ index名 Throughput Average Median 95% Line 99% Line wiki_test_a 77.1 25 22 49 71 wiki_test_p 226.6 8 8 11 13 wiki_test_t 208.6 9 8 13 14 wiki_test_pt 220.1 8 8 12 14 ANALYSISとそれ以外では大きな違いが見受けられますが、これならPOSTINGSが最も優秀そうです。 match_phrase_prefixクエリ index名 Throughput Average Median 95% Line 99% Line wiki_test_a 74.6 26 23 43 51 wiki_test_p 73.6 26 24 44 52 wiki_test_t 205.9 9 8 13 14 wiki_test_pt 195.6 9 9 14 16 matchクエリの時とは一転して、POSTINGSはANALYSISと大して変わらない性能となってしまいました。 まとめ ElasticsearchにおけるUnifiedHighlighterとindexの属性指定の関係をまとめて、簡単な性能比較をしてみました。 結果としては、 1. POSTINGS_WITH_TERM_VECTORSは有効 2. 多く情報を保存することでハイライト処理は高速になる。一方で、必要となるディスク領域が大きくなっていく 3. 利用するクエリに応じてどの情報を保存するべきかを判断すべき ということが確認できました。 今回は簡単にディスク容量とハイライトの速度の関係をまとめてみました。それぞれのStrategyにおける使用メモリ量なども機会があれば記事にできればと思います。 この記事を書いた人 溝口泰史 MNTSQ社で検索エンジニアをしています。
アバター
preloadはけっこう難しい mntsqのソフトウェアエンジニアチーム所属のhagiwaraです。 Rails アプリケーションのパフォーマンスチューニングとしてN+1問題を潰すというのはよく行われます。 教科書的には簡単に書けるのですが、現実のアプリケーション開発ではpreloadで頭を悩ませることがあります。 長く開発されてきた Rails アプリケーションは、さまざまな歴史的経緯があり、モデルのリレーションツリー構造が深く、特定の条件下でのみ必要になるテーブルがあちこちにぶら下がっているということが珍しくありません。 preloadは一般的にcontrollerの最終段階で行うことになると思いますが、コードベースが巨大だとリレーションのpreloadの要不要の判定が難しいことがあります。 そんな中で、可能性のあるリレーションを全て保守的にeagar_load/preloadした結果、パフォーマンスに問題を抱えているアプリケーションは決して少なくないという現実があります。 ar_lazy_preload gemを試してみる preloadをlazyに行うことで、上のお悩みを軽減できる可能性のあるgemです。 以下のようなmodelを用意して実験しました # Rails 6.1.3 / ruby 3.0.0 class User < ApplicationRecord has_many :boards end class Board < ApplicationRecord belongs_to :user end User.count #=> 10 Board.count #=> 100 (各userに10) まずは素の Rails の動作から users = User.all # どのuserのboardsもpreloadされていない users.any? { |u| u.boards.loaded? } # => false # 一つのユーザーのboardを一つ参照してみる users[0].boards[0] # user[0]以外のboardsはロードされていない users.all? { |u| u.boards.loaded? } # => false lazy_preloadを使用して動作を見ます users = User.lazy_preload(:boards).all # どのuserのboardsもpreloadされていない users.any? { |u| u.boards.loaded? } # => false # 一つのユーザーのboardを一つ参照してみる users[0].boards[0] # 全てのuserのboardsがpreloadされている users.all? { |u| u.boards.loaded? } # => true 4層のような深い子孫関係にある場合でも期待した動作をしています。 クエリの自由度が高く、クエリパラメータによってpreloadしなきゃいけないものが変化する テンプレートや埋め込む変数が動的に変動して予測が難しい などのケースで保守的に全部preload刺していたようなところは少し楽ができそうです。 この記事を書いた人 hagiwara 実家の猫をリモートで愛でる毎日です
アバター
はじめに 皆様はpythonで書かれたソフトウェアのリアーキテクティング1をどのように進めていますでしょうか? 既存のソフトウェアに新規機能が追加しにくいとか、機能が修正しにくい等の問題がある場合にリアーキテクティングは有効です。 リアーキテクティングの初手としては既存のソフトウェアが抱える課題の洗い出しが行われます。その際にソフトウェア内のモジュール同士の依存関係を図で把握したい場面があります。 モジュール同士の依存関係が図示されていれば、モジュール同士の構造上の問題点を伝えやすくなり、かつモジュール同士の関係を将来的にどのように落としていくかも議論しやすくなります。 このような用途に用いるpython用の依存関係解析ツールとして、今回はpydepsを紹介します。 pydeps.readthedocs.io なお、本記事で扱うコードは下記にアップロードしてあります。 GitHub - UsrNameu1/PydepsSample: Sample module code to describe how to use pydeps package インストール方法&ミニマムな使い方 グラフを図示する機能を有するため、本体のインストールの前にGraphvizのインストールが必要です。 Linux環境上であれば各ディストリのパッケージマネージャ経由で、OSXであればbrew経由で brew install graphviz 本体はpipでインストールします。 pip3 install pydeps 使い方は pydeps [--options] [pythonファイル名|モジュールディレクトリ名] です。 例えば以下の構造をもったディレクトリpackage1があり、 └── package1 ├── __init__.py ├── moduleA.py └── moduleB.py 各モジュールの内容が次のようになっているとします。 moduleA.py class A: def foo(self): pass moduleB.py from .moduleA import A class B: def bar(self): _ = A() この時、ルートディレクトリ上で pydeps package1 を実行することで、以下の図がsvgとして出力されます。 Out オプションの説明 以上が簡単な使い方の説明でした。その他にもpydepsには循環インポートの洗い出しや他ライブラリを図示する際の細かい設定等をオプション経由で行う機能があります。 主要なオプションを表にまとめました。 オプション 内容 -T FORMAT 図の出力フォーマット. FORMAT=svg or png --show-cycles 循環インポートが発生しているパッケージのみを図示 --max-bacon INT 対象ファイル/パッケージから依存が指定したホップ以上離れたモジュールを除外する --only MODULE_PATH 図示する対象をMODULE_PATHから始まる範囲に絞れる、スペース区切りで複数指定可能 --reverse 依存の矢印を逆方向にする --cluster 後述 このなかのフラグを使って実際にグラフを出力してみます。 --show-cyclesフラグによる循環インポートの洗い出し このフラグを用いて、循環インポートがあるようなパッケージを洗い出せます。先程のpackage1にさらに次の2つのモジュールが入ったとします。 moduleC.py from .moduleD import D class C: def foo(self): _ = D() moduleD.py from .moduleC import C class D: def bar(self): _ = C() --show-cycles フラグがない場合は、先程あったmoduleA, moduleBの双方も図に出力されますが、--show-cycles フラグを用いたコマンドの実行で循環インポートを含むノードのみ出力されます。 In pydeps package1 --show-cycles Out cluster関連機能 他のライブラリやパッケージをimportしているコードについては--cluster フラグが有用です。 package2パッケージを作成し、その中にmoduleE.pyを作成します。 ├── package1 │ ├── __init__.py │ ├── moduleA.py │ ├── moduleB.py │ ├── moduleC.py │ └── moduleD.py └─── package2 ├── __init__.py └── moduleE.py moduleE.py from sklearn.feature_extraction.text import TfidfVectorizer class E: def get_vectorizer(self): return TfidfVectorizer() その上でコマンドを実行します。 In pydeps package2/moduleE.py Out この図はscikit-learnの中身の依存関係も図示していますが、--cluster フラグを使えばモジュール同士の関係をよりざっくりと描画できます In pydeps package2/moduleE.py --cluster Out さらにフォルダアイコン形式での表示の有無や複数のノードをクラスタとして扱うかどうかを制御するための調整用フラグがあります: --max-cluster-size : フォルダアイコンとして表示されるノード数のしきい値を指定 --min-cluster-size:モジュールがクラスタとして扱われるノード数のしきい値を設定 実際に使ってみます In pydeps --cluster --min-cluster-size 2 --max-cluster-size 3 package2/moduleE.py Out 最後にモジュール範囲の最大ホップ数を指定する --max-bacon と --cluster の機能を組み合わせることで全体を俯瞰した図を見てみましょう。 ├── main.py ├── package1 │ ├── __init__.py │ ├── moduleA.py │ ├── moduleB.py │ ├── moduleC.py │ └── moduleD.py └── package2 ├── __init__.py ├── moduleE.py ├── moduleF.py └── moduleH.py main.py from package2.moduleH import H if __name__ == '__main__': _ = H() moduleF.py from package1.moduleB import B from package1.moduleC import C class F: def foo(self): _ = B() _ = C() moduleH.py from .moduleF import F from .moduleE import E class H: def bar(self): _ = E() _ = F() In pydeps --cluster --max-bacon 5 --min-cluster-size 2 --max-cluster-size 4 main.py Out おわりに この記事ではpydepsを用いてpythonコードの依存関係を図示する方法を紹介しました。本記事で紹介しきれなかった機能として設定ファイルの読み込みや、依存グラフのテキスト形式での出力等もあり、掘っていくとCI等の開発プロセスにも組み込めそうです。 この記事を書いた人 yad ビリヤニ食べたい レガシーソフトウェア改善ガイド (Object Oriented Selection) 第五章 | Chris Birchall, 吉川 邦夫↩
アバター
こんにちは、MNTSQでSREとして勤務している中原といいます。 プライベートも含めて、技術記事は久しぶりな気がします。がんばります。 さて、さっそくですが、日本人にとって、あるいは、韓国の方や中国の方も含めて、コンピュータ上でそれぞれの国の言葉を扱おうとしたときに苦労するのが文字コードです。 かつては(あるいは今も)、Shift JIS、EUC-JPなど、OSや環境などによって使われる文字コードが異なり、相互の連携や、同じOSでも設定次第で大いに苦労したものでした(と聞いておりますし、個人でPCを楽しんでいたときには苦しんだりした記憶があります)。 そうこうしているうち、多くのOSで標準的な文字コードとしてUnicodeが採用されるようになりました。Windowsでは内部でUTF-16LEを採用しています。Linuxでは、UTF-8を標準とすることが多くなりました。 Unicodeに統一がはかられるにつれ、かつてと比べ、環境差異の埋めやすさも、使える文字の種類についても増え、大きく利便性があがりました。「とりあえずUTF-8」などと、乱暴でも一杯目のビールよろしく指定しておけば、多くの場合困ることはなくなりました。 一方で、一言でUnicodeを使うという文脈の中にも様々な切り口から見た方式の種類があり、それぞれの違いを意識しなければいけない場面も出てきました。たとえば、UTF-8やUTF-16LEなどといった符号化スキーマの違いなどが有名ですが、この記事では”正規化 (Form Normalization)という側面について記述していきたいと思っています。 思っていますといっても、内容としては実は4年ほど前に個人のQiitaに書いた記事の改訂版になります。 かいつまんでまとめると、 当時、私はS3に日本語ファイル名のものをアップロードした awscliからアップロードしたファイルを検索しようとしてみたがマッチしなかった 原因はUnicode正規化の方式が、ファイル名と検索クエリの間で異なるせいで、人間には同じ見た目でも、内部的にコードが異なっておりマッチしなかった なので、どういうアップロードのしかたをしたときにS3でのファイル名がどういったフォーマットになるのかというのをまとめてみた というものでした。 正規化の種類 ユニコードの正規化とはなんでしょうか。 他のサイトにわかりやすいサイトも多くありますのでここでは割愛しますが、簡単に言えば、「が」という文字を見たときに、これを「が」という一つの文字として扱うか、「か」+「゛(濁点)」として扱うか、という点の取り決めのことです。濁点、半濁点の他、異字体(「神」と「神」など)、半角全角まで含めるか(「カ」と「カ」を同等に扱うか)などの観点でも扱いが変わります。 これらの扱いの違いによってNFC、NFD、NFKC、NFKDという4つの正規化方式があります。 Unicodeの規定では、この正規化の方式が異なっていても、それぞれ同様に扱うように決められていますが、必ずしもすべてのアプリケーションの実装がそうなっているわけではなく、また、ユースケースによっては厳密にわけなければいけない場合もあるわけで、上記のように検索に引っかからないこともあるわけです。 この正規化形式は、ソフトウェアやOSの処理系、ファイルシステムを経ることにより、暗黙的に変化する場合があります。それによって、見た目上は一致している文字列が内部では異なる文字列として扱われると言った不具合が出てくることがあるわけです。 本記事で試すこと 二つの内容を実施しました。 まず、Pythonのプログラムを使ってMac、Windowsそれぞれの環境で、NFC、NFD、NFKC、NFKDそれぞれの正規化方式と、なにも指定しない場合でファイルを作ってみて、ファイル名のバイト列がどのように生成されるかを確認してみます。 作るだけの簡単なプログラムなので、以下のようなものになります。文字列は、それぞれ濁点の含まれる文字ですね。なお、chr(0xfa19)は"神"です。 import os import unicodedata FORMS = ['default', 'NFC', 'NFKC', 'NFD', 'NFKD'] STRINGS = ["1_が", "2_ガ", "3_ガ", "4_㍊", "5_神", "6_{0}".format(chr(0xfa19))] def make_files(): for form in FORMS: os.mkdir(form) for string in STRINGS: if form == "default": with open(form + "/" + string, "w") as f: f.write("") else: with open(form + "/" + unicodedata.normalize(form, string), "w") as f: f.write("") 次に、上記の方法で作ったファイルをいくつかの方法でAmazon S3にアップロードし、S3でどういった扱いになるのかを再びPythonのプログラムで確認していきます。 def list_files(): session = boto3.session.Session() s3 = session.resource("s3") bucket = s3.Bucket(BUCKET_NAME) for i in bucket.objects.all(): print("{0}: {1}".format(i.key, i.key.encode("utf-8"))) 今回はMacで4つのアップロード方法、Windowsで5つのアップロード方法を試します。Macでは、awscliでのs3 cpとs3 sync、boto3によるPythonプログラムによるアップロード、ChromeでのAWSコンソールから。Windowsではaws cliでのs3 cpとs3 sync、boto3によるPythonプログラムによるアップロード、ChromeでのAWSコンソールから、そしてIE11でのAWSコンソールからのアップロードも追加しています。 正規化方式が5種類(なにもしないを含む)、文字が6種類、アップロードの方法が9種類とファイルシステムへの書き込み結果ということで、かけ算すると270種類の結果ができあがります。 270種すべてをここで掲示するのは難しい分量的にも難しいですし、そもそもすべてを貼る理由もないので、結果から言えることをまとめていきたいと思います。 なお、結果のCSVはこちらにアップロードしています。 実施してみてわかったこと S3はファイル名に対してなにもしていない ファイル名の正規化方式について、S3はなにか手を加えているかというと、なんも手を加えないですし、意思を持たないようです。原則、ローカルで作られたファイルは、ローカルで手を加えられない限りはそのまま上がるようです。 ただし、アップロードの過程で、一部の方法では透過的に正規化がかかる場合はあるようです(4.を参照のこと)。 aws s3の中で方法によって異なっていた結果は是正された模様 5年前試したときには、aws s3 cp と aws s3 sync でアップロードした場合に結果が異なるということがありました。今回試したなかで確認した限りでは、この現象は是正されていたようです。これはよいことだと思います。 神は正規化されるといなくなる これは知っている方には当たり前な話かもしれませんし、S3にも直接関係しませんが、ユニコード正規化をかけてしまうと「神」は「神」になってしまいます。これはPythonのunicodedataモジュールに限らないものです。 正規化しなければいけない場面や、正規化方式を統一するとデータの扱いが楽になる場面は多いのですが、データソースが変化してしまう情報も出てくる点については考慮が必要です。 Macはひどい 1.のなかで「基本的にはそのままのことが多いようです」という曖昧な書き方をしたのはこの処理のせいです。Macの処理系(おそらくFoundation APIのせい)を通すと問答無用に正規化方式がNFD/NFKDになるようです(参照)。これは他の方の記事でも指摘されていることで、Macの上で無理矢理にでもNFC/NFKCで処理したい場合にはPythonなどの処理系の中で明示的に正規化方式を指定して扱う必要がありそうです。 この点については、MacよりもWindowsの方が筋がいい挙動だなと思ったのは正直なところです。 (番外)IE11はひどい これは、正規化とは直接関わらない内容なので、ちょっとずれる話なのですが、IE11はアップロードの際にフォルダでのアップロードに対応していません。S3にファイルをアップロードする際には、ウェブインターフェースでそれぞれのディレクトリを作ってから、一つ一つファイルをアップロードする必要があります。ただ、単純に面倒くさかったです。 なお、ファイル名についての挙動は自然なものでした。 まとめ 結論としては、以前書いたものと大きくは変わりません。基本的にシステムで扱うファイル名などには、極力マルチバイト文字は使わない方がいいという簡単なものです。 とはいえ、ファイル名の情報を保持しなければいけない場面も多々発生しますし、処理の順番などにも気を遣わなければなりません。 また、今回Macでの扱いには注意が必要ということ痛感しました。今はコンテナを用いた開発が盛んですので、直接Macの上で開発する、ということを避けられる状況は整っていますが、開発はMac、本番環境はLinuxなど、環境が変わる場合には挙動も変わり得ますので、そういった考慮をした上で開発・運用した方が良さそうです。 冒頭にも書きましたが、コンピュータの上で日本語を使う際には否応なく文字コードを意識しなければなりません。今の苦しみ方は過去の苦しみ方とはまた異なりますが、今なお苦しみ続けなければいけないことには変わりがありません。 将来に渡って、なんらか根本的な解決策が発明されることを望みながら締めたいと思います。 この記事を書いた人 中原大介 MNTSQ社でSREをやってます。最近は乗り鉄してることが多いです。
アバター
前回記事 に続いてHugging Faceネタです。Transformers本体ではなく、 分かち書き を行うTokenizersライブラリの紹介をします。 Hugging Faceが開発しているTransformersでは、事前学習モデルと用いた 分かち書き 処理を同梱して配布している。 機械学習 モデルの学習時と推論時の間で 分かち書き 設定が異なったり、 分かち書き 済み公開データと 分かち書き 設定が揃っていなかったりすると、モデルの挙動が正しく再現できないので、この設定が揃うように仕組みで吸収できる良いプ ラク ティスといえる。 比較的古いバージョン *1 のTransformersが用いる トーク ナイザは、ライブラリ内に同梱される Python 実装のものであった。 日本語で配布されているTransformersモデルの事例でいうと、例えば 東北大学 の乾研究室から公開されている日本語BERTモデルでは、Transformers内の トーク ナイザ( BertTokenizer , WordpieceTokenizer )を継承した MeCab のtokenizerが定義・配布されている。これにより、学習時に用いられたtokenizerが何であるか、どういった前処理を経ているかということが追跡可能なだけでなく、ユーザーが特別意識しなくても使われるようになっている。 github.com 一方でちょうど一年ほど前に、Transformersが用いる トーク ナイザはRust実装の別ライブラリ——Tokenizersとして分離された。 Transformers内ではFastTokenizerという呼称で既存の Python 実装 トーク ナイザと区別され、v4.0.0以降ではデフォルトで使用されるようになっている: github.com 当エントリでは、なぜ新しいTokenizersを使ってみたかったのかという動機の説明と、日本語で使えるようにするための解説を行う。 筆者が自分で事前学習モデルを学習する際に、Hugging Face Tokenizersに準拠した日本語の 分かち書き を行おうとしてみて、いくつかの点で躓いたのでその点が解説の中心になる。 掲載しているコードは、 tokenizers v0.10.0 にて動作確認を行った。 *2 まえおき: 日本語のBERT トーク ナイザ事情 Tokenizersを導入することで嬉しい点がいくつかある: 日本語には余計なBERTの前処理を引数経由で簡単に外せる サブワード分割の学習が1ライブラリ内で完結し、各種のサブワード分割手法が容易に使える Rust実装で トーク ナイズ部が速くなる 出力結果のデータ型に便利な処理が備わっている 古いtokenizerと異なり、文字-単語間のアラインメントを保持した トーク ナイズを行ってくれる *3 文字列-idの相互変換をライブラリが管理してくれる truncate, padding等の深層学習 NLP で必要な処理を便利に行ってくれる 当節では最初の2点について解説する。 日本語には余計なBERTの前処理 BERT派生のモデルやライブラリにおいては、 Google のBERTの元実装のtokenizerが、前処理の細かい設定に至るまでコピーされていることが多く、Hugging Face Tokenizersもこの例外ではない。実験再現性を優先するための状況だが、日本語の 分かち書き を行う上ではデフォルト設定だと困った挙動をしてしまうことが知られている。 *4 日本語のオプションがおざなりになっている背景としては、 Google 版BERTの日本語モデル配布がもともと多 言語モデル として公開されたのが最初だったため、特定言語間で有効な前処理の設定が優先されていたのであろうと推測される。 具体的には以下の2点である: ひらがな・カタカナの濁点が除去されてしまう( ウムラウト など Unicode ベースのアクセント記号除去として) 漢字が必ず一文字に分割されてしまう(多 言語モデル でCJK文字のうち中国語が重要視された結果のデフォルト設定) 文書分類のように トーク ナイズ結果がどう処理されようが、良い最終出力が得られれば問題ないタスクにおいてはこの点は無視できるかもしれないが、前処理を適正化することで日本語のタスク性能が上がることもある。 また、固有表現抽出や抽出型質問応答タスクのような、入力テキストの一部を出力するようなタスクに取り組む際にこの点が気になることもある。 この点を修正するためには、BERT元実装や古いバージョンのTransformers同梱のtokenizerでは、モジュールの トーク ナイザ部分を直に書き直す必要があった。 しかし、新しいTransformers同梱のtokenizer実装では、この前処理が以下のような引数で詳細に制御できるように改善されていて、ライブラリ利用者としてはこちらを使用したい。 漢字を一文字分割しない: tokenize_chinese_chars=False 濁点を除去させない: strip_accents=False 古いバージョンでアクセント除去を無効化するには、 do_lower_case=False オプションでまるっとしか制御できなかったが、新しい版ではlower処理とアクセント除去処理の制御が分離されている。 https://huggingface.co/transformers/model_doc/bert.html#transformers.BertTokenizer 他にも細かい点だが、デフォルトで有効な unicode 正規化も場合によっては行ってほしくないことがある。 最近筆者が遭遇した事例としては、⑰のような1文字が '17' の2文字に分割されてしまい、文字列長が処理前後で変化してしまう罠にハマったりした。 後述するようにTokenizersライブラリでは前処理のパイプラインをユーザーが宣言的に定義できるので、この点も助かる。 サブワード分割の訓練が任意の別ライブラリに依存 BERT派生のモデルでは従来の単語 分かち書き だけでなく、さらに細かい 分かち書き 単位であるサブワード分割という処理が適用される。 モデル的にはこの処理は必須というわけではないものの、報告されているタスク性能のほとんどがサブワード分割を入れた上での性能値なので、特別な理由がなければ入れておきたい。 サブワード分割を行うためには、特定 コーパス に依存したサブワード分割パターンを学習・記憶するプロセスが必要になる。 vocabularyファイルがサブワード分割の学習の結果生成され *5 、サブワード分割を行う際に必要になる。 事前学習モデルに入力される トーク ンはこのvocabularyファイルに依存して決まるため、自分で事前学習を行う場合には、用いる コーパス 上でのサブワード分割を学習した上で使用するのが推奨される。 Google のBERT元実装や従来のTransformers同梱のtokenizerでは、サブワード分割の学習結果は外部から与えられるものとされており、サブワード分割手法に応じた外部ライブラリの選択はユーザーに委ねられていた。そのため、事前学習の訓練 スクリプト の他に、サブワード学習のための スクリプト や外部ライブラリを追加する必要があった。 また、BERT論文で用いているとされるWordPieceの元実装にアクセスしづらかった状況から、日本語では実装にアクセスしやすいBPEやsentencepieceといったサブワード分割手法が選ばれてきた印象がある: BPEの代表的なライブラリ: https://github.com/rsennrich/subword-nmt SentencePiece: https://github.com/google/sentencepiece この点に対して、Hugging Face TokenizersではWordPiece, BPE, SentencePieceなどを含む各種のサブワード分割を学習する実装が同梱されており、Tokenizersライブラリだけでサブワード学習が完結するようになった。 *6 日本語でHugging Face Tokenizersを動かす 検索に微妙にhitしづらいのでドキュメントへのリンクを掲載する。これをざっと読めばライブラリ構成の概要は把握できる。 huggingface.co Hugging Face TokenizersにおけるTokenizerオブジェクトとは、以下の要素からなる各種処理のパイプラインコンテナである。 Encode方向での利用、つまり事前学習モデルに入力可能な トーク ン列を生成する方向では、最 終結 果が Encoding オブジェクトとして得られる。 このデータに元の トーク ン列、モデル特有の 特殊文字 、 トーク ン-id対応、 トーク ン-元の文字列対応といったデータが格納されている。 Encode方向: 文字列 → 事前学習モデル入力可能なEncodingオブジェクト Normalizer: 文字列正規化の前処理( Unicode 正規化や小文字化など) PreTokenizer: 文字列→基本 トーク ン(単語)の変換 Model: 基本 トーク ン→ トーク ン(サブワード)の変換;学習済みvocabularyが必要な箇所 PostProcessor: 最終的なEncodingデータを生成する後処理(BERTの特殊 トーク ンの追加など) Decode方向: トーク ン列 (Encoding)→ 元の文字列 Decoder: トーク ン-元の文字列対応情報を用いて各 トーク ンが位置する元の文字列上の位置情報を復元できる 注意すべき点としては、TokenizersにおけるTokenというのは、いわゆる単語ではなくサブワードのことである。 つまり、従来の日本語 形態素解析 器は、TokenizersパイプラインにおけるPreTokenizerに位置づけられる(英語ではカンマなどを考慮したwhite space分割などに相当)。 PreTokenizerに MeCab を差し込めば良さそうというところまではすんなり理解できるのだが、バックエンドがRustで Python は バインディング 提供という点から、 Python でやるにはこの先が思ったよりすんなり行かなかった。 結論から書くと、PreTokenizerを Python 側では継承定義することができず、既存のTokenizerに対して、以下のような custom PreTokenizerとして注入する必要がある: from tokenizers.implementations import BertWordPieceTokenizer from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer ... # 既存Tokenizer tokenizer = BertWordPieceTokenizer( handle_chinese_chars= False , # for japanese strip_accents= False , # for japanese ) # ユーザー定義のcustom PreTokenizer(MecabPreTokenizer)を注入 tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) ... この点はdocumentに書かれておらず、以下のissueからやり方を把握した(多言語対応は現状だとPoC機能に位置付けられている模様): github.com コメントにあるように、custom PreTokenizerの作り方は 中国語custom PreTokenizerのサンプル を参考にした。 ただし、日本語では単語から元の文字列上のspanアラインメントを保持する機能は自分で追加してやる必要がある(中国語 形態素解析 器のjiebaでは便利なことに標準機能のようだ)。 このアラインメント処理がTokenizersの機能要件に入っていることで、 トーク ンと元の文字列の行き来が相当スムーズになるので、この労力は払う価値があると考えている。 この点が便利なケースについては、 前回のエントリ を参照いただきたい。 トーク ンと元の文字列のアラインメント処理については pytextspan を使用した。 ほぼ最小限に近い日本語custom PreTokenizerのサンプル実装は以下のようになる: from typing import List, Optional from MeCab import Tagger import textspan from tokenizers import NormalizedString, PreTokenizedString class MecabPreTokenizer : def __init__ ( self, mecab_dict_path: Optional[ str ] = None , ): """ Construct a custom PreTokenizer with MeCab for huggingface tokenizers. """ mecab_option = ( f "-Owakati -d {mecab_dict_path}" if mecab_dict_path is not None else "-Owakati" ) self.mecab = Tagger(mecab_option) def tokenize (self, sequence: str ) -> List[ str ]: return self.mecab.parse(sequence).strip().split( " " ) def custom_split ( self, i: int , normalized_string: NormalizedString ) -> List[NormalizedString]: """ See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py """ text = str (normalized_string) tokens = self.tokenize(text) tokens_spans = textspan.get_original_spans(tokens, text) return [ normalized_string[st:ed] for char_spans in tokens_spans for st, ed in char_spans ] def pre_tokenize (self, pretok: PreTokenizedString): pretok.split(self.custom_split) これで問題は8割ほど解決したが、作成した日本語custom PreTokenizerを備えたTokenizerを使って、サブワード分割学習・ シリアライズ ・ロード、と使ってみようとすると、 custom PreTokenizerはシリアライズできない と怒られる。 この点についてはまだ issue に積まれている状況で、ひとまず動かす目的としては シリアライズ 可能なノンカスタムのPreTokenizerをダミーで代入して シリアライズ することで回避した(custom PreTokenizerは実際にテキストが入力されるタイミングで有効になっていさえすれば良い)。 設定ファイルからワンラインでロードできない状況なので、この点が解決しない限り日本語含む非英語言語に完全対応したとは言えなさそうだと感じているが、これでひとまず学習・推論まで正しく動作するTokenizerが得られた。 以下が、カスタムTokenizerの作成・サブワード分割の学習・モデル シリアライズ の一連の流れを示したコードである。( tokenizer._tokenizer という箇所は、 BertWordPieceTokenizer がTokenizerのラッパーになっている都合上こうなっている。) def train_custom_tokenizer ( files: List[ str ], tokenizer_file: str , **kwargs ) -> BertWordPieceTokenizer: """ Tokenizerの学習・保存処理:custom PreTokenizer付きのTokenizerを学習・保存する。 """ tokenizer = BertWordPieceTokenizer( handle_chinese_chars= False , # for japanese strip_accents= False , # for japanese ) tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) # 与えられたコーパスファイル集合からサブワード分割を学習 tokenizer.train(files, **kwargs) # vocab情報に加えて、前処理等パラメータ情報を含んだトークナイザ設定のJSONを保存 # NOTE : Pythonで書かれたcustom PreTokenizerはシリアライズできないので、RustベースのPreTokenizerをダミー注入してシリアライズ # JSONにはダミーのPreTokenizerが記録されるので、ロード時にcustom PreTokenizerを再設定する必要がある。 tokenizer._tokenizer.pre_tokenizer = BertPreTokenizer() tokenizer.save(tokenizer_file) # (Optional) .txt形式のvocabファイルは f"vocab-{filename}.txt" で保存される(外部の処理で欲しい場合) filename = "wordpiece" model_files = tokenizer._tokenizer.model.save( str (Path(tokenizer_file).parent), filename ) return tokenizer def load_custom_tokenizer (tokenizer_file: str ) -> Tokenizer: """ Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。 """ tokenizer = Tokenizer.from_file(tokenizer_file) # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。 tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) return tokenizer 参考のためにデモ スクリプト 全文を掲載しておく from pathlib import Path from typing import List, Optional import MeCab import textspan from tokenizers import NormalizedString, PreTokenizedString, Tokenizer from tokenizers.implementations import BertWordPieceTokenizer from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer class MecabPreTokenizer : def __init__ ( self, mecab_dict_path: Optional[ str ] = None , ): """Construct a custom PreTokenizer with MeCab for huggingface tokenizers.""" mecab_option = ( f "-Owakati -d {mecab_dict_path}" if mecab_dict_path is not None else "-Owakati" ) self.mecab = MeCab.Tagger(mecab_option) def tokenize (self, sequence: str ) -> List[ str ]: return self.mecab.parse(sequence).strip().split( " " ) def custom_split ( self, i: int , normalized_string: NormalizedString ) -> List[NormalizedString]: """See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py""" text = str (normalized_string) tokens = self.tokenize(text) tokens_spans = textspan.get_original_spans(tokens, text) return [ normalized_string[st:ed] for char_spans in tokens_spans for st, ed in char_spans ] def pre_tokenize (self, pretok: PreTokenizedString): pretok.split(self.custom_split) def train_custom_tokenizer ( files: List[ str ], tokenizer_file: str , **kwargs ) -> BertWordPieceTokenizer: """Tokenizerの学習・保存処理:custom PreTokenizer付きのTokenizerを学習・保存する。""" tokenizer = BertWordPieceTokenizer( handle_chinese_chars= False , # for japanese strip_accents= False , # for japanese ) tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) # 与えられたコーパスファイル集合からサブワード分割を学習 tokenizer.train(files, **kwargs) # vocab情報に加えて、前処理等パラメータ情報を含んだトークナイザ設定のJSONを保存 # NOTE : Pythonで書かれたcustom PreTokenizerはシリアライズできないので、RustベースのPreTokenizerをダミー注入してシリアライズ # JSONにはダミーのPreTokenizerが記録されるので、ロード時にcustom PreTokenizerを再設定する必要がある。 tokenizer._tokenizer.pre_tokenizer = BertPreTokenizer() tokenizer.save(tokenizer_file) # (Optional) .txt形式のvocabファイルは f"vocab-{filename}.txt" で保存される(外部の処理で欲しい場合) filename = "wordpiece" model_files = tokenizer._tokenizer.model.save( str (Path(tokenizer_file).parent), filename ) return tokenizer def load_custom_tokenizer (tokenizer_file: str ) -> Tokenizer: """Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。""" tok = Tokenizer.from_file(tokenizer_file) # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。 tok.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) return tok if __name__ == "__main__" : s = "今日はいい天気だ" with open ( "test.txt" , "wt" ) as fp: fp.write(s) fp.write( " \n " ) fnames = [ "test.txt" ] tokenizer_file = "tokenizer_test.json" settings = dict ( vocab_size= 30000 , min_frequency= 1 , limit_alphabet= 5000 , # 日本語の文字種類数の参考値 ) tokenizer = train_custom_tokenizer(fnames, tokenizer_file, **settings) print (s) # tok = load_custom_tokenizer(tokenizer_file) tok = Tokenizer.from_file(tokenizer_file) print (tok.encode(s).tokens) # ただしく分割できない tok.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) print (tok.encode(s).tokens) print (tok.normalizer) print (tok.pre_tokenizer) print (tok.model) print (tok.decoder) 本記事の主題とはずれるが、この後段で SWIG の MeCab Tagger がpickle化できないと怒られることがあり(MLFlow等)、調べたところ SWIG 版 Tagger をpickle化可能にする処理を書いてくれた方がいたのであわせて共有させていただきます(通常の Tagger をラップすれば良い): tma15.github.io 以上、Hugging Face Tokenizersをなぜ使いたいかの動機と、日本語でHugging Face Tokenizersを動かす際に詰まった点を解説しました。 まだ開発中の色合いが強いライブラリで気軽に使用を勧められる状況とは必ずしも言えませんが、大変多くのユーザーを抱えた開発の盛んなライブラリであるため、近い将来BERT Tokenizer周りのコードはより洗練されたものになっていくことでしょう。 この記事はMNTSQ株式会社の業務時間内に書かれました。 MNTSQ株式会社では契約書解析を高度化する 自然言語処理 エンジニアを募集しています: hrmos.co この記事を書いた人 稲村和樹 自然言語処理 エンジニア。爬虫類が好き。 *1 : といっても2年ほど前のバージョンですが *2 : 日本語に適用したサンプルは知る限りにおいては無かったです。いまだに開発が盛んなライブラリであり、日本語で使う上でのソフトウェア上の課題はまだ残っているという点を予め断っておきます。 *3 : この機能が便利になる場面については、 前回のエントリ で解説しています。ただし、後で見るように日本語でこれを実現するには別のライブラリの力を借りています。 *4 : この点への言及例としては、 京都大学 黒橋研究室で公開されているBERTの注意書きを参照: http://nlp.ist.i.kyoto-u.ac.jp/?ku_bert_japanese#r6199008 *5 : TFIDFやword2vecの学習で コーパス ごとにvocabularyファイルが出力されるのに似ていますが、 コーパス ごとに適応的に決まるサブワード分割のvocabularyファイルは、サブワード分割のモデルと呼ばれます。 *6 : Tokenizers実装WordPieceの日本語のサブワード分割が遅くてつらいですが、学習が速いBPE実装も追加されている模様です: https://github.com/huggingface/tokenizers/pull/165
アバター
最近、身近な スモールデータ をさくっと分析してみる機会があったので、過程をまとめてみました。 スモールデータ の解析であっても、前処理、可視化、示唆出しなどデータ分析に必要な所作というのは変わりません。ステップに分けながら紹介したいと思います。 今回はツールに Google Spreadsheetしか使っていないので、ノンエンジニアのビジネスサイドの人であっても同じ分析を回すことができます。 Google Workspace(Gsuite)を使っている企業であれば紹介した生データも取得ができるかと思いますし、30分くらいしかかからないので、試してみると面白いかもしれません。 今回取扱いたいデータは Google Meetのログデータです。COVIDの影響で営業や採用文脈でリモート MTG が増えました。「最近、リモート MTG のちょっとした遅刻、多くない?」という社内のふとした問題提起から、実際にログをみることで「ちょっとした遅刻」がどれくらい発生しているかを可視化してみたいというモチベーションが生まれました 結論としてこんな感じの可視化を得ました 目次 データの取得 生のデータを読む 前処理をする 可視化をする 味わう データの取得  まずは生データの取得からです。今回は Google BusinessのAdminコンソールからMeetのLogデータを出力します。「Report」> 「 Google Meet」 > 「ダウンロード」でダウンロードが可能です。 Google スプレッドシート か csv か選べるようになっています。今回は30分クッキングということで、 スプレッドシート で出力してみます。  データ分析のツールといえばJupyter notebookやRだと思います。が、個人的には Excel やSpreadsheetも使い所によって、すごく強力なツールになると思います。特にスモールサイズのテー ブルデー タを色々いじくりまわす時にはこちらの方がスピードが出ることも多いのではないでしょうか。 生のデータを読む  出力されたファイルを開くと、下記のようなカラムがあることがわかります。(一部抜粋) 日付 イベント名 イベントの説明 会議コード 参加者 ID 組織外の参加者 クライアントの種類 主催者のメールアドレス プロダクト タイプ 期間 通話の評価(5 段階) 参加者名 (その他60項目程度)  今回の分析のモチベーション「みんな時間通りに MTG 入ってる?」という論点から考えると、参加者IDと日付、期間などの情報が特に興味がある情報です。  参加者IDは欠損値も多いことにすぐ気づきます。他の項目と照らし合わせてみると、これは組織外の参加者と MTG をしている時にはこの項目が入ってこないということがわかります。  また、「期間」には整数値が入力されています。これが示す値がどういうものなのかよくわかりません。まずは雑に分布を見てみます。雑にみる時は全部の値をsortしてから、グラフで出してみるのを初手でやることが私は多いです。結果、3600くらいまでの値が多く、そこから少数のレコードでいきなり増加していき、最大値は82649だということがわかります。なんだか3600くらいまでとそれ以上で全然違うメ カニ ズムの値がありそうだなという感覚を受けます。 もうちょっとよくみてみたかったので、 ヒストグラム にしてみました。 バケット サイズは自動にしつつ、上の方に異常値があったので、「異常値のパーセンタイル」に「5%」を設定します。こうすると、上と下の異常値は一つの バケット に入れてくれるので、ぱっと見わかりやすくなります。  こんな感じになりました。これをみると「期間」は「接続時間の秒数」を指しているのではないか、ただし、一部の値はバグっているのではないか?ということに気がつきます。 異常値を除けば3600あたりから急に値が減っています。これは Google Calendar で MTG を入れるとき、ほとんどの MTG は1時間以内しか時間を取らないからだと思われます。 3600秒まで、を見てみると60分あたりの山、45分あたりの山、30分あたりの山、15分くらいまでの山がなんとなくあることがわかります。これは実際にカレンダーで設定しやすい MTG の時間帯と対応している可能性があると思います 90秒以内の異常値は接続不良などがあげられるかと思います。「マイクが調子悪そうだから スマホ で入り直す」みたいな事象はこちらにあげられるかと思います。 5000秒以上のめちゃくちゃ長いレコードについては、実際にどういうレコードか眺めてみます。幸い、自分の入ったことがある MTG もあったので、その時のカレンダーを辿ってみるものの、そんなに長く通話していたわけではありません。ここから上のカウントはもしかするとなんらかの計測バグなのかもしれません なんとなく「画面共有」を使っていた回が多いのかもしれない? という感触は持ちましたがあんまり確信はもてていないです。ここはふかぼっていません。 また「日付」のところにはタイムスタンプがおされていますが、一体開始時刻か終了時刻か、はたまた別のレコードなのかはぱっと見てよくわかりません。しかし、ここは自分の MTG の記憶でたぐると「会議を切断した時刻」であることがわかります。 前処理をする 「みんな時間通りにはいってるか知りたい」というモチベーションからすると「日付」のタイムスタンプから「期間」の秒数を引くことで「入室時刻」がわかるのではないか? とわかります。 Spreadsheetではこんな感じで計算をしました。 想定入室時刻 = <日時が書いてるセル> - TIME(0,0,<秒数のセル>) 想定入室時刻の「分」だけを取得 = MINUTE(<想定入室時刻のセル>) 無事に想定入室時刻が取得できたので、人をキーにしてピボットテーブルを作成し、集計してみます。この際に、上記のゴミデータが混ざらないように下記のフィルタをかけます。 参加者IDが登録されているレコードのみ(社内の人である) 7200秒未満のレコードである(上の外れ値を除去) 1200秒以上のレコードである(quick callなどを除去し、予定された MTG に絞るため) 可視化をする すると上記のような分布が得られます。一つ一つの色が一人のIDと対応しています。横軸が「分」で、縦軸が割合になっています。こうやってみると、一番大きな山が「0分」付近(グラフでいうと左端と右端)に分布していて、次に大きな山が「30分」付近にあることがわかります。これはほとんどの MTG が「X時00分」もしくは「X時30分」から予定されることを考えると非常に自然な分布に見えます。 では、上記のグラフの中で「X時00分」と「X時30分」の前後10分間のみを取得して重ね合わせてみると、「 MTG 前後の入室状況」がより可視化されるのではないか、とわかります。 やってみると。。。 こんな感じになりました。綺麗に「 MTG のちょっと前」に人がたくさん入ろうとしてきているのが見えますね。(中央が MTG 開始時刻) ここから、「 MTG の前後十分間の中で、開始時刻までに入室した率」を人ごとに計算してみます。すると、冒頭で出したような「人物ごとの『間に合った率』」が算出できます。また、それぞれの人が平均よりどれくらい間に合っているのか、どれくらい間に合っていないのかという程度も可視化がされます。 味わう  なんか結果っぽいものが出ましたが、一足跳びに結論に飛びつくのは危険です。データを味わった上で、本当に計算は正しいのか、言えることは何なのか、この分析の限界があるとするとどういうところか、ということを考える必要があります。回りのデータを扱う仕事をする人を見ていると、できる人であればあるほど「結果を正しく疑う」という所作がきちんとできているなと感じます。  データ分析は適当にやっても答えっぽい数値が算出できてしまいます。しかし、それをどこまでどう信頼すべきかの判断はまた別の話です。途中でとんでもない計算ミスをしていても気づかずするっと集計されていき、それをベースに意思決定が発生してしまう、というケースは最悪ですが、割と起きている事象かと思います。  比較的こぢんまりした今回の分析の中でも、実はデータ分析をする人が操作できるレバーというのは比較的多岐に渡ります。今回で言えば、例えば「期間」の異常値をどこまで弾くべきか、集計の際にどういうフィルタを噛ませるべきか、30分の山を足し合わせるべきか、などが挙げられるかと思います。これらのレバーを無意識のうちに分析者の仮説に有利な方に傾けていないかどうか。自分がフェアな分析をできているか、批判的に引いてみてみるというのはとても重要です。  今回の例で言うと、出た結果を見て定性的に不思議に思うところはないかどうか、体感と齟齬があるところはないか、この算出プロセスで結果が狂うとしたらどういうものがあるか、ということを考えていきます。 算出された傾向を何人かに聞いてみて、違和感があまりないことを確認 外れ値の人に聞いてみるのが有効 「0:0x」分からはじまる MTG 等が定例で入っていたりすると不利になりそう アドホック にいきなりはじまったCallなのか、予定されていた MTG なのかの区別はレコード上できないので、 アドホック な MTG を不規則にガンガンやっている人がいれば数値上不利になる可能性がある 回線や機器が不安定だったりして、繋ぎ直しを頻繁にしている人は計算上不利になりそう 上記のようなぶれは生じるので、正確ではないことには注意しつつ、大きな傾向は信頼できそうだとわかった まとめ 身近にあるデータをがちゃがちゃといじるだけであっても色々発見や工夫のしどころはあるものです。小さいデータであっても大きいデータであっても、以下の大きな流れは共通しているかと思います。 まずは一行一行をちゃんと読む 欠損値、外れ値に対して対処する データの分布をみることで性質を掴む ちょくちょく可視化して体感とずれがないか、どういう操作が有効か考える 出てきた結果にすぐ飛びつかない 「どういう限界があるか」、「どこまでの示唆を読み取ってよいか」、「本当に計算はあっているか」をいろんな角度から考えてみる
アバター
あなたはDockerに何回入門しただろうか? 何度あのクジラを見て頭を抱えたことだろうか? 今回あなたを「とりあえずDockerを使ってワールドプレスを表示する」ところまで道案内しようと思う。 そう、夢はでっかく世界に羽ばたかないとね。 間違えた、 ワードプレス だ。 Dockerって何 ワードプレスって何 Dockerでワードプレスを動かす ダウンロード インストール Windows macOS Dockerを起動する Windows macOS ワードプレスの設定ファイルを作る テキストエディタを開く Windows macOS 設定を貼り付ける 設定ファイルを保存する Windows macOS ワードプレスを動かす Windows macOS ワードプレスを表示してみよう 注意事項 ワードプレスを止める おわり 仲間募集中! この記事を書いた人 Dockerって何 「仮想化かーそうかー」 「Dockerでどっかーんwww」 Dockerの何たるかも知らずにのほほーんと暮らしている日本人(主語でか)のなんと多いことか。 今、すべての日本人(主語でか)に問います。 「Dockerとは何でしょう」 まぁそんな深遠な事は書かない。 Windows や macOS などのOSがあると思うが、他にも実は Linux という有名なOSがある。 Dockerは Linux をいい感じに仮想化してくれるソフトウェアだ。 Linux は今あなたが見ているこのサイトの裏側など見えにくいところで動いている。 面倒くさいのでそれ以上の説明をここではしない。 ワードプレス って何 「ブログ」はさすがに知っているのではないだろうか。 ブログのシステムがいくつかあるんだけど、そのうち一番メジャーなものが「 ワードプレス 」だ。 素人でも使えちゃうので、色々アレな部分もあるが、とっつきやすさの点で勝っている。 Dockerで ワードプレス を動かす では実際に作業していこう。 以降は記事執筆時点の見た目であり、今後のバージョンアップなどで見た目などが変化する可能性がある。 なお、筆者はダークモードが大好きなので、ところどころ白黒が反転している画面があるが、ご了承いただきたい。 ダウンロード 何はともあれ、まずダウンロードしないと始まらないだろう。 www.docker.com このサイトにアクセスして、右上の「Get Started」をクリック 左下あたりの「Download for Windows 」または「Download for Mac 」をクリックしよう インストール ダウンロードが完了したらインストールしよう。 Windows と macOS で インストーラ ーの見た目が違うので、それぞれの項目を見て欲しい。 ダウンロードされたファイルをダブルクリックしよう。 Windows こんな画面が表示されるので、そのまま右下のOKをクリックしよう。 すると何かが進んでいくが、お茶を飲むかNetfixを眺めるか匍匐前進するかなどしながら待つと良い。 インストールが完了すると以下のような画面が表示される。ボタンを押すのだが、 ここの「restart」はマシンの再起動を示している ので、何か作業中の場合は保存しておくこと。 Windows の再起動が完了すると、こんな通知が出たはずだ。 macOS 書いてあるとおりドラッグ・アンド・ドロップしよう。 それだけ?それだけ。 Dockerを起動する Dockerを利用するには、Dockerを起動しておく必要がある。まぁ当然と言えば当然だ。 Windows Windows の場合はスタートメニューに「Docker Desktop」というメニューが増えているので、それをクリックすると良い。 すると以下のような画面が表示される。左下に黄色いランプで「starting」と表示される。マシンの性能次第であるが、 Windows の場合それなりに時間がかかるようだ。 macOS 「アプリケーション」に「Docker Decktop」が増えているはずなので、それをクリックしよう。 以下のように聞かれるが、「開く」で大丈夫だ。 その後何やら小難しい英語のダイアログが表示されるが、「OK」で大丈夫だ。 続いて「ヘルパーをインストール」というダイアログが表示される。ユーザー名とパスワードは入力して欲しい。 それが終わるとこっそりステータスバーで起動する。アニメーションでコンテナが増えたり減ったりしている時は起動している最中の状態だ。 ワードプレス の設定ファイルを作る では ワードプレス を起動するために、設定ファイルを作ろう。 テキストエディタ を開く Windows Windows では「メモ帳」というアプリを使ってファイルを作る。Wordは使わないように。 スタートメニューから「 Windows アクセサリ」→「メモ帳」とたどるか、検索しよう。 macOS macOS では「テキストエディット」というアプリを使う。「アプリケーション」から「テキストエディット」を探してクリック。 すると以下のようなダイアログが開くので、「新規書類」をクリック。 あと、このままでは余計な情報が含まれてしまうので、「フォーマット」→「標準テキスト」にしておく。 設定を貼り付ける 以降は共通だが、下記内容を貼り付けよう。 version : '3' services : db : image : mysql:5.7 volumes : - db_data:/var/lib/mysql restart : always environment : MYSQL_ROOT_PASSWORD : somewordpress MYSQL_DATABASE : wordpress MYSQL_USER : wordpress MYSQL_PASSWORD : wordpress wordpress : depends_on : - db image : wordpress:latest ports : - "8000:80" restart : always environment : WORDPRESS_DB_HOST : db:3306 WORDPRESS_DB_USER : wordpress WORDPRESS_DB_PASSWORD : wordpress volumes : db_data : まぁここのコピーだが: docs.docker.jp 以下は Windows で貼り付けた後の状態だ。 設定ファイルを保存する Windows ファイルを保存しよう。保存場所はどこでも良いのだが、説明の都合で「ドキュメント」にして欲しい。ファイル名は docker-compose.yml にして欲しい。 docker-compose.yml をコピペして「ファイル名」欄に貼り付けよう。 macOS ファイルを保存しよう。保存場所はどこでも良いのだが、説明の都合で「書類」にして欲しい。ファイル名は docker-compose.yml にして欲しい。 docker-compose.yml をコピペして「ファイル名」欄に貼り付けよう。 少し変わったファイルを使うので、以下のようなダイアログが表示されるが、「".yml"を使用」をクリックする。 ワードプレス を動かす いよいよ ワードプレス を動かそう。 ここから少しだけITエンジニアな画面を見る事になるが、我慢していただきたい。 いずれも「ターミナル」というアプリで作業をする。 以下、コマンドが3つ登場するので、基本的にはコピー&ペーストして欲しい。 Windows の場合は右クリックが「ペースト」になる。 Windows スタートメニューから「 Windows システムツール」→「コマンド プロンプト」とたどるなどして「コマンド プロンプト」を起動する。 すると真っ黒な画面が出てくるので、 cd Documents と入力してリターンキーを押す。 その後、 docker-compose up -d と入力してリターンキーを押す。 こんな感じで何かが流れていくはずだ。 もしかしたらこのタイミングで以下のようなダイアログが出てくるかも知れないが、ここは「アクセスを許可する」をクリックする(でないとDockerが動かない)。 処理が完了すると、 C:\Users\ユーザー名\Documents> という表示が出て止まる。 macOS 「アプリケーション」→「ユーティリティ」→「ターミナル」を起動する。 すると真っ黒な画面が出てくるので、 cd Documents と入力してリターンする。 その後、 docker-compose up -d と入力してリターンキーを押す。 すると以下のように何かが動いていくはずだ ワードプレス を表示してみよう 実は先程の作業で ワードプレス はもう起動している。 早速見に行ってみよう。 まずDocker Desktopアプリを開こう。 Windows はアプリを開くと出てくるが、 macOS の場合はステータスバーから「 Dashboard 」を開く。 するとこういう感じの状況になっているはずだ(緑が今正に動いている「コンテナ」だ!) documents_wordpress_1 にマウスカーソルを合わせると、右側にこんな感じのアイコンが出てくるので、一番左の OPEN IN BROWSER をクリックしよう。 すると ブラウザー が開いて、以下のような画面が表示されたはずだ。 おめでとう! ワードプレス が無事に起動した! 後は適当に設定しながら(設定したパスワードとユーザー名は忘れないように!)設定を完了させよう。 左上の(設定したサイト名)→「サイトを表示」をクリックしてみよう。 これがあなたのブログだ! 注意事項 さて、これで ワードプレス が動いたわけだが、これは あなたのマシンの上でだけ動いている ものだ。これをワールドにプレスするには、他にもいくつかする事があるのだが、このブログ記事の目的は達成されたので、その話はまた今度機会があればするかも知れない。 ワードプレス を止める Windows なら「コマンド プロンプト」、 macOS なら「ターミナル」で docker-compose down と入力してリターンキーを押そう。すると以下のようになるはずだ。 これでお掃除は完了だ。 おわり おわり。 簡単だったと思うが、そういう事だ。 次回があればもう少し何か書くかも知れないが、予定は常に未定だ。 仲間募集中! このエントリーは素人でも(非エンジニアでも)分かるように書いてみたつもりだ。 弊社では非エンジニアとのコラボレーションを重要視しているので、このようなスキルは重要なのだ。 弊社にはリーガルチームがあるが、リーガルチームはリーガルの事をエンジニアに分かるように説明するよう努力するし、エンジニアチームもエンジニアリングの事をリーガルに分かるように説明するよう努力する。 そんな文化に興味がある方はぜひともエントリーいただきたい。 と、もっともらしい事を書いて締めの言葉としたい。 この記事を書いた人 Yuki Nishimura 雑食系エンジニア
アバター