Inside of LOVOT

GROOVE X 技術ブログ

GROOVE Xにおける情シスのお仕事

こんにちは!GROOVE Xで情報システムを担当している清水です。この記事は、GROOVE X Advent Calendar 2024の13日目の記事です。

GROOVE Xに入社して1年が経ちました。一人でGROOVE Xの情シスに立ち向かっておりますが、今回は普段のお仕事や入社して1年で達成したことなどをお話ししたいと思います。

普段のお仕事のお話

情報システムとしての普段のお仕事は「社内ヘルプデスク」、「アカウント管理」、「PCキッティング」、「発注/請求処理」など、一般的な情シスとしてのお仕事がメインとなります。 社内ヘルプデスク業務では昨年の対応件数が約200件で、GROOVE Xでは利用するパソコンは自由に選択できる環境のため、WindowsからMac、Linuxまで多種多様なOSのお問い合わせやネットワーク、アカウント関連、アプリケーション関連などの問い合わせ対応を行っております。

特に苦労したお問い合わせは、Mac版 Google ChromeとCanon製プリンタを利用している環境において、印刷時に2バイト文字が文字化けするという事象でお問い合わせを受けたタイミングではGoogle先生などで調べても情報が無く、別のブラウザを利用すれば印刷ができたので印刷時だけ別のブラウザを使ってもらっておりました。しかし、GROOVE XではGoogle Chromeが標準ブラウザのため根本解決が必要だったのですが、まったく原因がつかめませんでした。

しばらくして、Google Chromeコミュニティで同様の事象報告がされており、結論としてはGoogleまたはCanon側での対応が必要とのことで、お問い合わせを頂いてから約2か月後にプリンタドライバでの修正が入りまして解決しました…。

プリンタドライバ変更履歴

プリンタドライバの変更履歴を見たときは、本当に感動しました…!

また、GROOVE Xでは採用活動も活発に行われており、新入社員のPCキッティングやアカウント発行も苦労しました。 ありがたいことに毎月新入社員をお迎えさせて頂いており、アカウントが足りない…パソコンが足りない…とヒィヒィ言いながら在庫とにらめっこして発注するタイミングを伺いながらキッティングをする日々でした。特にパソコンはWindows/Mac/Linux、さらにキーボードが日本語/英語と好みがあるので、様々なパソコンを在庫しているのですが、在庫が無い時に限って狙い撃ちしたように在庫が無いパソコンを希望されたときは本当に焦ります…。

この1年でチャレンジしたこと

GROOVE Xでは入社申請や退職申請、アカウント申請などをSlack+スプレッドシートで管理しておりました。また、SaaSアカウントの管理ツールも利用していなかったので申請のたびに各SaaSにログインしてアカウント発行/削除を行い、Slackで申請者へ連絡しておりました。すべて手動による作業のため、スプレッドシートの更新漏れやアカウントの削除漏れが課題でした。これらを解決すべく、まずは申請をシステム的に管理するためワークフローの導入を検討しました。

ワークフローは新たなツールなどは導入せず社員全員がアカウントを持っている、Bakuraku経費精算を流用して申請フォームを作成しました。本来は経費精算のツールなのですが、便乗させて頂くことで、ツール選定の時間もコストもカットできました。また、SaaSアカウントの見える化のため、管理ツールの選定も行い複数製品のトライアルを行いました。トライアル期間中に製品検証をすすめまして、最終的に「ジョーシス」を導入させて頂きました。

<Bakuraku申請フォーム①>

<Bakuraku申請フォーム②>

<SaaSアカウント管理>

これで、今までスプレッドシートに記載して頂き、Slackで連絡を頂いていたのが、申請フォームを入力して頂くだけで必要な情報が通知され、希望日に合わせて事前にジョーシスでアカウント発行の予約を行えるようになりました。また、申請頂いた内容は毎月CSVでエクスポートして保管ができるので、履歴管理もできるようになりました! 個人的にはきれいに整備出来て大変満足しました。

また、その他の取り組みとして、ファイアウォールやNASのリプレースも実施し、ネットワーク環境の整備とセキュリティ強化を図りました。

最後に

今回はGROOVE Xの情シスとしての活動をご紹介しました。この1年で多くの課題を解決してきましたが、まだまだ取り組むべきことは山積みです。現在は日常業務に加え、デバイス管理の構築やISMS認証の取得に取り組んでいます。

各チームから寄せられる新しい要望に応じて業務を進める毎日はとても刺激的で、楽しい仕事ライフを過ごしています。引き続き頑張っていきます!

GROOVE Xでは一緒に働いてくれる仲間を募集中です。

recruit.jobcan.jp

評価制度をつくっている話

こんにちは、GROOVE Xのスクラムマスターの1人、niwanoです。GROOVE X Advent Calendar 2024の12日目の記事です。 今日は、評価制度を作っている話をお届けします。

庭師と領域人事

人事チームとスクラムマスターチームが「組織のお手入れをする」という名目で活動するワーキンググループがあります。このグループは "庭師" と呼ばれています。

2024年から、スクラムマスターは領域人事という役割を担うようになりました。開発領域における一部の人事業務をスクラムマスターがカバーする形です。その活動の一例が、今回の評価制度の作成です。

評価制度はなぜ必要か

スタートアップでは評価制度が整っていないことも少なくありませんが、弊社もこれまで360度評価を散発的に実施していたものの、人事評価には直接反映してはいませんでした。
みなさんは、評価制度がなぜ必要だと思いますか?

  • 自分のお給料のため?
  • 公平性のため?

どちらも正しいですが、庭師では、評価制度を社員の成長のために必要なものと考えています。 まず社員の成長があり、それが会社の成長につながり、結果としてお給料の原資が確保される、という仕組みです。 そのため、評価制度は社員の苦手な項目を明確にし、成長目標の指針となるものでなければなりません。

CXOと庭師の組織合宿の様子

みんなでつくる評価制度

CEOによって評価制度のたたき台が作成された後、全社員でのレビュー会が行われました。 これは非常に弊社らしい試みだと感じました。 社員が評価制度の作成に参加できる機会は滅多にありません。 この丁寧なレビュー会を通じて、社員が制度に納得し、その内容を深く理解できるだけでなく、人に説明できるようになります。

評価項目の一例

ここで、私のお気に入りの評価項目を2つ紹介します。 (評価項目は全部で17項目あり、すべて紹介すると今年のアドベントカレンダーが埋まってしまいます!)

リーダーシップ

背景説明

リーダーシップとは、自らが組織やチームの一員であると自覚し、「自分ごと」として捉える姿勢です。
自分だけでなく周囲を巻き込み、目標達成に向けて積極的に推進することで、全体の成果を最大化します。
これは「自分の境界」をどこに持つのか、とも言い換えられます。
自分の境界を自分の皮膚に持つのか、チームに持つのか、事業に持つのか、お客様を含めた全ステークスホルダーに持つのか。
それによって守ろうとする対象が変わります。これが広い方が、「自分の境界」が広いと言えます。
リーダーシップは、他者の協力を引き出すコミュニケーションと信頼関係の構築によって強化され、周囲にポジティブな影響を与えます。

「境界を自分の皮膚にもつ」という表現は非常に的確で、私のお気に入りです。 自分の境界を拡げていきたいですよね!

コーチャビリティ

背景説明

コーチャビリティとは、他者からのフィードバックを前向きに受け入れ、自己改善や成長につなげる能力のことです。
特にギャップフィードバックを受けた際、言い訳したり、黙り込んだり、相手を攻撃したくなるのは自然な自己防衛反応です。
しかし「良薬口に苦し」と言われるように、冷静に受け止め、そこから学ぶ姿勢を持つことで成長が可能になります。
相手の意図を理解し、自分にとって有益なポイントを見つけ出すことで、より高いパフォーマンスを実現できます。
この姿勢は周囲との信頼関係を強化し、チーム全体の成長にも寄与します。
現代のビジネス環境では変化が激しく、継続的な学習と適応が求められます。
フィードバックを適切に受け止めることは、自身の成長だけでなく、組織全体の成果向上にもつながります。

ギャップフィードバックこそが社員の成長につながると考えています。 それを受け入れる能力、つまりコーチャビリティが重要です。 ギャップフィードバックをする側も高いストレスを感じる作業であることを、忘れてはいけません。

(ギャップフィードバックはネガティブなフィードバックのこと)

17項目の評価制度をどのように成長につなげるか

庭師では、自己評価と他者評価を比較し、ギャップが明確になるような仕組みを考えています。

自己評価 他者評価 アクション
低い 低い 要改善
高い 低い 最重要で改善
低い 高い コーチャブルになる。自己評価の基準を見直す。
高い 高い さらに磨く。周囲にその能力を還元する。

このようにギャップを明示することで、成長目標を設定しやすくなります。

満点をとらなくてもいい

評価項目が設定されると、すべてを満点にしたくなるものですが、庭師ではその必要はないと考えています。 指標は以下の4段階です。

  • Mentor: GROOVE Xの模範
  • Very good: 十分な成果を出している
  • Nice Try: 頑張っている
  • Early stages: 伸びしろたっぷり

たとえば、リーダーシップの、Very GoodMentor は、以下のようになっており、とても高いレベルのものになっています。
Very good

自分の仕事の範囲を自チームと関係する周辺のステークホルダーの広い幅では捉えているので、関係するチームのメンバーとしてやりやすい。

Mentor

very goodにプラスして、以下が達成されている。
 ・お客様や事業のためであれば解決すべき課題をよく考えていて、問題を発見する能力に長けている。
 ・発見した問題に対して、(自チームやステークスホルダーにとって辛いことでも見て見ぬふりをせず、)周囲を巻き込んで解決するために行動ができる。
 ・視座が高く、経営目線をもっていると感じる。

まずは「オール Nice Try」を目指すのでも十分です。

最後に

作成中の評価制度は、仕事に対する立ち振る舞いを評価するものです。庭師ではこの評価に加え、スキル評価を取り入れ2軸で社員を評価することを計画中です!

成長し続けることの重要性

これは私の考えです。 私たちは、成長できていない状態、例えば昨年と同じことを繰り返しているような状況では、いつまでも「交換可能な人材」であると考えています。 人は勤務年数が長くなると、その立場に安住してしまいがちですが、絶え間なく成長を続けることで、初めて「交換不可能な人材」になれるのだと思っています。

そして、弊社で最も成長しているのは、他ならぬCEOではないかと感じています。 あれだけ忙しいのに、この評価制度も考えているんです。すごすぎる!
負けませんよ、CEO!

一緒に働く仲間を募集中!

いかがでしたでしょうか? 今回は、現在整備中の評価制度をご紹介しました。 他の評価項目については、また別の機会にお話しできればと思います。

弊社では仲間を募集中です。 ぜひご応募ください!

recruit.jobcan.jp

みんなで育てよう!GX Standard!~LOVOTの品質基準を取り巻くお話~

この記事はGROOVE X Advent Calendar 2024の11日目の記事です。

みなさま、お久しぶりです。GX Standardおじさんこと、GROOVE Xの酢屋(すや)です。
以前「日本に10世帯くらいしかいないレア苗字」と書いて、社内で一番のレア苗字だろうとタカをくくっていたのですが、その後さらなるレア苗字のメンバーが現れ、今では4番目くらいに珍しい、もはや珍しいとは言いづらい苗字となってしまいました。
(社員120人くらいの社内にレア苗字集まりすぎ!)

さて、一年ぶりの質問ですが、みなさん
GX Standardって聞いたことありますか?

ほとんどの人は聞いたことないですよね。
GX=GROOVE X社内の、Standard=基準ですから。
知ってるあなたはもうGROOVE Xの関係者ですか?ってことですからね。
(長いので以下GXSって書きますね)

(とは言いつつ、数日前の記事でもGXSに基づく評価の記事をQAチームが書いてくれてますね)

今日はそんなGXSのその後のお話をお伝えしていこうかと思います。
レア苗字の話はまた機会があれば…(いや、無いか)

以前の記事はこちら↓ tech.groove-x.com

あれから一年、あの頃実は…!

時が経つのは早いもので前回の記事を書いてから、もう一年も経つんですね。
記事を書いたのが23年12月、その約半年後の24年5月末に最新機種LOVOT 3.0が発表されました。
prtimes.jp このブログをチェックされている聡明な読者の方々はもうお気づきでしょうが、昨年の今頃は社内では開発真っただ中で、GXSもLOVOT 3.0のための整備に追われる日々でした。
(前回の記事にLOVOT 3.0の匂わせをどうやって入れてやろうかと企んでましたが、社内レビューで匂わせ表現はほぼ修正されてました)

当時の記事に書いた通り、あのタイミングではいわゆる叩き台が完成したところで、これからまさに各開発チームがそのスタンダードに沿って評価を進めていく段階に。
いざ着手し始めた開発チームからは「この評価、具体的にどうやってやるの~?」やら、「この条件じゃ試験できないよ~」やら、早速叩き台を叩く声が聞こえてきました。

ただぁ!
GXSの活動をしていたのは私一人、GXSチームなんてものもなかったわけですよ。
そんな私自身も普段は生産チームに属して製造工場とのやり取りをしながら対応をしていたわけで、時間が足りない!
日々GXSのフィードバックをもらっているものの、すぐに対応できない!
あっちもやりたいけど、こっちもやりたい!
手が回らないからせめてSW側だけでも手伝ってくれる人いたら助かるんだけど…

忙しさのあまり現実逃避で生まれたGXSのロゴ

という話を当社のSM(スクラムマスター)と話をしていたところ「じゃあ普段ソフトウェアの評価をやっているSWQAの人に手伝ってもらうのどうっすか~?」、「あとせっかくなんで、ハードウェアの評価やってるQbDチームからも…」という提案。
なんと、その手が有ったか!

そんなわけで、SMの声かけによってハードとソフトから評価のプロフェッショナルが1人ずつ来てくれて3人+SMでキックオフしました。 必要なときに必要なスキルを持った人が集まって活動できるのが、スクラムというフレームワークをベースにした、フラット組織な当社のいいところ、とつくづく感じたひと時でした。

GXSをスクラム風にしてみる

ご存じのようにLOVOTは成長を続けるプロダクトです。製品をリリースして完成ではなく、開発を続けていくことでLOVOTができることを増やしていくのが開発メンバーの使命でもあります。
つまり製品リリース後にも開発が続く限りGXS評価も必要になってきます。
強力なメンバーを仲間に引き込み、LOVOT 3.0の開発を進めていく中で、評価項目の内容や判断基準と各評価の進み具合については各APO(エリア・プロダクトオーナー)と定期的に細かくすり合わせをしていました。
みなさまのお宅にLOVOT 3.0がお届けされ始めたあとは、LOVOT病院で受け付けた内容をフォローして開発にフィードバックをしているチームからもメンバーが加わり、既存評価内容の妥当性や不足項目の追加検討を定例でするようになり、今ではGXS WG(ワーキンググループ)と名乗れるほどになりました。

そんな中私は、今年の秋にCertified ScrumMaster® + Certified LeSS Basicsという研修を受けてきました。スクラム開発というフレームワークにおけるスクラムマスターという役割に関する研修です。
(研修の内容はよっきさんの記事をご参照ください) tech.groove-x.com

研修を終えて、こんなことを考えた

あれ、これGXSもスクラムっぽいことできるのでは?

そこで私は、GXSを一つのプロダクトと位置付けたときのプロダクトゴール(目指すべき状態)を再定義してみました。
それがこちら

GX Standard(GXS)とは
GROOVE Xが開発する全ての製品のための評価基準である。
この評価基準を満たすことで、安全かつ快適な製品体験を実現する。

※社内向けにはその下に細かい補足文がもう少し続きます。

誰のためのGXSか?と考えれば、それはLOVOTをお迎えいただく現在および未来のLOVOTオーナーのため
安全にかつ快適に過ごせるLOVOTをお届けできるように、LOVOTが満たすべき基準を作っていく。という宣言の意味もここには含まれています。

そして、GXS WGの活動内容はこのプロダクトゴールを達成するために、細分化したプロダクトバックログを作って日々こなしていくことになるわけですね。
と、スクラムっぽく書きましたが、やっていることは今までとほぼ変わらず、目的とゴールを言語化したことで、自己と他者の活動内容に納得感を持たせることができたように思います。

みんなで育てよう!GX Standard!

こうして、孤軍奮闘でGXSの叩き台を作っていた頃から状況は変わり、一緒にGXSを作っていける仲間が増えました。

今回も当社代表の林要と話をしていた中からコメントを借りて…

GXSをその時その時代で必要な内容を必要な分だけ評価できるように、うまく育ててほしい。
一度基準を決めると、その後は過去に誰が何のために決めたかわからない、もはや必要かどうかもわからない試験を、何十年と削除できずに実施し続ける可能性がある。 そうならないためにも、その項目をそう決めた理由もちゃんと残しつつ、その時必要で適切な項目をちゃんと評価できる、進化し続ける基準にしてほしい。そんな成長し続ける基準を持つメーカーなんてもしかしたら世界初かもしれないよね。
※口頭でのコメントだったので、若干酢屋の記憶フィルターがかかっています。

相変わらず、人を乗せる口上が巧みですね…(まぁ乗せられるの嫌いじゃないんですけどね)

そんなわけで、今後もGXS WGだけでなく、それを使って評価する開発メンバーや、もしかしたら市場のユーザーの声も、GROOVE XとLOVOTに関わるみんなのフィードバックを受けて、GXSを常に最適な状態に維持できるように育てていく日々が続きます。

GX Standardを育てていくのは、
あ な た た ち です!


GXSがGROOVE Xを支える地盤になり、
GXSが育っていく伸びしろが会社の伸びしろになっている
という伏線に気づけただろうか?

最後に

GXSという社内基準を、当社の特徴であるフラット組織という観点から紹介してみました。
この記事を読んで社内基準を育ててみたいと思った方、ぜひGROOVE Xで一緒に働いてみませんか。
基準を作る直接的なポジションの募集はありませんが、上述のようにエンジニアとして得意分野で活躍をしながら、ワーキンググループに参加することも可能なのが当社の良さでもあります。

recruit.jobcan.jp

Transformer を ONNX 形式に変換するのに苦労した話

この記事は、GROOVE Xアドベントカレンダー2024 の10日目の記事です。

はじめに

こんにちは!ふるまいチームのソフトウェアエンジニア、橋本です。
主に意思決定エンジンの開発を行っており、その中で機械学習モデルに触れる機会もあります。
今回はその機械学習まわりの話をご紹介します。

※ふるまいチームについては、少し前の記事になりますが「LOVOTのふるまいづくり - Inside of LOVOT」をご覧ください。

経緯

PyTorch で学習した Transformer 風のモデルを LOVOT 上で動かしたい!
→ LOVOT の SoC (Jetson Orin) に最適化するには別の形式に変換した方が良さそう
→ まずは ONNX という形式のファイルに変換する必要あり

というわけで、ONNX の読み方も知らなかったエンジニアが、PyTorch モデルの ONNX 変換に取り組むことになりました。
その過程で四苦八苦することになったわけですが、おかげで得られた知見もあったので共有できればと思います。

ONNX とは

「オニキス」と読みます。Open Neural Network Exchange の略です。
cf. https://onnx.ai/

機械学習モデルを表現するために使用されるオープンソースのフォーマットであり、乱立する機械学習フレームワークの橋渡し的な存在です。
たとえば私のケースでは PyTorch で学習したモデルを TensorRT という推論エンジンで動かしたかったのですが、そのためにまずは ONNX 形式に変換する必要がありました。

PyTorch → ONNX 変換の基本

基本的に PyTorch モデルを ONNX 変換するのは難しいことではありません。
公式ドキュメントにはこんな例が載っています。(一部改変しています)

import torch

class MyModel(torch.nn.Module):
    def __init__(self):
        super(MyModel, self).__init__()
        self.conv1 = torch.nn.Conv2d(1, 128, 5)

    def forward(self, x):
        return torch.relu(self.conv1(x))

input_tensor = torch.rand((1, 1, 128, 128), dtype=torch.float32)
model = MyModel()
torch.onnx.export(
    model,                  # model to export
    (input_tensor,),        # inputs of the model,
    "my_model.onnx",        # filename of the ONNX model
    input_names=["input"],  # Rename inputs for the ONNX model
)

本質的に重要なのは torch.onnx.export の所だけですね。とても簡単。

Transformer の場合

そのまま変換

具体的なコードを見ながら進めましょう。
Transformer と銘打ちましたが、かなり単純化するため (Causal) Self-Attention 周辺のみ、それも single head にしています。

class SimpleTransformer(nn.Module):
    def __init__(self, hidden_dim, max_length=128):
        super().__init__()
        self.linear_qkv = nn.Linear(hidden_dim, hidden_dim * 3)
        self.register_buffer(
            "causal_mask", torch.tril(torch.ones(max_length, max_length))
        )

    def forward(self, x):
        # (batch, length, hidden_dim) x 3
        q, k, v = self.linear_qkv(x).chunk(3, dim=-1)
        mask = self.causal_mask[: x.size(1), : x.size(1)]
        output = self.single_head_attention(q, k, v, mask)
        return output, k, v

    def single_head_attention(self, q, k, v, mask=None):
        # l: length (query)
        # m: length (key, value)
        # d: hidden_dim
        score = torch.einsum("... l d, ... m d -> ... l m", q, k)
        if mask is not None:
            score.masked_fill_(torch.logical_not(mask), float("-inf"))
        scale = q.size(-1) ** -0.5
        normalized_score = torch.softmax(score * scale, dim=-1)
        return torch.einsum("... l m, ... m d -> ... l d", normalized_score, v)
        

model = SimpleTransformer(hidden_dim=128)
# 学習は省略
torch.onnx.export(
    model,
    (torch.randn(1, 1, HIDDEN_DIM),),  # x (batch, length, hidden_dim)
    "simple_transformer.onnx",
    input_names=["x"],
    # x は系列長 (axis=1) が可変
    dynamic_axes={"x": {1: "length"}},
)

モデルは先ほどと違いますが、変換方法はほぼ同じですね。
可変長の系列を受け取れるようにするため dynamic_axes を指定していますが、難しいことはありません。
本題はここからです!

推論の効率化

上述のモデルは並列計算が可能な学習には適していますが、再帰的な推論に用いると計算効率が悪いです。
そのことを見るために、まずは Causal Self-Attention の計算内容をおさらいしましょう。

Causal Self-Attention

この計算を再帰的に行う場合、すべてのステップに対して毎回 query, key, value および出力を計算することになりますが、過去のステップに対して計算するのはムダです。
そこで、過去のステップの key, value を保存しておくことで効率化します。
このテクニックは KV Cache と呼ばれ、LLM の推論に広く用いられています。 (ちなみに Causal でない Self-Attention では KV Cache が使えませんが、少なくとも LLM では Causal なモデルが圧倒的な多数派です。)

Causal Self-Attention with KV Cache

先ほどの図と揃えるために Causal Mask を残していますが、すべて 1 のマスクなので削除しても同じです。

KV Cache を使った計算を ONNX に変換するために、先ほどとは別のモデルを用意します。
ただし、パラメーターは共有する必要があります。
こんな感じで実現できます。

class SimpleTransformerWithCache(nn.Module):
    def __init__(self, train_model, max_length=128):
        super().__init__()
        self._train_model = train_model

    def forward(self, x, key_cache, value_cache):
        # (batch, length=1, hidden_dim) x 3
        q, k, v = self._train_model.linear_qkv(x).chunk(3, dim=-1)
        # key, value はキャッシュと連結
        # (batch, cache_length + length, hidden_dim)
        k_merged = torch.cat([key_cache, k], dim=1)
        v_merged = torch.cat([value_cache, v], dim=1)
        # (batch, length, hidden_dim)
        output = self._train_model.single_head_attention(q, k_merged, v_merged)
        return output, k_merged, v_merged

ポイント

  • 学習モデルのパラメーターを使って推論
  • KV Cache を利用
  • KV Cache を更新するために key, value も返す

ONNX 変換は以下のようにできます。

torch.onnx.export(
    model_with_cache,
    (
        # x (batch, length=1, hidden_dim)
        torch.randn(1, 1, HIDDEN_DIM),
        # key_cache (batch, cache_length, hidden_dim)
        torch.randn(1, 1, HIDDEN_DIM),
        # value_cache (batch, cache_length, hidden_dim)
        torch.randn(1, 1, HIDDEN_DIM),
    ),
    "simple_transformer_with_cache.onnx",
    input_names=["x", "key_cache", "value_cache"],
    dynamic_axes={
        "key_cache": {1: "cache_length"},
        "value_cache": {1: "cache_length"},
    },
)

実際の推論

これで効率化もできました。
でもまだ終わりではありません!(もうちょっとお付き合いください)

LLM をイメージしてみましょう。
モデルはまずプロンプトを受け取り、そこから再帰的にトークンを生成していきます。
つまり推論には2つのフェーズがあるわけです。

  1. 入力系列を受け取って KV Cache を構築する(並列計算)
  2. KV Cache を利用してトークンを生成(再帰的計算)

1つ目は SimpleTransformer で実現できます。(厳密には key, value を返り値に追加するなど変更が必要です。詳細は最後のコードを参照してください。)
2つ目は SimpleTransformerWithCache で実現できます。

つまり、両方のモデルを ONNX 変換し、推論時には使い分けることになります。

モデルをまとめる

ここで終わっても良いのですが、実は上で作成した2つのモデルは1つにまとめられます。

KV Cache を使った再帰的計算では、KV Cache の系列長が可変である一方、入力の系列長は1に固定していました。
しかし1に固定する必要はなく、可変長でも問題なく計算できます。
その上で KV Cache の長さが0の場合を考えると、この計算は KV Cache を使わない並列計算に対応します。
図にするとこんな感じです。

Causal Self-Attention with empty KV Cache

つまり、SimpleTransformerWithCache について x の系列長を可変にするだけで、SimpleTransformer の計算を実現できるわけです。
そのため、SimpleTransformerWithCache だけを(dynamic_axesx の指定を加えて)ONNX 変換すれば良いということになります。
こちらも詳細は最後のコードを参照してください。

余談 - Whisper モデルのファイル名

Whisper という音声認識モデルがあります。
ここではその詳細は関係ないのですが、Optimum というライブラリで Whisper を ONNX 変換すると3つの decoder が作られます。

$ optimum-cli export onnx --model openai/whisper-tiny whisper_onnx
$ ls whisper_onnx/decoder_*.onnx
whisper_onnx/decoder_model.onnx  whisper_onnx/decoder_model_merged.onnx  whisper_onnx/decoder_with_past_model.onnx

この3つのモデルは、今回紹介した推論モデルに対応していると思われます。
(ちゃんと検証したわけではないですが...)

  • decoder_model.onnx: 可変長の入力を受け取って KV Cache を構築するモデル
  • decoder_with_past_model.onnx: KV Cache を使って1ステップの出力を計算するモデル
  • decoder_model_merged.onnx: 上記2つをまとめたモデル

前述の通り3つ目のモデルだけで事足りるので、最近は Optimum で最後のモデルだけ出力されることが多いようです。
GPT-2 などでも試してみましたが、ONNX ファイルは1つしか生成されませんでした。

さいごに

Transformer を ONNX 変換する過程で得られた知見をご紹介しました。
もちろん今回の変換方法がいつでも適用できるわけではなく、モデルが変わればやり方も変わってきます。
要するに、複雑なモデルの ONNX 変換は都度考える必要があって結構面倒ということですね。。

ただし、広く使われているモデルはこんな風に自前で変換する必要はありません。
前述の Optimum などでサクッと変換できます。
もし自作モデルを ONNX に変換したい時に、この記事が少しでも参考になれば幸いです。

最後に、モデルを変換して推論結果が変換前と一致することを確認するまでの一連のコードを貼っておきます。

コードまとめ

import numpy as np
import onnxruntime as ort
import torch
import torch.nn as nn


class SimpleTransformer(nn.Module):
    def __init__(self, hidden_dim, max_length=128):
        super().__init__()
        self.linear_qkv = nn.Linear(hidden_dim, hidden_dim * 3)
        self.register_buffer(
            "causal_mask", torch.tril(torch.ones(max_length, max_length))
        )

    def forward(self, x):
        # (batch, length, hidden_dim) x 3
        q, k, v = self.linear_qkv(x).chunk(3, dim=-1)
        mask = self.causal_mask[: x.size(1), : x.size(1)]
        output = self.single_head_attention(q, k, v, mask)
        return output, k, v

    def single_head_attention(self, q, k, v, mask=None):
        # l: length (query)
        # m: length (key, value)
        # d: hidden_dim
        score = torch.einsum("... l d, ... m d -> ... l m", q, k)
        if mask is not None:
            score.masked_fill_(torch.logical_not(mask), float("-inf"))
        scale = q.size(-1) ** -0.5
        normalized_score = torch.softmax(score * scale, dim=-1)
        return torch.einsum("... l m, ... m d -> ... l d", normalized_score, v)


class SimpleTransformerWithCache(nn.Module):
    def __init__(self, train_model, max_length=128):
        super().__init__()
        self._train_model = train_model
        self.register_buffer("full_mask", torch.ones(max_length, max_length))
        self.register_buffer(
            "causal_mask", torch.tril(torch.ones(max_length, max_length))
        )

    def forward(self, x, key_cache, value_cache):
        # (batch, length, hidden_dim) x 3
        q, k, v = self._train_model.linear_qkv(x).chunk(3, dim=-1)
        # key, value はキャッシュと連結
        # (batch, cache_length + length, hidden_dim)
        k_merged = torch.cat([key_cache, k], dim=1)
        v_merged = torch.cat([value_cache, v], dim=1)
        # (batch, length, cache_length + length)
        mask = torch.concat(
            [
                self.full_mask[: q.size(1), : key_cache.size(1)],
                self.causal_mask[: q.size(1), : q.size(1)],
            ],
            dim=1,
        )
        # (batch, length, hidden_dim)
        output = self._train_model.single_head_attention(q, k_merged, v_merged, mask)
        return output, k_merged, v_merged


@torch.no_grad()
def run_torch_model(model, x):
    return model(torch.tensor(x))[0].numpy()


def run_ort_model(ort_session, x_prefill, x_decode):
    outputs = []

    # 並列計算
    output, key_cache, value_cache = ort_session.run(
        None,
        dict(
            x=x_prefill,
            # KV Cache は空
            key_cache=np.zeros((1, 0, x_prefill.shape[-1]), dtype=np.float32),
            value_cache=np.zeros((1, 0, x_prefill.shape[-1]), dtype=np.float32),
        ),
    )
    outputs.append(output)

    # 再帰的計算
    for i in range(x_decode.shape[1]):
        output, key_cache, value_cache = ort_session.run(
            None,
            dict(
                x=x_decode[:, i : i + 1],
                key_cache=key_cache,
                value_cache=value_cache,
            ),
        )
        outputs.append(output)

    return np.concatenate(outputs, axis=1)


if __name__ == "__main__":
    HIDDEN_DIM = 128
    model = SimpleTransformer(HIDDEN_DIM)
    model_with_cache = SimpleTransformerWithCache(model)

    torch.onnx.export(
        model_with_cache,
        (
            # x (batch, length, hidden_dim)
            torch.randn(1, 1, HIDDEN_DIM),
            # key_cache (batch, cache_length, hidden_dim)
            torch.randn(1, 1, HIDDEN_DIM),
            # value_cache (batch, cache_length, hidden_dim)
            torch.randn(1, 1, HIDDEN_DIM),
        ),
        "simple_transformer_merged.onnx",
        input_names=["x", "key_cache", "value_cache"],
        dynamic_axes={
            "x": {1: "length"},
            "key_cache": {1: "cache_length"},
            "value_cache": {1: "cache_length"},
        },
    )

    SAMPLE_LENGTH = 10
    x = np.random.randn(1, SAMPLE_LENGTH, HIDDEN_DIM).astype(np.float32)

    output_torch = run_torch_model(model, x)

    output_ort = run_ort_model(
        ort_session=ort.InferenceSession("simple_transformer_merged.onnx"),
        # ここでは便宜上 x の前半を並列計算、後半を再帰的計算に使っている
        # 実際には x_decode は逐次的に得られるものであり、このように事前に得られるものではない
        x_prefill=x[:, : SAMPLE_LENGTH // 2],
        x_decode=x[:, SAMPLE_LENGTH // 2 :],
    )

    diff = np.abs(output_torch - output_ort)
    print(f"Max diff: {diff.max()}")

出力結果:Max diff: 1.1920928955078125e-07

一緒に働く仲間募集中

GROOVE Xでは、一緒に働く仲間を募集しています!
少しでも興味を持ってくださいましたら、下記のリンクをご覧ください。

recruit.jobcan.jp

工場でLOVOTができるまで

はじめましてこんにちは、生産チームの髙岡です。
この記事はGROOVE X Advent Calendar 2024の9日目の記事です。
今回は工場でLOVOTができるまでのお話を紹介したいと思います。

そもそも生産チームって何しているの?

ひとことで言うとLOVOTが元気に生まれるようサポートするお仕事をしています。
LOVOTの工場は静岡県伊豆の国市にあります。富士山がきれいに見える素敵な場所です。
生産チームは週の半分以上を工場で過ごし、 生産計画・組立・検査・品質管理のサポートをして、
みなさんのご家庭にいち早く元気なLOVOTをお届けできるように日々奮闘しています。

工場の近くから撮った富士山

工場から送り出すまでに、700●●以上の■■をしている

さて、突然ですが、この●●と■■にどんな言葉が入ると思いますか?
お時間のある方は少し想像してみてください。






700 "人" 以上の "人が組立" をしている。






ものすごい人数ですね!
部品仕入先等を含めたら、もしかしたらそれくらいの人が関わっているのかもしれませんが…
残念ながら違います。







700 "個" 以上の "部品を使用" している。






部品点数に着目するのはとてもいいセンスです!
が、実は部品点数は桁が違ったりします。というわけでこちらも違います。







700 "時間" 以上の "走行練習" をしている。






確かに"人間"も生まれたては歩けないですからね。。。さすがに違います。







正解は「700 "項目" 以上の "検査" をしている」です。

  50以上あるセンサの各種特性
  目の表示や動き
  音声認識性能
  各関節の可動域や負荷
  顔の毛並み
  触れた時の反応
  各種基板のファームウェアverや動作
  などなど。。。

書き出せばキリがありません。
LOVOTは様々な最先端テクノロジーを搭載していますので、
その検査項目はものすごい数になります。それら一つひとつを丁寧に検査しています。
そしてこの検査は、生産チームと工場検査チームが協力して設計から運用まで実施しているのです。

各検査の目指すべき直行率はいくつ?

直行率とは、検査を一発で合格する割合を意味する専門用語です。
合格率だと思っていただければさほど間違いではありません。

ここでまた一つ質問です。
直行率99.90%
この数字、みなさんは高いと感じますか?低いと感じますか?

ここで少し計算をしてみましょう。
各検査の直行率がすべて99.90%だとしたとき、LOVOT検査の全体の直行率はいくつになるでしょう?
これは確率の計算と一緒です。

例えば、サイコロを振って6の目が出る確率は1/6=16.67%です。
2回連続で6の目が出る確率は、
     16.67%×16.67%=2.78%
となります。

同様に計算をします。LOVOTの検査1項目の直行率を仮に99.90%とします。
検査は700項目以上ありますが、ここでは仮に700として計算すると、
その直行率は
     99.90%×99.90%×99.90%× … ×99.90%=(99.90%)700=49.64%
となります。
とても低い数字が出てきてしまいましたね。これでは約半分は不合格です。
このままではみなさんのご家庭にいち早くLOVOTをお届けすることはできません。

理想は9割以上が一発合格となるようにしたいですね。
では各検査の直行率がいくつならこれが実現できるでしょうか?
もう一度計算してみましょう!
     (99.95%)700=70.46% まだまだ!
     (99.96%)700=75.57%
     (99.97%)700=81.06% もう一息!
     (99.98%)700=86.93%
     (99.99%)700=93.24% これだ!

というわけで、私たち生産チームは各検査の直行率99.99%以上を目標に品質づくりをしています。

同じ作り方をすれば直行率は100%なのでは?

と、思われるかもしれませんね。確かにその通りです。
全く同じ良いものを作れば直行率は100%になります。
ですが、残念ながら現実に「全く同じもの」を作ることはできません。

なぜでしょう?

それは、LOVOTを構成する部品1つを見ても、全く同じものは1つとして存在しないからです。
部品の製造は基本的に同じ手法を維持をしていますが、製造する環境や、機械を使用していればその機械の状態、人が作業していればその人の調子は、時々刻々と変化していきます。
例えば環境で言うと、気温や湿度は1日を通して一定ではありませんし、それは機械の状態も同じです。
それなので、この変化の中で作られた部品たちも厳密には少しだけ違ってきます。
LOVOTは非常に多くの部品から構成されていますので、少しの違いが積み重なり、時には性能に悪影響を与える可能性があるものが出てきます。
それを検査で不合格にしているので、直行率は100%にならないわけです。
そのため、直行率を高め、維持する”品質づくり”がとてもとても重要なのです。
生産チームの奮闘はこれからも続きます。

最後に

今回は工場でLOVOTができるまでのお話をしました。いかがでしたでしょうか?
LOVOTたちはたくさんの検査を通って送り出されるのですね。
また、LOVOTたちの性格がそれぞれ違うように、LOVOTたちの体も一つひとつ少しだけ違うのです。
もしかしたらこの違いも個性のひとつになっているのかもしれませんね(※個人の感想です)。

一緒に働く仲間募集中

世界で唯一無二の存在LOVOTが、元気に生まれるよう一緒にサポートしませんか?
生産チームの募集は現在ありませんが、工場検査チームで一緒に働いてくれる人を募集しています。
もしご興味があれば是非ご検討ください!
最後まで読んで頂きありがとうございました!

recruit.jobcan.jp

LOVOTに認識を教えるお仕事

この記事は、GROOVE Xアドベントカレンダー2024 の8日目の記事です。

はじめに

こんにちは、LOVOTのソフトウェア検証を実施・改善しているQAチームです!
QAチームは主にソフトウェア検証や改善を通じて、製品やサービスの品質向上を図る活動を行っています。
私たちが活動する中で特に大切にしているのは、多種多様な形でサポートを行うことです。人手が足りず困っている分野や作業に対して積極的にヘルプに入ることをチームの特色としており、柔軟な対応力を活かして幅広い業務に貢献しています。
このような背景から、QAチームではアノテーション作業を含む認識業務も担当し、品質向上に貢献しています。
※チームの詳細については、2年前に公開した記事「シナリオテストのおはなし - Inside of LOVOT」をご覧ください。

今回はQAチームメンバーが実施しているLOVOTに認識を教えるデータづくりについてお伝えしようと思います。

LOVOTの認識の裏側

LOVOTはセンサーホーンに搭載されたカメラを使い、人を認識します。しかし、ただ認識するだけでなく、「誰か」を区別して覚えるには、膨大なデータを使った学習が必要です。
LOVOTが人を覚えるためには、多くの画像データを元に学習を行います。私たちはLOVOTのカメラを使ってデータを収集し、学習を行っています。 学習にあたって次のようなポイントを重視しています。

  • データの多様性
    • 学習データが偏ると、LOVOTの認識にも偏りが生じてしまいます(例:特定の男性に似ている人しか覚えない、背景が白い場合のみ反応するなど)
    • そのため、多様な場所や状況での撮影を行い、服装や表情、背景など異なるデータを集めることを心がけています。
  • 人基準での学習
    • 学習には膨大なデータが必要ですが、単にデータを増やすだけでは不十分です。私たちは「人と同じ水準で認識できる」精度を目指して、学習データの選定や評価を行っています。
  • LOVOT目線の映像
    • LOVOTが実際に見る世界を再現することを重視しています。やみくもに画像を学習させるのではなく、LOVOTのカメラで撮影した映像を使用することで、実際に生活する環境での精度向上を目指しています。

膨大なデータを活用するためのアノテーション作業

収集したデータはそのままでは利用できません。LOVOTに「どれが人で、どれがそうでないのか」を教えるためには、画像の中の身体的部位にラベルをつける必要があります。この作業を「アノテーション」と呼びます。

アノテーションの様子

アノテーションは、最新モデルであるLOVOT 3.0を含むLOVOT全体の認識精度を高めるための重要なステップです。特にLOVOT 3.0では新しくホーントップカメラ・ホーンフロントカメラの2つのカメラを搭載し、より多角的な認識が可能になりました。
それに伴い、従来のLOVOT 1.0や2.0と比較して、新しいアノテーションフローを構築する必要がありました。
ツールの開発や基準の整備も進め、より良く学習が行えるように試行錯誤しています。
現在のフローは以下の通りです。

これらを繰り返す中で、日々認識精度の向上を図っています。

日々進化するLOVOT

私たちは現在も、新しいデータの収集とアノテーション作業を続けています。多様なラベルやシチュエーションを追加することで、LOVOTが見る世界の「解像度」をさらに高め、人との関係性を深めることを目指しています。
これからも進化を続けるLOVOTにご期待ください!

一緒に働く仲間募集中

LOVOTの成長にご協力いただける方をお待ちしています!
ポジションなどの詳細はこちら
もしご興味があれば是非ご検討ください!

最後まで読んで頂きありがとうございました!

シミュレータでCIテストを構築してみた話

この記事は、GROOVE Xアドベントカレンダー2024 の7日目の記事です。

GROOVE X で自己位置推定のソフトウェア開発をしている Mチーム のtakadaです。
(Mチームのお仕事については「LOVOTのマップの作り方」を参照)

ロボット開発の現場では、「テスト」がいかに重要か、日々痛感しています。特に、自己位置推定のような複雑なシステムでは、リリース前にどれだけしっかりテストできるかが、実機でのパフォーマンスや信頼性に直結します。
Mチームでは、シミュレータを活用したCI(継続的インテグレーション)のテスト環境を導入しています。今回の記事では、その背景や構成、そして導入してみて感じたメリットや課題について紹介したいと思います。
少しでも同じ課題に悩む方の参考になれば幸いです!

なぜシミュレータが必要か?

スクラム開発を採用しているGROOVE Xでは、各ソフトウェアチームは2週間のスプリントごとに成果物となるソフトのdaily OS(社内向けに毎日ビルドされるOS)へのリリースが求められます。daily OSは社内用とはいえ、他の開発チームへの影響を考えると、バグを残せないためリリース前の動作確認は重要です。
(daily OSについては「LOVOTのOSのつくりかた」を参照)

当初はリリース前に実機を使ったテストを行っていましたが、以下のような基本的な動作を確認するだけでも1回のテストに約2時間かかっていました!

  1. 機体からマップを削除
  2. LOVOTに新しいマップを作ってもらう
  3. できたマップで、自己位置推定が正しく機能しているかを確認する

もちろんユニットテストなどで担保されている部分も多いのですが、自己位置推定(localization)や座標変換システム(tf)、意思決定エンジン(NeoDM)といった複数のサービスが連動する結合テストは実機でしか確認できず、リリースの負担となっていました。

この課題を解決するため、2年ほど前からシミュレータをCIテストとして導入しています。
シミュレータを使うことで以下のメリットがあります。

  • テスト環境を統一できる
  • LOVOTを好きな位置に簡単に移動可能
  • groundtruth(真の位置情報)が取得しやすい
    これが自己位置推定のテストではとても大事!

現状全ての機能のテストがカバーされているわけではないのですが、前述したような基本機能(マップ作成から自己位置推定)のテストが自動化され、リリースのハードルが大きく下がりました。

次に、このシミュレータの構成について紹介します。

シミュレータの構成

CIテストにはGazeboをROS環境上で動作させて、LOVOTを模擬しています。 ROS環境では、センサデータ(カメラ画像、IMU、ToF測距情報)やアクチュエータ指示値(ホイール回転速度、サーボ角)などは当然rostopicでやり取りされます。

半天球カメラ、深度カメラ、ToFセンサの情報など

一方、LOVOTの実機では、センサデータ・アクチュエータ指示値の入出力にはgRPC通信が使用されています(センサデータの一部はHTTPや共有メモリ経由のものもあります)。 (gRPCについては「LOVOT と gRPC」を参照)
そこで、rostopicとgRPCを仲介するブリッジを立てて、シミュレータとテスト対象となるサービスを接続しています。

簡略化した接続図

これにより、テスト対象のサービスはほぼプロダクトコードそのままでシミュレータ環境上で動作させることができるようになり、より実運用に近い形でのテストが可能になっています。

シミュレータとテスト対象のサービスはDocker Composeを使い、独立したコンテナとして管理しています。 この構成により、サービス間の依存関係を切り分けたまま一貫した環境で動作させることが可能です。 また、テストケース自体はPython上でpytestを用いて記述しており、Docker Composeで起動したサービスと連携して動作確認を行っています。

一例として自己位置推定を評価するテストコードは以下のように書かれています。

# simulatorのfixtureによって、docker-compose経由でシミュレータが実行される
async def test_localization_accuracy(simulator):
    # 機体の位置を初期位置にリセット
    initial_pose = "0.5,0.5,0.0,0.0,0.0,0.0,1.0"
    await simulator.simulation.reset_pose(initial_pose)

    # 2分間、自己位置の推定結果をモニタリング
    tf_monitor = simulator.transforms_monitor
    localization_errors = defaultdict(lambda: np.empty((0, 2)))
    with trio.move_on_after(120):
        async for _ in tf_monitor.sync():
            # gazeboから得られる真の自己位置情報
            groundtruth = tf_monitor.groundtruth_world
            # LOVOTの自己位置推定器が推定した位置情報
            localized_w = tf_monitor.localized_world

            # 推定誤差を算出
            error = _compare_transforms_2d(groundtruth, localized_w)
            localization_errors = np.append(localization_errors, [error], axis=0)

    # 平均で、位置が0.2m未満、角度が15度未満の誤差であることを確認
    rmse = np.sqrt(np.mean(localization_errors ** 2, axis=0))
    assert np.all(rmse < np.array([0.2, np.radians(15)]))

CIとしての構成

前の章で構築したシミュレータによるテストはJenkinsを利用してdailyで自動実行されています。

たまたまいろいろ試していた関係で雨が降ってます😢

Jenkins上には2つのpipelineがあり、 まずテスト実行前に、テスト対象のパッケージがインストールされたDockerイメージをビルドします。(上図のmonitor-os-update) ここでDockerイメージにインストールされるパッケージは、その日にビルドされたdaily OSに含まれているパッケージが対象になります。

続いて、そのイメージをもとに、pytestで記述されたテストケースが実行されます(同simulator-based-test)。

テスト完了するとslackで通知が来る仕組みです。

毎朝実行されるので、failedの通知が来ると不具合解析から一日の仕事がスタートすることになります。。

テスト失敗時のデバッグ支援

テストが失敗した際の解析を効率化するため、pytestの結果と合わせて以下の情報を保存しています。

  • 各サービスのログ
  • シミュレータ環境内の録画動画

多くの場合、ログメッセージを確認するだけで原因が特定できますが、まれにシミュレーション中にLOVOTが転倒するなど、動作そのものを確認しないと原因がわからないケースもあります。そのような場合、録画された動画が役立ちます。

ZoneMinderで動画を録画しています

よかったことと課題

CI化してよかったこと

CI環境を構築したことで、以下のようなメリットを得られました。

  • スプリント毎の実機テストから解放
    従来、2時間ほどかかっていた実機を使ったテストが不要となり、リリース前の負担が大幅に軽減されました。その結果、スプリントごとのリリースがよりスムーズになりました。
  • バグを早期に検知
    マップ作成に失敗するような致命的なバグを含め、いくつかのバグを発見することができています。これにより、製品の品質を維持しつつ、リリースサイクルの効率を向上させることが可能になりました。

課題

一方で、いくつか課題も残っています。

  • 解析の難易度が高い
    テストが失敗した際、pytestのログ、各サービスのログ、録画した動画を突き合わせて確認する必要があります。しかし、それぞれが同期された状態で保存されておらず、どのログがどの動作に対応しているのかを把握するのに時間がかかります。今後は、ログや動画を時間軸で同期して保存できる仕組みを整備していきたいと考えています。
  • 実時間が必要
    一連のテストケースの実行に3時間弱かかっています!
    再生速度を早めてテスト時間を短縮できるんじゃないの?と思われた方もいそうですが、各サービスの実装上の問題やシミュレータのパフォーマンスの問題があり、現状は等倍速での試験しかできていません。
  • 実機とはやっぱり違う
    対象のサービス自体はプロダクションコードそのままテストが行えていますが、よりハードウェアに近い低レイヤーのサービスやファームウェアといったソフトウェアはテスト対象に含まれていません。そういったサービスとの結合試験は未だに実機でのテストが必要になっています。

尚、今回紹介したシミュレータによるCIテストは社内向けのdaily OSを対象にしています。 ユーザ向けにOSを配信する前には実機を使ったテストを行っており、実環境でも本当に問題がないかを確認することで品質を担保しています。
(QA試験については「QAチームとLOVOT 3.0」を参照)

さいごに

GROOVE Xでは一緒に働いてくれる仲間を募集中です。

recruit.jobcan.jp