nikkie-ftnextの日記

イベントレポートや読書メモを発信

近況報告:100DaysOfContribution 達成しました!💚

f:id:nikkie-ftnext:20200816022144p:plain

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
オンライン登壇に、今月末のPyCon JPの準備にと、熱い夏を過ごしています。
そんな中、最近達成したあることを記事にします。

目次

100DaysOfContribution

「草」、ご存知でしょうか?🌱
笑いの方の草(ww)ではなく、GitHubのプロフィールの方です。

GitHubのプロフィールにはコントリビューションカレンダーがあります。
このたび、自分のカレンダーを緑で100日埋めることを達成しました1!(現在も継続中)
5月のGWから8月前半で100日です。

  • 5月はremote.pyへの登壇に向けた開発
  • その後PyCon JPのスタッフ活動で使うWebアプリ開発へ移行(〜6月)
  • 7月はEuroPython登壇準備
  • 8月はPyCon Africa登壇準備

と細ーくですが、継続してコードを書いてきました。
やってみて思うのは、100DaysOfContributionであれば、それほど難しくはなかったということです。

戦略:100DaysOfContribution

コントリビューションとしてのカウントは上記のGitHubのページに詳しいです。

  • Issueを立てる
  • プルリクエストを作る
  • デフォルトブランチにコミット(プルリクエストのマージも含む)

言ってしまえば、これをサイクルで回せば、毎日コードを書かなくても薄ーく草を生やすことができます。

  • 1日目:小さいIssueを立てる
  • 2日目:ブランチを切って実装、プルリクエスト作る
  • 3日目:プルリクエストをデフォルトブランチにマージ

1日目と3日目にはコードを書いていません2
それでも草は維持できています。

GWに1週間草が生え、「これを維持してみようかな」と思って続けるうちに上記のサイクルに至りました。
続けられたのはリモートワークに移行し、通勤時間がなくなったことが大きいです。
通勤時間だった時間を使って、コードを書いています。

なお、プルリクエストをデフォルトブランチにマージするとコミットの日付は変わらずに取り込まれます。
これは、カレンダー上は緑が絶えていても、その間フィーチャーブランチに毎日コミットしていれば、プルリクエストマージで緑が復活するということだと思っています。
ただ、私は緑が絶えるのを見たくなかったので、上記サイクルに落ち着きました。
平日に1回か2回は強制的にコードを書くリズムが作れています。

この先の世界:Write Code Every Day

この記事を書きたいと思っていたところで、Write Code Every Dayを知りました3

薄く緑が生やせるようになったら、次のステージは緑を濃くすることだと思っています。
というわけで、薄い緑を続けつつ、濃くする方向で試行錯誤してみようと思います。

終わりに

100DaysOfContributionは実質34CyclesOfContributionでした(1Cycle = 3Days)。
うっすらとでも毎日草を生やすのは、やる前は高いハードルと感じていました。
ですが、やってみるとハードルはそこまで高くはなかったです(少し頑張ったらできたなという感じ。達成した立場のバイアスがかかっているかもしれませんね)
よろしければ、2020年の残り4ヶ月半にいかがでしょうか?


  1. 私の場合、仕事で書いたコードはカレンダーに反映されません。全てプライベートの時間に書いたコードです

  2. コードを書けば、実装が加速するので、インセンティブはあります。無理なく続ける形として、このサイクルに落ち着いています。

  3. https://twitter.com/yosuke_furukawa/status/1294460648999403520?s=20 で知りました

イベントレポート | Python mini Hack-a-thon(オンライン)で"機械学習しました" #pyhack

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
週末の #pyhack で手を動かした内容についてブログに残します。

目次

勉強会の概要

(第112回)Python mini Hack-a-thon(オンライン) - connpass

基本的に毎月開催です。スプリントのゆるい版みたいな感じで各自自分でやりたいことを持ってきて、勝手に開発を進めています。参加費は無料です。 初めての方も常連さんもぜひご参加ください。

オンライン開催にしました

取り組んだこと

日本語テキストの分類タスクに取り組みました。

取り組んだ問題とは異なりますが、履歴書のテキストデータから採用するかしないかを分類するような問題設定です。
手元にはこれまで人がつけた「採用」「不採用」というラベルがあるので、教師あり学習が使えると見込んでいます。
性能のいい機械学習モデルができると、採用されやすい履歴書から人が見ることで、人のリソースを効率的に使えそうです。

データサイエンティストとして1年、独力でどこまででき、どこを補強した方がいいのかの確認を目的に、「機械学習で課題解決できたらいいな」とワクワクしながら取り組みました。

取り組みのログは以下にあります:

うまくいったこと

ホットケーキ作りです!

というのは冗談で、日本語テキストの前処理のための環境設定と思っています。
特にmecab-ipadic-NEologdを更新し、ふだん使っているmecab-python3ではなくfugashiを試しました。

2020年Q1の週1ブログでの素振りにより、前処理をするためのスクリプトはスラスラ書けました。
(今回はモデル訓練までを通して行うことを優先したので、前処理はとても大雑把です)
また、jupyterではなく小分けにしたスクリプトを作って進めたのも感触がよかったです。

課題に感じていること

前処理のコードに比べて、モデルの交差検証での性能評価のコードが書きにくかったです。

  • 正例の方が少ない不均衡なデータのため、推論された確率を使って、閾値以上なら正例とするように処理を加える必要があった
  • これによりsklearnに準備されたcross_val_scorecross_validateを使うのを断念。KFoldを繰り返し処理してモデルの指標の値を算出するように変更

結果、繰り返しが多いコードになり、「もうちょっとうまく書けるのでは?」と感じています。

今後

できたモデルはあまり性能がよくはありません。
前処理が単純すぎるので、工夫する余地が多分にあるという感触です。

またモデルの評価指標を決めずにいろいろなスコアを出力して交差検証したのはあまりよくなかったと感じています。
2クラス分類モデルの評価の指針は、例えば以下の本の5章にありそうです。

懇親会では形態素解析のライブラリの話になりました。
最近はginzaが登場してきたとのことでした。
分かち書き教師なし学習で行う手法も登場していますね(sentencepieceなど)。

参加者、そして、運営の皆さま、1日ありがとうございました!

イベントレポート | オンライン開催のみんなのPython勉強会#58(Sphinx回)にスタッフ参加しました(配信まわり編) #stapy

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
ふた月ぶりの更新です。
5月はPyCon JPスタッフ活動に打ち込んでいました1
「少しずつでもブログにアウトプットしたい」という気持ちが出てきたので、直近でスタッフ参加した「みんなのPython勉強会」の配信運営まわりについて書きます。

目次

勉強会の概要

みんなのPython勉強会#58 - connpass

「みんなのPython勉強会」では、Pythonを中心としてプログラミングを仕事、研究、趣味など様々なシーンに生かす方法を一緒に学びます。

今回のテーマは「Python製ドキュメント生成ツールSphinx丸わかり」

勉強会の様子

YouTube アーカイブ(今後編集されるかもしれませんが17分過ぎから始まります)

Togetter

うまくいったこと

開始前の自動スライドショーです。

5/30のOSC nagoyaにスタッフ参加したところ、幕間にパワポ製のスライドを自動再生した状態でZoomに画面共有するという方法を知りました2
Zoomの機能でYouTube配信に連携している場合は、画面共有されたスライドが自動で進んでいきます。
この方法でタイムテーブルなどを案内するのはいいなと思いました。

これはGoogleスライドでもできそうだったので、今回試してみました。
G Suite アップデート ブログ: Google スライドのプレゼンテーションでの空白画面への切り替えと、自動再生の切り替わり時間オプションの設定

f:id:nikkie-ftnext:20200613013638p:plain

これまで(4月や5月)は開始前の打合せや世間話が配信されていました。
今回は打合せの代わりに自動再生スライドを流せたことで、待機している方も待機しやすくなったんじゃないかと思います。

音楽がほしいというのは、試してみないと得られなかった反響ですね。

課題に感じていること

発表のタイムキープ(残り時間のお知らせ)です。

Zoomの発表者画面はZoomのウィンドウが消える挙動なので、チャットでお知らせしても発表者が気づかない(そもそも開いていない)可能性がありますよね。
そうすると、「ミーティング内の参加者カメラで残り時間を伝えたほうがいいのかな」というのが現時点の感触です。

Zoomで行った懇親会ではスマホのアプリ3をカメラに映して、LTのタイムキープをしました。
5分のLTなのでできましたが、本編のトーク(25分程度)となると別の方法が必要そうです(腕が辛そう)。

小さく始めるならカンペを試してもいいかもしれません。
最終的には5月のOSCのLTで見たドラおばけさんのタイムキーピングを真似したいと思っています(どう実現しているのか、アーキテクチャを調べるところから)。

終わりに

YouTube Liveをご覧いただいた皆さま、登壇いただいた皆さま、そして懇親会までお付き合いいただいた方々、誠にありがとうございました。
スタッフの皆さん、お疲れさまでした!

みんなのPython勉強会の配信の裏側に興味がある方は過去のレポートもどうぞ!

おまけ:ふた月ぶりにブログ更新のきっかけ

ゆめちさんのブログを購読しているのですが、以下の記事に触発されました。
ゆめちさんはPyCon JPスタッフ活動もされつつ、直近はブログを継続更新されています。
スタッフに極振りしがちな私も「ちょっとずつでもブログにアウトプットしていこう」と思った次第です。


  1. この間の私のアウトプットに興味がある方は PyCon JP Blog をご覧ください

  2. 当日の中継を見た方は分かると思います(分割後の動画がアップされたため、当日の中継は現在非公開でした)

  3. Presentation Timerを使っています

イベントレポート | オンライン開催のみんなのPython勉強会#56(サーバサイドエンジニア回)にスタッフ参加しました #stapy

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2020/04/15にみんなのPython勉強会がオンライン開催されました。
3月に続けてスタッフ参加ブログを書きます(驚くほど順調に行ったので、今回は裏側の話は少なめです)。

目次

勉強会の概要

【オンライン+無料開催】みんなのPython勉強会#56 - connpass

"サーバーサイド上級エンジニア"に!!! おれはなる!!!!

今回のテーマは、サーバーサイドエンジニアです。このテーマにぴったりな有識者が勢揃いました。 初級、中級のサーバーサイドエンジニアにターゲットを絞り、もう一段上のレベルを目指せる講演を用意しましたのでご興味のある方はお申し込みください。

Ansible、AWS Lambda、アジャイルとDevOpsと、たしかに一歩進んだ印象を受けるトピックが揃いました。
著名な登壇者が揃った効果か、申込みは560名
PyCon JP現地参加者の半分くらいですね。
現地開催のみんなのPython勉強会ではこうはいきません(オンライン開催で初めて受け入れられる参加者数です!)。

勉強会の様子

YouTube アーカイブ

3 〜stapy #56 Online〜

オンライン開催の舞台裏(約3時間)の共有です。

前回うまくいかなかったZoom→YouTube Liveというアーキテクチャで配信しました。

  • (18:30過ぎ)nikkie、スタッフ&登壇者用のZoomに参加(今回はスタッフも全員自宅から参加です)
  • (18:40頃)画面共有チェックスタート、YouTube Liveにも配信開始
  • (19:00)定刻スタート
  • (21:00過ぎ)機材トラブルもなく(!)、少し押すくらいで終了
  • (21:15)Remoを使ったオンライン懇親会へ移行
  • (22:00過ぎ)オンライン懇親会終了

前回と比べると信じられないくらい順調に進みました


このような感想もいただき、ありがたい限りです。

Remo懇親会の舞台裏

最近注目を浴びている1Remo Conferenceを試しました。
Zoom懇親会は厳しそう、かつ、個人的に試したいと思っていたので、やってみました。

560人の参加者なので300人の部屋を2つ用意。
Present機能を使うと全参加者に対して配信できるようなので、1部屋を「懇親会LT」(オフラインでおなじみ)もできる部屋として用意していました。

20時をすぎた段階での視聴者は270名程度。
「これは2部屋あると混乱する」と直感し、1部屋にまとめて運用しました。

実際の参加者は30名くらいでした。
視聴者の1割くらいというのはやってみての発見です。

ひとまず無事に終わり、今はほっと胸を撫で下ろしています(冷静に考えてみると600人全員来たら大冒険でした)。

参加いただいた方から嬉しい感想もいただきました!

トークの感想も共有します。

『Ansible で始めるサーバーサイドのインフラ構築』(佐藤さん)

Ansibleを知らない/聞いたことがある方向けに、Windows Serverの設定例を通してAnsibleを導入するトーク
「始める」というタイトルの通り、とっつきやすい説明でした。
playbookやroleといった概念を出さずにAnsibleを導入したのが、入門にはうってつけだと思いました。

私自身は業務でAnsibleを触り始め、inventory, playbook, roleなどの概念がまだ掴みきれていません。
このあたりは佐藤さんも執筆に加わった『Ansible実践ガイド』を読んでみようと思います。

Ansibleには、KatacodaのコンテンツやコミュニティのSlackもあるんですね!

『Pythonistaに贈るAWS Lambda入門』(西谷さん)

AWS Lambdaを知らない方向けにサーバレスの概念やLambdaの実装例〜実装ポイントを解説するトーク
Lambdaを触ったことのある身2には、実装ポイントがありがたかったです3

寡聞ですが最近のAWSのマネージドは本当にすごくて4、以下の言葉が印象に残っています(個人的に好きな言葉と結びつきました)。

私がエンジニアとしてのキャリアを始めた時点と比べて、「やらなくてもいいこと」はすでにかなり変わっている印象です。
それにキャッチアップし、使いこなせるようになった上で、自分が価値が出せることを追求しなきゃなと思います。

西谷さんはAWS Black Belt Online SeminarでもLambdaについて話されていて、以下の動画は積ん読です:

アジャイルとDevOps』(長沢さん)

アジャイルやDevOpsの本質(なぜ必要なのか)を考える機会になるトークでした。
私の今いる環境はアジャイルを実践しているのですが、私自身はアジャイルについて深掘ったことはありません。

印象的なところをメモ:

  • ビジネルもサービス/アプリもチームも「変わらない」から「変わる」ようになった(裏にテクノロジーの変化)
  • 技術の不確実性×合意の難しさによる複雑なプロジェクトが増えてきている
  • ソフトウェア自体は安定していない(→安定させようとする)
  • マネジメントからすればアジャイルチームはブラックボックスフルスタックになっているチームを信じる
  • グループ(組織)とチーム。同じ景色を見ているのがチーム(自分ごと)

変わるようになった(=パラダイムが変わった)からこそ、それが前提の「アジャイル」や「DevOps」、さらに「ドメイン駆動設計」(コードの変更しやすさ)の価値が上がっているんじゃないかというのが気づきです。

"サーバーサイド上級エンジニア"に!!! おれはなる!!!!に込められた思い

あべんべんさんの導入から

長所で助ける(補い合う)などコミュニティに通じるところがあるとしみじみ思いました。
ちなみにワンピースは最近広告で攻勢をかけていますね(伏線回収!?)

参加いただいた方のアウトプット!

オフライン開催と一番違うと思ったのは、懇親会の裏で参加レポートが上がってきたこと。
運営スタッフからすると、参加レポートは本当にありがたいです。
オンライン開催で見る人が今回一気に増えたからこそ、参加レポートが即アップされたんだと思います。

感想

濃密な時間でしたー。
とくに本編のコンテンツは過去の開催と比較してもかなり濃かったと思います。
スタッフ活動しつつたくさんインプットできました!

個人的に課題を感じたところを書き出します。

  • オンライン登壇
    • 私は慣れてしまった5のですが、話しているときにリアクションがないので話しづらいんですよね(懇親会の話から)
    • 解決の方向性としては、発表者にリアクションが見えるようにする or Zoomに入っている参加者が聞いている感じを積極的に出す?
  • アナウンスまわり
    • 懇親会のLTについては事前にアナウンスできなかったので、オフラインのLT常連の方には申し訳なかったです(私が急遽Remoにしましょうと提案したためです)
    • 次回は懇親会のLTについて申込時には発表できるように個人的にはしたいです(懇親会LT文化を継続したい気持ち)
    • connpassにRemoの詳細を追記したのも当日なのですが、connpass最後まで読む方ってきっと少数ですよね。これはメッセージを送るべきだったなと思います
  • Remoの使用感
    • オンライン懇親会でビデオチャットって結構障壁が高かったんじゃないかと思います
    • 事前アンケートによると参加者の過半数は初めてでした
    • 見知らぬ人とのビデオチャット自体、すごく勇気がいると思います
    • なので初参加で懇親会まで参加していただいた方には感謝しかありません
    • ビデオチャットの障壁が高いと感じたので、最初はテキストチャットからの方が参加者は増えるのではないかと思います(テキストでどう懇親するか再考が必要)
    • もしくはRemoでのアイスブレイクの工夫(緊張緩和 & 期待される振る舞いを全員で確認)
    • ある程度顔見知りどうしなら運営の労力はあまりいらなそうですが(実際今回は顔見知りの方が多かった)、初参加の方に参加しやすい懇親会にするにはもっと労力をかける必要がありそうというのが個人の感触です

YouTube Liveをご覧いただいた皆さま、登壇いただいた皆さま、そして懇親会までお付き合いいただいた方々、誠にありがとうございました。
スタッフの皆さん、お疲れさまでした!

次回は5周年記念です。お楽しみに!


  1. ZoomでできなくてRemoでできること(参考noteリンク追加)|黒田 悠介 / 議論メシ|noteWeb会議システムRemoを使ってみた。イベントでのWeb懇親会に使えそう!|kurita|note。前者には参考リンクが追加されていました。感じた課題感にアプローチがあるのか、目を通したいところです

  2. 過去の懇親会LTでも扱いました:AWS Lambdaでpip installしたパッケージを使うときにハマったこと 〜zipでアップロードといわれても〜

  3. PyCon Singapore 2019でも聞いたトピックを再び聞いてようやく消化されたように感じています:SG 2019 Day1 16:00〜 Python on AWS Lambda · Issue #16 · ftnext/PyConTalkSummary · GitHub

  4. 機械学習関連でSagaMakerがヤバかったです。学習の自動化が来ました!それも日本リージョンでも使えるんです!

  5. 登壇報告 | 2/29にオンラインで開催されたPyCon mini ShizuokaでDjango入門トークをしました #pycon_shizu - nikkie-ftnextの日記に書きました

週末ログ | GKEでKFServingを使ってPyTorchのモデルのサーブを試しハマりました(後編:デプロイしたら暗中模索)

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。

週末ログの後編です。
PyTorchでモデルを訓練した後、KFServingでサーブする部分です。

目次

1. KFServingが使える環境を用意する

前編を参照ください。

2. PyTorchでテキスト分類するモデルを用意

PyTorchのチュートリアルの中から、テキストの多クラス分類のモデルを選択しました。
AG_NEWSの記事を4つのカテゴリのいずれかに分類します1
KFServingのサンプルにあるCIFAR10を使った画像分類から、問題設定とモデルを変えました。

Python環境

$ python -V  # venvを使用
Python 3.7.3
$ pip list  # 手で入れたパッケージを示します
black                    19.10b0
flake8                   3.7.9
ipython                  7.13.0
requests                 2.23.0
torch                    1.4.0
torchtext                0.5.0

モデルの学習スクリプト

続く3-1のステップを通ったもの(ag_news.py)を示します2

import os
import time

import torch
from torchtext.datasets import text_classification
import torch.nn as nn
from torch.utils.data import DataLoader
from torch.utils.data.dataset import random_split
from torchtext.data.utils import get_tokenizer, ngrams_iterator


NGRAMS = 2
BATCH_SIZE = 16
EMBED_DIM = 32
N_EPOCHS = 5
min_valid_loss = float("inf")


class Net(nn.Module):
    def __init__(self, vocab_size=1308844, embed_dim=EMBED_DIM, num_class=4):
        super().__init__()
        self.embedding = nn.EmbeddingBag(vocab_size, embed_dim, sparse=True)
        self.fc = nn.Linear(embed_dim, num_class)
        self.init_weights()

    def init_weights(self):
        initrange = 0.5
        self.embedding.weight.data.uniform_(-initrange, initrange)
        self.fc.weight.data.uniform_(-initrange, initrange)
        self.fc.bias.data.zero_()

    def forward(self, text, offsets=None):
        if offsets is None:
            offsets = torch.tensor([0])
        embedded = self.embedding(text, offsets)
        return self.fc(embedded)


def generate_batch(batch):
    label = torch.tensor([entry[0] for entry in batch])
    text = [entry[1] for entry in batch]
    offsets = [0] + [len(entry) for entry in text]
    offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
    text = torch.cat(text)
    return text, offsets, label


def train_func(sub_train_):
    train_loss = 0
    train_acc = 0
    data = DataLoader(
        sub_train_,
        batch_size=BATCH_SIZE,
        shuffle=True,
        collate_fn=generate_batch,
    )
    for text, offsets, cls_ in data:
        optimizer.zero_grad()
        text, offsets, cls_ = (
            text.to(device),
            offsets.to(device),
            cls_.to(device),
        )
        output = model(text, offsets)
        loss = criterion(output, cls_)
        train_loss += loss.item()
        loss.backward()
        optimizer.step()
        train_acc += (output.argmax(1) == cls_).sum().item()
    scheduler.step()
    return train_loss / len(sub_train_), train_acc / len(sub_train_)


def test(data_):
    loss = 0
    acc = 0
    data = DataLoader(data_, batch_size=BATCH_SIZE, collate_fn=generate_batch)
    for text, offsets, cls_ in data:
        text, offsets, cls_ = (
            text.to(device),
            offsets.to(device),
            cls_.to(device),
        )
        with torch.no_grad():
            output = model(text, offsets)
            loss = criterion(output, cls_)
            loss += loss.item()
            acc += (output.argmax(1) == cls_).sum().item()
    return loss / len(data_), acc / len(data_)


if __name__ == "__main__":
    if not os.path.isdir("./.data"):
        os.mkdir("./.data")

    train_dataset, test_dataset = text_classification.DATASETS["AG_NEWS"](
        root="./.data", ngrams=NGRAMS, vocab=None
    )
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # VOCAB_SIZE = len(train_dataset.get_vocab())
    # NUM_CLASS = len(train_dataset.get_labels())
    model = Net().to(device)

    criterion = torch.nn.CrossEntropyLoss().to(device)
    optimizer = torch.optim.SGD(model.parameters(), lr=4.0)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, 1, gamma=0.9)

    train_len = int(len(train_dataset) * 0.95)
    sub_train_, sub_valid_ = random_split(
        train_dataset, [train_len, len(train_dataset) - train_len]
    )

    for epoch in range(N_EPOCHS):
        start_time = time.time()
        train_loss, train_acc = train_func(sub_train_)
        valid_loss, valid_acc = test(sub_valid_)

        secs = int(time.time() - start_time)
        mins = secs // 60
        secs = secs % 60

        print(f"Epoch {epoch+1} | time in {mins} minutes, {secs} seconds")
        print(
            f"\tLoss: {train_loss:.4f}(train)\t|",
            f"\tAcc: {train_acc * 100:.1f}%(train)",
        )
        print(
            f"\tLoss: {valid_loss:.4f}(valid)\t|",
            f"\tAcc: {valid_acc * 100:.1f}%(valid)",
        )

    print("checking the results of test dataset...")
    test_loss, test_acc = test(test_dataset)
    print(
        f"\tLoss: {test_loss:.4f}(test)\t|\tAcc: {test_acc * 100:.1f}%(test)"
    )

    torch.save(model.state_dict(), "model.pt")

    ag_news_label = {1: "World", 2: "Sports", 3: "Business", 4: "Sci/Tec"}

    ex_text_str = "MEMPHIS, Tenn. – Four days ago, Jon Rahm was \
        enduring the season’s worst weather conditions on Sunday at The \
        Open on his way to a closing 75 at Royal Portrush, which \
        considering the wind and the rain was a respectable showing. \
        Thursday’s first round at the WGC-FedEx St. Jude Invitational \
        was another story. With temperatures in the mid-80s and hardly any \
        wind, the Spaniard was 13 strokes better in a flawless round. \
        Thanks to his best putting performance on the PGA Tour, Rahm \
        finished with an 8-under 62 for a three-stroke lead, which \
        was even more impressive considering he’d never played the \
        front nine at TPC Southwind."

サーブするモデルに渡すテンソルを用意

今回は小さく試すため、テキストではなく、テンソル化した(=前処理済みの)テキストをモデルに与えるとします3

$ python -i ag_news.py
 :
Epoch 5 | time in 0 minutes, 22 seconds
    Loss: 0.0022(train) |   Acc: 99.0%(train)
    Loss: 0.0000(valid) |   Acc: 90.7%(valid)
checking the results of test dataset...
    Loss: 0.0002(test)  |   Acc: 89.6%(test)
>>> tokenizer = get_tokenizer("basic_english")
>>> ex_tokenized = tokenizer(ex_text_str)  # ag_news.py中に定義したテキストをトークナイズ
>>> vocab = train_dataset.get_vocab()
>>> tokens = [vocab[token] for token in ngrams_iterator(ex_tokenized, NGRAMS)]
>>> len(tokens)
237
>>> import json
>>> # テンソル化したテキストのJSONを作成
>>> with open('input.json', 'w') as f:
...     json.dump({'instances': [tokens]}, f, indent=4)

3. KFServingでサーブを試す

3-1 ローカルでモデルをサーブする

まず、PyTorchのモデルをKFServingでサーブするサンプルで使っているpytorchserverを入手します(python -m pytorchserver ...)。
これはPyPIには上がっておらず、リポジトリのコードからインストールする必要がありました。

$ git clone git@github.com:kubeflow/kfserving.git
$ cd kfserving/python/pytorchserver/
$ pip install -e .
$ cd ../../..  # 元の階層に戻る

ディレクトリ配置

.
├── __pycache__
├── ag_news.py  # 2で作成
├── env
├── input.json  # 2で作成
├── kfserving  # 3-1でclone
├── model.pt  # 2で作成
└── pytorch.yaml  # 3-2で作る

model.ptがあるディレクトリでpython -m pytorchserver --model_dir ./ --model_name pytorchmodel --model_class_name Netを実行します。
--model_dirで指定したディレクトリにあるPythonスクリプト(ここではag_news.py)をimportするようです。

  • Pythonスクリプト複数あるとエラー になりました
  • if __name__ == "__main__":を書いていなかったら、学習を1から実行しました

サーバが起動したら、サンプルに沿ってrequestslocalhostにinput.jsonの内容をPOSTします。

In [1]: import json

In [11]: with open('input.json') as f:
    ...:     form_data = json.load(f)
    ...:

In [14]: import requests

In [15]: res = requests.post('http://localhost:8080/v1/models/pytorchmodel:predict', json=form_data)

In [16]: res
Out[16]: <Response [200]>

In [17]: res.text
Out[17]: '{"predictions": [[-1.8232978582382202, 7.340854644775391, -1.85544753074646, -4.12541389465332]]}'

ここでつまづいたのは2点:

(1) モデルをロードする際に引数を渡せません4

self.model = model_class().to(self.device)

そこで、Net.__init__の引数にデフォルト値を指定しました(マジックナンバーの正体です)。
以下のようにデフォルト値を調べましたが、引数のデフォルト値は一度だけ評価されるので、len(train_dataset.get_vocab())を指定してもいいかもしれません。

In [2]: from torchtext.datasets import text_classification

In [3]: train_dataset, test_dataset = text_classification.DATASETS["AG_NEWS"](
   ...:         root="./.data", ngrams=2, vocab=None)

In [4]: len(train_dataset.get_vocab())
Out[4]: 1308844

In [5]: len(train_dataset.get_labels())
Out[5]: 4

モデルのロードで引数を渡したいと、Issueも上がっていました:

(2) 送られてくるJSONは以下のように操作されます5

inputs = torch.tensor(request["instances"]).to(self.device)
self.model(inputs).tolist()

model(inputs)と渡すためNet.forwardoffsets引数にもデフォルト値が必要でした6
現状では、POSTしたJSONからoffsets引数に値は渡せないと思います。

また現状は、POSTするデータとしては、一度に1テキストしかポストできないようです(キーがinstancesですが、値の実体はinstance)。

2. GKEでモデルをサーブする(ハマり中)

訓練したモデルをGCSに置きます(今回バケットはコンソールから作りましたが、gcloudコマンドでも作れそうですね)。

$ gsutil cp model.pt  gs://nikkie-knative-project/models/pytorch/ag_news/

サンプルに沿ったyamlファイルを準備します。

apiVersion: "serving.kubeflow.org/v1alpha2"
kind: "InferenceService"
metadata:
  name: "pytorch-agnews"
spec:
  default:
    predictor:
      pytorch:
        storageUri: "gs://nikkie-knative-project/models/pytorch/ag_news/"
        modelClassName: "Net"

kubectl applyでKFServingのリソースをデプロイ!

$ kubectl apply -f pytorch.yaml
inferenceservice.serving.kubeflow.org/pytorch-agnews created

ところが、READYがFalseとなって、URLを取得できません😱

$ kubectl get inferenceservices
NAME             URL   READY   DEFAULT TRAFFIC   CANARY TRAFFIC   AGE
pytorch-agnews         False                                      95s

暗中模索

似た事象のIssue

k8sの練度が低く、Issueの情報が活かせていません。
特に、どのリソースについてログが見られるかがよく分かっておらず、「Issueに書いてある情報はどうやって出すんだろう」という状況です。

別の問題を解消

GitHub - kubeflow/kfserving: Serverless Inferencing on Kubernetes にあったインストール後のテストを試しました。

$ kubectl get po -n kfserving-system
$ kubectl apply -f kfserving/docs/samples/sklearn/sklearn.yaml
$ kubectl get inferenceservices sklearn-iris
NAME           URL   READY   DEFAULT TRAFFIC   CANARY TRAFFIC   AGE
sklearn-iris         False                                      19s

READYがFalseだった7ため、テストに続くトラブルシューティング8からk8s 1.15以上でオススメのコマンドを試します(リソースはdeleteしています)。

kubectl patch mutatingwebhookconfiguration inferenceservice.serving.kubeflow.org --patch '{"webhooks":[{"name": "inferenceservice.kfserving-webhook-server.pod-mutator","objectSelector":{"matchExpressions":[{"key":"serving.kubeflow.org/inferenceservice", "operator": "Exists"}]}}]}'

すると、READYがFalseという事象は解決しました。
しかし、リクエストを送ると503が返ります。

$ kubectl get inferenceservices sklearn-iris
NAME           URL                                                              READY   DEFAULT TRAFFIC   CANARY TRAFFIC   AGE
sklearn-iris   http://sklearn-iris.default.example.com/v1/models/sklearn-iris   True    100
$ kubectl port-forward --namespace istio-system $(kubectl get pod --namespace istio-system --selector="app=istio-ingressgateway" --output jsonpath='{.items[0].metadata.name}') 8080:80

# 別のターミナルで
$ curl -v -H "Host: sklearn-iris.default.example.com" http://localhost:8080/v1/models/sklearn-iris:predict -d @./kfserving/docs/samples/sklearn/iris-input.json
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /v1/models/sklearn-iris:predict HTTP/1.1
> Host: sklearn-iris.default.example.com
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 76
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 76 out of 76 bytes
< HTTP/1.1 503 Service Unavailable
< date: Sun, 12 Apr 2020 09:28:08 GMT
< server: istio-envoy
< connection: close
< content-length: 0
<
* Closing connection 0

CIFAR10の例でも同様のエラー

git cloneしていたので試してみました。
しかしながら、READYはFalseのままです。

前掲のトラブルシューティングで知ったログの出力を試すと

$ kubectl logs -l app=networking-istio -n knative-serving
 :
W0412 13:10:04.732258       1 reflector.go:299] runtime/asm_amd64.s:1357: watch of *v1.ConfigMap ended with: too old resource version: 137817 (139711)

too old resource version ... 🤔(分からない。。)

お片付け

逆順にdeleteしていきます。

$ kubectl delete -f pytorch.yaml
$ kubectl delete -f $CONFIG_URI
$ kubectl delete --filename https://github.com/knative/serving/releases/download/v0.13.0/serving-istio.yaml
$ kubectl delete -f https://raw.githubusercontent.com/knative/serving/master/third_party/istio-${ISTIO_VERSION}/istio-minimal.yaml
$ kubectl delete -f https://raw.githubusercontent.com/knative/serving/master/third_party/istio-${ISTIO_VERSION}/istio-crds.yaml
$ kubectl delete --filename https://github.com/knative/serving/releases/download/v0.13.0/serving-core.yaml
$ kubectl delete --filename https://github.com/knative/serving/releases/download/v0.13.0/serving-crds.yaml

# 1-1で参照したドキュメントより Cleaning up
$ gcloud container clusters delete $CLUSTER_NAME --zone $CLUSTER_ZONE

感想

ローカルでは動くので、GKE上の環境の構築ミスだと思うのですが、k8sの練度が低くて今回は切り分けられませんでした。
k8sの理解が甘いので、ひとまずリソースとログの関係をキャッチアップしたいところです。
IstioやKnativeをそれぞれ触って理解を深めるのもいいかもしれません。
また、今回は中を見ずにapplyしたyamlファイルを覗いて9、どうあるべきかを掴むことも考えられます。

KFServingはじめKubeflowは機械学習環境を自力で自由に整えられるツールと理解しています。
好きなように整えられるのは魅力ですが、自由を謳歌するにはやはり技術力が必要ですね。
他方では、マネージドな機械学習環境として、SageMakerなどクラウドベンダー各社が提供するものを使うという選択肢もあります。

KFServingのyamlは非常にシンプルだったので、k8sの練度を上げて、今回の事象を解決したいところです。
読まれた方でピンときた方はコメントやTwitterで教えていただけると大変助かります。


  1. torchtext.datasets — torchtext 0.5.1 documentation

  2. PyTorchを使っての発見もいくつかあったのですが、それはまたの機会とします

  3. 今回デプロイするリソースはpredictorだけですが、pre-processorpost-processorをデプロイすることにより、KFServing側で前処理(例:テキストのテンソル化)や後処理(例:出力されたテンソルをクラスラベルに変換)ができそうです(前掲の101 Slidesより)

  4. https://github.com/kubeflow/kfserving/blob/8c261457b3ec8017736b882c4ffd3379914471ac/python/pytorchserver/pytorchserver/model.py#L57

  5. https://github.com/kubeflow/kfserving/blob/8c261457b3ec8017736b882c4ffd3379914471ac/python/pytorchserver/pytorchserver/model.py#L66

  6. この検証には Saving and Loading Models — PyTorch Tutorials 1.4.0 documentation が参考になりました。対話モードでモデルをロードし試行錯誤しました。

  7. もしかするとkubectl get inferenceservicesで確認するタイミングが早かったのかもしれません

  8. https://github.com/kubeflow/kfserving/blob/master/docs/DEVELOPER_GUIDE.md#troubleshooting

  9. GitHub - kubeflow/manifests: A repository for Kustomize manifests

週末ログ | GKEでKFServingを使ってPyTorchのモデルをサーブを試しハマりました(前編:GKE環境構築)

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
自然言語処理ネタで週1ブログは完結したので、気の向くまま不定期にブログを書いていきます。

今回は週末に手を動かしたことをまとめます。
うまくいっていない😢(悔しくて死にそう)のですが、記録に残します。
調査事項や工夫点を探して見返すことで次に繋げられたらと考えています。

目次

取り組んだこと

PyTorchのモデルをKFServingでサーブするサンプルに沿って手を動かしました(KFServingが何かは追って説明します)。

サーブする環境にはGCPを選択しました。
GCPのGKEを利用し、Kubernetes(以降、k8sクラスタを用意し、KFServingが使えるようにして進めています。

KFServingとは

KFServingとKubeflow

タイトルにあるKFServingは、k8s機械学習をするツールKubeflow1コンポーネント2の1つです(執筆時点でBeta版です)。
KFServingはその名の通りモデルのサーブ(=モデルをAPIにして予測結果を利用可能にする)に関係します3
複数の機械学習フレームワークのモデルに対応しており、今回はPyTorch製モデルのサーブを試しました。

Kubeflowの全容を掴むには、ドキュメント4にあった以下の図が分かりやすいです。
元の図:https://www.kubeflow.org/docs/images/kubeflow-overview-workflow-diagram-2.svg

f:id:nikkie-ftnext:20200412235349p:plain

この図によれば、KFServingの用途はProduction(本番環境)でのモデルのサーブです。

KFServingの依存要素

KFServingを動かすには、k8s以外にも必要なものが2つあります。
それが、

  • Knative、特にKnative Serving5(Knativeをざっくりいうとk8sでサーバレス?)
  • Istio(マイクロサービスにおけるサービスメッシュ?なるもの)

です6
ドキュメント7に分かりやすい図がありました。
元の図:https://www.kubeflow.org/docs/components/serving/kfserving.png

f:id:nikkie-ftnext:20200412235113p:plain

やったこと

依存要素を揃えてKFServingが使える環境を用意したあと、PyTorchのモデルを用意し、KFServingでサーブできるか手を動かしました。

  1. KFServingが使える環境を用意する
    1. GKE
    2. Knative
    3. Istio
    4. KFServing
  2. PyTorchのモデルを用意する
  3. KFServingでサーブを試す
    1. ローカルでサーブする
    2. GKEでサーブする

結論を言えば、3-2で動いていません。
1の環境構築に不備があったと考えています。

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G3020
$ gcloud version
Google Cloud SDK 288.0.0
beta 2019.05.17
bq 2.0.56
core 2020.04.03
gsutil 4.49
kubectl 2020.02.07
$ kubectl version  # gcloud components install kubectl で入ったもの(GKEのクラスタ作成後に確認)
Client Version: version.Info{Major:"1", Minor:"15", GitVersion:"v1.15.9", GitCommit:"2e808b7cb054ee242b68e62455323aa783991f03", GitTreeState:"clean", BuildDate:"2020-01-18T23:33:14Z", GoVersion:"go1.12.12", Compiler:"gc", Platform:"darwin/amd64"}
Server Version: version.Info{Major:"1", Minor:"15+", GitVersion:"v1.15.11-gke.5", GitCommit:"a5bf731ea129336a3cf32c3375317b3a626919d7", GitTreeState:"clean", BuildDate:"2020-03-31T02:49:49Z", GoVersion:"go1.12.17b4", Compiler:"gc", Platform:"linux/amd64"}

1. KFServingが使える環境を用意する

1-1 GKEでk8sクラスタを構築する

KnativeのドキュメントにあったGKEの環境構築を進めていきます。

  1. まずGCPのプロジェクトを作成
  2. 1のプロジェクトの中にGKEのクラスタを作成
$ gcloud components install kubectl
$ gcloud auth login

$ export CLUSTER_NAME=knative
$ export CLUSTER_ZONE=asia-northeast1-c  # 東京
$ export PROJECT=nikkie-knative-project

$ gcloud projects create $PROJECT --set-as-default

コマンドラインからプロジェクトを作りました。
gcloud config get-value core/projectで確認すると、作ったプロジェクトがデフォルトとして設定されています。

プロジェクトを作ったあとは請求先を設定する必要がありました8(請求先がないと続くコマンドがエラーになります)。

クラスタの作成です。

$ gcloud beta container clusters create $CLUSTER_NAME \
  --addons=HorizontalPodAutoscaling,HttpLoadBalancing \  # Istioを外した
  --machine-type=n1-standard-4 \
  --cluster-version=latest --zone=$CLUSTER_ZONE \
  --enable-stackdriver-kubernetes --enable-ip-alias \
  --enable-autoscaling --min-nodes=1 --max-nodes=10 \
  --enable-autorepair \
  --scopes cloud-platform

gcloudコマンドのbetaコンポーネントをインストールしていなかったため、インストールしてからクラスタ作成と進みました。

クラスタの作成コマンド9は、以下の記事(☆)を参照し、Istio addonを入れないように変更しています。

東京Zoneのn1-standard-4タイプのGCEインスタンスの料金は1時間あたり$0.2440です10💸

# gcloud beta container clusters create の出力の一部です
kubeconfig entry generated for knative.
NAME     LOCATION           MASTER_VERSION  MASTER_IP       MACHINE_TYPE   NODE_VERSION   NUM_NODES  STATUS
knative  asia-northeast1-c  1.15.11-gke.5   104.198.88.245  n1-standard-4  1.15.11-gke.5  3          RUNNING

$ kubectl create clusterrolebinding cluster-admin-binding --clusterrole=cluster-admin --user=$(gcloud config get-value core/account)

出力1行目にあるように、作成したクラスタへの接続情報が.kube/configkubectl--kubeconfig引数のデフォルトの値)に書かれています (なお、課金を止めるためにリソースは削除済みです)。

1-2 k8sクラスタにKnativeをインストール

k8s クラスタは v1.15以上なので、Knative v0.13.0 を入れるドキュメントにスイッチしました。
KFServingが依存する Knative Servingのみ インストールしています。

$ kubectl apply --filename https://github.com/knative/serving/releases/download/v0.13.0/serving-crds.yaml
$ kubectl apply --filename https://github.com/knative/serving/releases/download/v0.13.0/serving-core.yaml

CRD (Custom Resource Definition)からapplyする手順はこの後も頻出です。

1-3 k8sクラスタにIstioをインストール

1-2で参照したドキュメントの続きを進めます(3.Pick a networking layer の部分)。

Istioのインストールは、クラスタ作成時にIstio Addonを入れないようにしたMedium記事(☆)を参考に読み替えます11

$ export ISTIO_VERSION=1.4.6
$ kubectl apply -f https://raw.githubusercontent.com/knative/serving/master/third_party/istio-${ISTIO_VERSION}/istio-crds.yaml
$ kubectl apply -f https://raw.githubusercontent.com/knative/serving/master/third_party/istio-${ISTIO_VERSION}/istio-minimal.yaml

1-2で参照にしたドキュメントに戻って進めます。

$ kubectl apply --filename https://github.com/knative/serving/releases/download/v0.13.0/serving-istio.yaml

$ kubectl --namespace istio-system get service istio-ingressgateway
NAME                   TYPE           CLUSTER-IP   EXTERNAL-IP     PORT(S)                                      AGE
istio-ingressgateway   LoadBalancer   10.0.5.145   35.243.114.23   15020:32112/TCP,80:31674/TCP,443:30741/TCP   2m19s
$ kubectl --namespace knative-serving get pods
NAME                                READY   STATUS    RESTARTS   AGE
activator-869f6d4f9f-vht4h          1/1     Running   0          7m48s
autoscaler-78994c9fdf-jrvs4         1/1     Running   0          7m47s
controller-b94c5b667-cphpl          1/1     Running   0          7m47s
networking-istio-5847754959-9m2fc   1/1     Running   0          118s
webhook-7cdb467d79-zgfjx            1/1     Running   0          7m47s

1-4. k8sクラスタにKFServingをインストール

$ TAG=0.2.2
$ CONFIG_URI=https://raw.githubusercontent.com/kubeflow/kfserving/master/install/$TAG/kfserving.yaml
$ kubectl apply -f $CONFIG_URI

2. PyTorchでテキスト分類するモデルを用意

長くなってきたので、後編に分割して公開します。→公開しました

3. KFServingでサーブを試す

暗中模索

お片付け

感想


  1. Kubeflowの概要を掴むには、私が見た範囲では id:ymym3412 さんの Kubeflow Pipelinesで日本語テキスト分類の実験管理 - やむやむもやむなし が参考になりました。訓練まわり(実験管理)が簡単にできそうですね。訓練したモデルはKFServingなどでサーブできます。

  2. コンポーネント一覧:Components of Kubeflow | Kubeflow

  3. モデルをサーブするコンポーネントにはKFServingの他にSeldon Coreというものもあります。ref: Overview | Kubeflow

  4. Kubeflow Overview | Kubeflow

  5. Prerequisitesは Knative Serving (v0.8.0 +) と Istio (v1.1.7+) です。ref: KFServing | Kubeflow

  6. KnativeとIstioの説明はKFServing 101 slidesを参考にしていますが、理解が追いついておらずざっくりです

  7. https://www.kubeflow.org/docs/components/serving/kfserving/#install-with-kubeflow

  8. コンソールから操作します。ref: Create, modify, or close your Cloud Billing account  |  Google Cloud

  9. GCEインスタンスが3台できていたので、--num-nodes引数を指定するのがよさそうです。今回のようなちょっとした検証に3台というのはオーバースペックです

  10. https://cloud.google.com/compute/vm-instance-pricing?hl=ja#n1_standard_machine_types

  11. (☆)にあるように https://github.com/knative/serving/tree/master/third_party を見てバージョンを指定しました

transformersのTFBertModelを使ってテキストを特徴量に変換し、ロジスティック回帰、ランダムフォレスト、MLPで分類を試しました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。

そこで直近1クール(2020年3月末まで)は、自然言語処理のネタで毎週1本ブログを書くことにします。

今回で最終回を迎えます。
前回3/22の取り組みで「BERTの学習が遅いために、テキストから特徴量を作るのに使われる」ということを体感しました。
その続きとしてBERTで特徴量を作るのを試しました。

目次

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G3020
$ python -V  # venvによる仮想環境を利用
Python 3.7.3
$ pip list  # 手動で入れたものを抜粋して記載 (3/22の環境に追加)
ipython                  7.13.0
numpy                    1.18.1
scikit-learn             0.22.2.post1
tensorflow               2.1.0
transformers             2.5.1

作ったスクリプトたち

ファイル配置

.
├── bbc-text.csv  # 原データ(BBCニューステキスト。5カテゴリ)
├── bert_feature.py
├── bert-feature.csv
├── env  # 仮想環境
├── preprocess.py  # 前処理
├── preprocessed-bbc.csv
└── train_from_bert_feature.py

これらは以下の関係にあります。

  1. preprocess.pybbc-text.csvのテキストを前処理(トークン化)し、preprocessed-bbc.csvとして保存(詳しくは前回の記事を参照)
  2. bert_feature.pypreprocessed-bbc.csvのテキストを特徴量(小数値)に変換し、bert-feature.csvとして保存
  3. train_from_bert_feature.pybert-feature.csvを用いていくつかの分類器を学習

BERTで特徴量を作るにあたっての参考資料

やりたかったことに近かった以下の記事を参考にしました(「ツイートを文章ベクトルに変換する」の部分)。

記事ではPyTorchで実装されていますが、

  1. BertTokenizerでテキストをIDに変換1
  2. BertModel__call__を呼び出し、IDを変換し、文章ベクトルを取り出す

という手順になるようです。

ですが、BertTokenizerBertModelTensorFlowならTFBertModel)を使った他のコードを見てもいまいちピンとこず。。
そんな中で参考になったのが以下の記事(☆)。

DistilBERT(?)についての記事ですが、入出力についてはBERTにも該当するようです。
BERTで特徴量を作るには、BERTのTokenizerやモデルへの入出力の意味を掴むのが早道でした。
たくさんある図を参考にして手を動かしていきました。
記事(☆)に登場するコードの全容はこちら

1.transformersのサンプルコードを理解する

前回「動いた!」と喜んだサンプルコードですが、これが何をしているかの理解が必要でした。

In [1]: import tensorflow as tf

In [2]: from transformers import BertTokenizer, TFBertModel

In [3]: tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

In [4]: model = TFBertModel.from_pretrained('bert-base-uncased')

BertTokenizerを使ってテキストを対応するIDの並びに変える

BertTokenizerencodeメソッド2にテキスト(str)を渡すと、自然数からなるリストが返ります3

Converts a string in a sequence of ids (integer), using the tokenizer and vocabulary.

この自然数トークンに対応するIDです。
transformersのドキュメントではInput IDと呼ばれています。

In [5]: encoded = tokenizer.encode("Hello, my dog is cute", add_special_tokens=True)

In [6]: encoded
Out[6]: [101, 7592, 1010, 2026, 3899, 2003, 10140, 102]

encodeメソッドのadd_special_tokens=Trueという指定により、文頭や文末を表す[CLS][SEP]に対応するIDも付与されています。

TFBertModelにIDの並びを入力し、出力を得る

TFBertModel__call__メソッドを呼び出して、出力を得ます4

ここで、__call__メソッドへの入力はtf.Tensorにする必要があります。
encodeメソッドの出力を直接与えられません。
tf.constantで変換して渡す必要があります。

また、入力するtf.Tensorの形式は(batch_size, sequence_length)とする必要があります。
この例の場合は1つの文だけなので、batch_sizeが1、sequence_lengthlen(encoded)と同じ8になります。
(batch_size, sequence_length)という2次のテンソルにするために、tf.constant([encoded])という書き方5が必要でした。

In [7]: input_ids = tf.constant([encoded])

In [8]: input_ids.shape
Out[8]: TensorShape([1, 8])

In [10]: outputs = model(input_ids)

TFBertModelの出力から、特徴量を取り出す

__call__メソッドで得たoutputsですが、これは長さ2のtupleでした。

  • output[0]__call__メソッドのドキュメントによるとlast_hidden_state。形式は(batch_size, sequence_length, hidden_size)6
  • output[1]__call__メソッドのドキュメントによるとpooler_output。形式は(batch_size, hidden_size)

output[0](last_hidden_state)からテキストの特徴量が取り出せるようです7注意:(☆)の記事とは変数の対応を変えています)。

In [11]: last_hidden_states = outputs[0]

In [12]: last_hidden_states.shape
Out[12]: TensorShape([1, 8, 768])

(☆)の記事では、文頭を表す[CLS]を表現したテンソルに興味があるとのことなので、それにならって特徴量を取り出します8(理由が腑に落ちていないので深堀りたいところです)。
numpyメソッドでnumpy.ndarrayとして取り出せます。

In [9]: last_hidden_states[:, 0, :].numpy().shape
Out[9]: (1, 768)

こうしてtransformersのサンプルコードの場合は、どのようにすればBERTを使って特徴量が取り出せるのかが分かりました。

なお、以下のIssueも参考になりました。
word or sentence embedding from BERT model · Issue #1950 · huggingface/transformers · GitHub

2.BERTを使ってBBCニュースのテキストを特徴量に変換する

前回使ったBBCニュースのテキスト(2225件)をBERTで特徴量(小数からなるテンソル)に変換します。
コード(bert_feature.py)はこちら:

import csv

import tensorflow as tf
from transformers import BertTokenizer, TFBertModel


tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = TFBertModel.from_pretrained('bert-base-uncased')

categories = ['tech', 'business', 'sport', 'entertainment', 'politics']
category_to_id = {
    category: index for index, category in enumerate(categories)
}
with open('preprocessed-bbc.csv') as fin:
    reader = csv.reader(fin)
    texts_by_ids = []
    category_ids = []
    for text, category in reader:
        texts_by_ids.append(tokenizer.encode(text, max_length=512))
        category_ids.append(category_to_id[category])

max_len = 0
for input_id_list in texts_by_ids:
    if len(input_id_list) > max_len:
        max_len = len(input_id_list)
padded_texts_by_ids = [
    input_id_list + [0]*(max_len-len(input_id_list))  # 0 padding (テキスト長さ揃える)
    for input_id_list in texts_by_ids]

with open('bert-feature.csv', 'a') as fout:
    writer = csv.writer(fout)
    for text_by_ids, category_id in zip(padded_texts_by_ids, category_ids):
        input_ids = tf.constant([text_by_ids])
        output = model(input_ids)
        last_hidden_states = output[0]
        feature = last_hidden_states[:, 0, :].numpy()  # [CLS]についての全重み
        writer.writerow(list(feature[0]) + [category_id])

このコードにより、ニュース1つ1つを768の数値からなる1次のテンソルに変換できました。
達成する中でつまづいたのは以下です。

  • encodeメソッドにmax_length=512と指定する必要があった
    • BertConfigのデフォルト値がモデルに渡っているらしい
    • model.configという辞書を確認したところ、'max_position_embeddings'の値は512だった
    • encodeメソッドのmax_length指定により、512語を超えるテキストでもIDに変換されるトークンは512に揃う(先頭から512語が使われている?)
  • 2225件のテキストを一度にテンソルに変換しようとしたところ、メモリが足りなくなって落ちた(Killedの表示)
    • 1件ずつ変換してCSVファイルに書き込む方法に変更(writer.writerow(list(feature[0]) + [category_id])
    • featureは2次のテンソルのため、[0]指定が必要
    • 1行だけ作ったところ10KB程度(768個のfloat32)。2000件のテキストでは20MB程度のCSVになります
    • 30分くらいかかったように思います(休憩していました)

3.BERTで作った特徴量をもとに分類器を作成

以下のアルゴリズムを試します:

  • sklearn.LogisticRegression
  • sklearn.RandomForestClassifier
  • MLPtf.keras.Sequentialで実装)

Accuracyを比較しました:

Accuracy of LogisticRegression: 0.9285393258426966
Accuracy of RandomForestClassifier: 0.835505617977528
Epoch 1/30
1780/1780 [==============================] - 0s 227us/sample - loss: 1.5421 - accuracy: 0.3444 - val_loss: 1.2829 - val_accuracy: 0.5685
: (略)
Epoch 30/30
1780/1780 [==============================] - 0s 44us/sample - loss: 0.2793 - accuracy: 0.9084 - val_loss: 0.2423 - val_accuracy: 0.9438

RandomForestClassifierよりLogisticRegressionとMLPのAccuracyが高いという結果になりました。
MLPは、validationデータのAccuracyの方が高いため、30epochでは学習不足なようです(epochを50まで増やしたところ学習不足は変わりませんでした。データが少ないため?)

import csv

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
import tensorflow as tf
from tensorflow.keras import layers


with open('bert-feature.csv') as fin:
    reader = csv.reader(fin)
    rows = [row for row in reader]

features = [list(map(float, row[:-1])) for row in rows]
X = np.array(features)
category_ids = [int(row[-1]) for row in rows]
y = np.array(category_ids)

lr = LogisticRegression(
    multi_class='multinomial', solver='saga', max_iter=100
)
rf = RandomForestClassifier()

lr_scores = cross_val_score(lr, X, y, cv=5, scoring='accuracy')
print(f'Accuracy of LogisticRegression: {lr_scores.mean()}')

rf_scores = cross_val_score(rf, X, y, cv=5, scoring='accuracy')
print(f'Accuracy of RandomForestClassifier: {rf_scores.mean()}')

# ref: https://qiita.com/ftnext/items/ff9e08e4686d76eddd40
number_of_classes = 5
y = tf.keras.utils.to_categorical(y, number_of_classes)
model = tf.keras.Sequential(
    [
        layers.Dense(128, input_shape=(768,), activation=tf.nn.relu),
        layers.Dropout(0.5),
        layers.Dense(number_of_classes, activation=tf.nn.softmax),
    ]
)
model.compile(
    loss="categorical_crossentropy",
    optimizer=tf.keras.optimizers.Adam(),
    metrics=["accuracy"],
)
history = model.fit(
    X,
    y,
    batch_size=32,
    epochs=30,
    verbose=1,
    validation_split=0.2,
)

感想

前回にネタとして挙げた「BERTで特徴量を作ってニューラルネットワークを学習」を達成できました!
BERTで特徴量を作るのにも時間はかかりましたが、一度作って保存しておけば色々なモデルで試せるんですね。
これが「初手BERT時代」。。
現在の状況のキャッチアップに少し手がかかってよかったです🤗(まだまだ高い崖がそびえていますが「これからこれから」ですね)

世はまさにPyTorch時代といった感じで、TensorFlowからBERTを使う情報は少ない印象です。
ですが、手を動かす中で、「特徴量作成は結果をファイルに保存するわけだからTensorFlowでなくてもいい、つまり、情報の多いPyTorchでやってもいい」という気づきを得ました。

自然言語処理ネタで週1ブログはこれで終わりです。
この試みはとてもよくて継続したいのはやまやまなのですが、次のクールは別のことを優先する予定です。
取り組みから離れる前に、できたこと、できなかったことをはっきりさせたいので、振り返り記事を予定しています。


  1. encodeメソッドのドキュメントに「Same as doing self.convert_tokens_to_ids(self.tokenize(text))」という記載を見つけ、腑に落ちました。

  2. BertTokenizerPreTrainedTokenizerを継承しており、encodeメソッドはPreTrainedTokenizerに定義されています。

  3. (☆)の図 https://jalammar.github.io/images/distilBERT/bert-distilbert-tokenization-2-token-ids.png が分かりやすいです

  4. (☆)の図 https://jalammar.github.io/images/distilBERT/bert-model-input-output-1.png が分かりやすいです

  5. transformersのドキュメントでは、2次のテンソルにするために [None, :] というインデックス指定がされているようです(この書き方についてドキュメントで裏付けが取れていません)。tf.constantのドキュメントを確認し、2次元配列(リストを要素とするリスト)を渡せば、2次のテンソルとなることが分かりました(a = np.array([[1, 2, 3], [4, 5, 6]]))。

  6. サンプルコードは一文だけのため、sequence_lengthは文に含まれるトークンの数と等しくなります。複数の文がある場合は各文でトークンの数を揃えるために、パディング埋めする必要があるようです。

  7. (☆)の図 https://jalammar.github.io/images/distilBERT/bert-output-tensor.png

  8. (☆)の図 https://jalammar.github.io/images/distilBERT/bert-output-tensor-selection.png