nikkie-ftnextの日記

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

tf.dataを使って英文テキストを読み込み、分類するモデルを作るTensorFlowのチュートリアルに取り組みました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はTensorFlowにおける新しめのデータの扱い方チュートリアルに取り組みました。

チュートリアル「tf.data を使ったテキストの読み込み」

問題設定は、英文テキストの多クラス分類です。

  • データ:ホメロス(ホーマー)の『イリアス』(『イリアッド』)の英語翻訳版3通り
    • cowper, derby, butler
  • 推論:与えられたテキストがどの翻訳版のものか

以下の手順で進みました。

  1. テキストのダウンロード
  2. テキストをデータセットに読み込む
  3. テキスト行を数字にエンコードする
  4. データセットを、テスト用と訓練用のバッチに分割する(ハマりました😢)
  5. モデルの構築・訓練

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ # 注:コードを動かすのにGPUは使っていません
$ python -V  # venvによる仮想環境を使用
Python 3.7.3
$ pip list  # grepを使って抜粋して表示
tensorflow               2.1.0
tensorflow-datasets      1.3.2
matplotlib               3.1.2
  • ipythonのコードでセルの番号が前後するのは、ブログを書くに当たり実行したコードがあるためです
  • 乱数のシードを固定せずに実施しました。再現性を担保する場合、numpyのシードの固定やtensorflow.random.set_seedshuffleメソッドのseed引数を指定することになると考えています(未実施)

そもそも、tf.dataとは

2019年10月に行ったPyCon SingaporeのTensorFlow 2.0 チュートリアルtf.dataについてインプットしていました1

チュートリアルによると、tf.dataの目的は、深層学習に取り組む上でCPUとGPUが交互に遊んでしまう(idle)状態の解決とのことでした。

  • tf.dataを使わない場合
    • CPUでデータの準備をする間、GPUは遊ぶ
    • GPUで学習をする間、CPUは遊ぶ
    • これが交互に繰り返される
  • tf.dataを使う場合
    • CPUでのデータの準備とGPUでの学習を同時に進める(パイプライン

データをtf.data.Datasetとして扱うことで、CPUとGPUが交互に遊ぶ状態は解決されます。
さらにデータの扱い方(インターフェース)が揃うのもメリットだと考えます。

1. テキストのダウンロード

tf.keras.utils.get_fileを用いて、テキスト(『イリアス』の3種の英語翻訳)のURLを指定し、ダウンロードします。

In [4]: DIRECTORY_URL = 'https://storage.googleapis.com/download.tensorflow.org/data/illiad/'

In [5]: FILE_NAMES = ['cowper.txt', 'derby.txt', 'butler.txt']

In [6]: for name in FILE_NAMES:
   ...:     text_dir = tf.keras.utils.get_file(name, origin=DIRECTORY_URL+name)
   ...:
  • 挙動:データがcacheにないため、ダウンロードする
    • 必須引数fnamexxx.txt
    • 必須引数origin:テキストのURL
    • データのcache先はcache_dir引数とcache_subdir引数で指定
    • 今回はどちらもデフォルト値(cache_dir引数がNonecache_subdir引数が'datasets'
    • $HOME/.keras/datasets/2 にダウンロードされる
  • 返り値:ダウンロードされたファイルを指すパス(手元のマシンの中のパス)
    • 上記のcacheにダウンロードされたことを確認できます

このチュートリアルで使われているテキストファイルは、ヘッダ、フッタ、行番号、章のタイトルの削除など、いくつかの典型的な前処理を行ったものです。

との記載から、テキストの前処理はtf.dataを使う前に行うようです(前処理してbutler.txtなどと同じ形式(単語の半角スペース区切り)にする想定)。

$ head -n3 .keras/datasets/butler.txt  # 注:カレントディレクトリはホームディレクトリ
Sing, O goddess, the anger of Achilles son of Peleus, that brought
countless ills upon the Achaeans. Many a brave soul did it send
hurrying down to Hades, and many a hero did it yield a prey to dogs and
$ wc -l .keras/datasets/*.txt
   12131 .keras/datasets/butler.txt  # 24%
   19142 .keras/datasets/cowper.txt  # 39%
   18333 .keras/datasets/derby.txt  # 37%
   49606 total

1では、まだtf.dataは使っていません。

2. テキストをデータセットに読み込む

テキストは、tf.data.TextLineDataset3として読み込みます。

TextLineDataset は、テキストファイルからデータセットを作成するために設計されています。この中では、元のテキストファイルの一行一行がサンプルです。(チュートリアル冒頭より)

In [9]: def labeler(example, index):
   ...:     return example, tf.cast(index, tf.int64)
   ...:

In [10]: labeled_data_sets = []

In [11]: for i, file_name in enumerate(FILE_NAMES):
    ...:     lines_dataset = tf.data.TextLineDataset(os.path.join(parent_dir, file_name))
    ...:     labeled_dataset = lines_dataset.map(lambda ex: labeler(ex, i))
    ...:     labeled_data_sets.append(labeled_dataset)
    ...:

必須引数filenamesを指定してTextLineDatasetを作成します(for文の節の1行目)。

テキストファイルの1行1行についてラベルを付けます(for文の節の2行目)。
ラベルと翻訳の対応は以下の通りです。

ラベル 翻訳
0 cowper
1 derby
2 butler

(リストFILE_NAMESにおけるインデックスがラベルです)

TextLineDataset.mapを使って、lines_datasetの1件1件について、labeler関数を適用します。
labeler関数はラベルの型tf.int64変換します。

labeled_data_sets

  • [0]:cowperによる翻訳テキスト
  • [1]:derbyによる翻訳テキスト
  • [2]:butlerによる翻訳テキストの順に並んでいます。
In [12]: len(labeled_data_sets)
Out[12]: 3

クラスごとに分かれているので、TextLineDataset.concatenateで一続きのDataset(all_labeled_data)になるように繋げます(concatenateした結果でall_labeled_dataを更新して繋げていく)。

In [16]: all_labeled_data = labeled_data_sets[0]

In [17]: for labeled_dataset in labeled_data_sets[1:]:
    ...:     all_labeled_data = all_labeled_data.concatenate(labeled_dataset)
    ...:

繋げた状態だとラベルが0→1→2と順番に並んでいるので、TextLineDataset.shuffleして並べ替えます。

In [23]: all_labeled_data = all_labeled_data.shuffle(
    ...:     BUFFER_SIZE, reshuffle_each_iteration=False)

並べ替えた後、all_labeled_data先頭3件を見ます。

In [18]: for ex in all_labeled_data.take(3):
    ...:     print(ex)
    ...:
(<tf.Tensor: shape=(), dtype=string, numpy=b'when his true comrade fell at the hands of the Trojans, and he now lies'>, <tf.Tensor: shape=(), dtype=int64, numpy=2>)
(<tf.Tensor: shape=(), dtype=string, numpy=b"On his broad palm, and darkness veil'd his eyes.">, <tf.Tensor: shape=(), dtype=int64, numpy=0>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'All-bounteous Mercury clandestine there'>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)

TextLineDataset.takeの返り値(Dataset)から取り出したextupleでした。
tupleのインデックス1の要素が、ラベルを表しています(tf.Tensorオブジェクトなるもの)。
ラベルの値(numpy)を見ると、先頭のテキストのラベルが2なので、シャッフルされていますね。

In [23]: type(ex)
Out[23]: tuple

In [26]: ex[0]  # テキストを表す
Out[26]: <tf.Tensor: shape=(), dtype=string, numpy=b'All-bounteous Mercury clandestine there'>

In [27]: ex[1]  # ラベルを表す
Out[27]: <tf.Tensor: shape=(), dtype=int64, numpy=0>

ここでは、テキスト行にクラスを表すラベルを付け、1つのDatasetとしてまとめることをしました。

3. テキスト行を数字にエンコードする

機械学習モデルが扱うのは単語ではなくて数字であるため、文字列は数字のリストに変換する必要があります。

ロイター通信のデータセットのように4、整数の並びにするということですね。
以下の2ステップでした。

  1. ボキャブラリーの構築
  2. テキストのエンコード

ボキャブラリーの構築

tfds.features.text.Tokenizerを使って、テキストに含まれる単語を取り出します。

In [28]: sample_tokenizer = tfds.features.text.Tokenizer()

In [29]: ex[0].numpy()
Out[29]: b'All-bounteous Mercury clandestine there'

In [30]: sample_tokenizer.tokenize(ex[0].numpy())
Out[30]: ['All', 'bounteous', 'Mercury', 'clandestine', 'there']

TextLineDatasetの1件1件(タプル)では、インデックス0がテキストを表します。
テキスト自体はnumpy()メソッドで取得できます。
これをTokenizer.tokenizeに与えると、単語を格納したリストが返ります。

全てのテキストについて上記の処理を繰り返し、入手した単語をボキャブラリーとします。
tokenizeの返り値をセットに入れることで、重複を除きます。

In [32]: tokenizer = tfds.features.text.Tokenizer()

In [33]: vocabulary_set = set()

In [34]: for text_tensor, _ in all_labeled_data:
    ...:     some_tokens = tokenizer.tokenize(text_tensor.numpy())
    ...:     vocabulary_set.update(some_tokens)
    ...:

In [35]: vocab_size = len(vocabulary_set)

In [36]: vocab_size
Out[36]: 17178

日本語テキストを扱う場合は、Tokenizer初期化時にalphanum_only引数をFalseに指定することになりそうです。

テキストのエンコード

vocabulary_settfds.features.text.TokenTextEncoder に渡してエンコーダーを作成します。

In [37]: encoder = tfds.features.text.TokenTextEncoder(vocabulary_set)

エンコーダTokenTextEncoderは、

  • 必須引数vocab_listを渡して初期化
  • encodeメソッドにテキストを渡すと、変換した整数のリスト(a list of integers)を返す

これを使って、all_labeled_dataの各テキストを、整数が並んだリストに置き換えます。

In [43]: def encode(text_tensor, label):
    ...:     encoded_text = encoder.encode(text_tensor.numpy())
    ...:     return encoded_text, label
    ...:

In [44]: def encode_map_fn(text, label):
    ...:     encoded_text, label = tf.py_function(
    ...:         encode, inp=[text, label], Tout=(tf.int64, tf.int64))
    ...:     encoded_text.set_shape([None])
    ...:     label.set_shape([])
    ...:     return encoded_text, label
    ...:

In [45]: all_encoded_data = all_labeled_data.map(encode_map_fn)

tf.py_functionについては今回掘り下げられていないのですが、ドキュメントの中に

tf.function が呼び出されるたびに Python のコードを実行したいのであれば、tf.py_function がぴったりです。

という記載を見つけました5
all_labeled_dataの1件1件にmapメソッドを介してencode関数を適用するために使っているようです。
set_shapeのあたりは言語化できるレベルで理解できていないのですが、tf.functionとの関係の中でとらえるとよさそうです。

エンコードしたテキストの確認

In [40]: for ex in all_encoded_data.take(3):
    ...:     print(ex)
    ...:
(<tf.Tensor: shape=(15,), dtype=int64, numpy=
array([ 7516,  2349, 11186, 13393,  4276,  1931,  1495,  8889, 16159,
        1495,  6530,   423,  5728,  1429, 12401])>, <tf.Tensor: shape=(), dtype=int64, numpy=2>)
(<tf.Tensor: shape=(10,), dtype=int64, numpy=
array([ 4287,  2349,   384,  3282,   423,  8741, 14597,  2713,  2349,
        5192])>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)
(<tf.Tensor: shape=(5,), dtype=int64, numpy=array([14260, 10012,  1468,   907,  9474])>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)

4. データセットを、テスト用と訓練用のバッチに分割する

小さなテスト用データセットと、より大きな訓練用セットを作成します。

  • テスト用データはTextLineDataset.takeで先頭から取り出す
  • 訓練用データはTextLineDataset.skipで、先頭からテスト用データを避けて取り出す

訓練用データとテスト用データを「バッチ化」します。
先ほど見たように、テキストを変換した整数のリストの長さは異なります(元のテキストの単語数が異なるため)。
バッチ化にあたり、短いリストには0を埋めて、同じサイズにします。

ハマった:TextLineDataset.padded_batch

チュートリアルのコードのとおりに実行すると、必須引数2つが指定されていないためにエラーが発生します。

In [41]: train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [42]: train_data = train_data.padded_batch(BATCH_SIZE)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-42-8afff0f1d82a> in <module>
----> 1 train_data = train_data.padded_batch(BATCH_SIZE)

TypeError: padded_batch() missing 1 required positional argument: 'padded_shapes'

padded_shapes引数について

A nested structure of tf.TensorShape or tf.int64 vector tensor-like objects representing the shape to which the respective component of each input element should be padded prior to batching. Any unknown dimensions (e.g. tf.compat.v1.Dimension(None) in a tf.TensorShape or-1 in a tensor-like object) will be padded to the maximum size of that dimension in each batch.

解決の参考になったのは、以下のIssueのコード:

output_shapes_train = tf.compat.v1.data.get_output_shapes(ds_train)

tf.compat.v1.data.get_output_shapesall_encoded_dataのoutput shapesを取得し、padded_batchpadded_shapes引数に渡します。

In [45]: output_shapes = tf.compat.v1.data.get_output_shapes(all_encoded_data)

In [46]: output_shapes
Out[46]: (TensorShape([None]), TensorShape([]))

In [47]: train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)

train_dataのバッチのうち、先頭1件を確認します。

In [52]: for sample_text, sample_label in train_data.take(1):
    ...:     print(sample_text)
    ...:     print('-' * 40)
    ...:     print(sample_label)
    ...:
tf.Tensor(
[[ 9310   722 10783 ...     0     0     0]
 [ 5766 12211 13137 ...     0     0     0]
 [ 2584 10652  4521 ...     0     0     0]
 ...
 [ 7497  8881  8781 ...     0     0     0]
 [ 6450  7516  1495 ...     0     0     0]
 [ 1785  2713 15027 ...     0     0     0]], shape=(64, 17), dtype=int64)
----------------------------------------
tf.Tensor(
[1 0 0 0 2 2 2 1 0 0 0 0 2 2 0 0 0 0 2 1 1 0 1 0 1 1 1 0 1 1 1 1 1 2 2 0 2
 0 1 0 2 2 2 2 1 2 1 0 2 1 0 0 0 0 0 2 1 1 0 1 0 2 0 0], shape=(64,), dtype=int64)

sample_textの整数の並びは終わりが0となって、長さが揃っていますね。
BATCH_SIZEでまとまっています(これがバッチ化という認識です)。

テスト用データも同様にバッチ化します。

In [55]: test_data = all_encoded_data.take(TAKE_SIZE)

In [56]: test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)

0という新しい番号で埋めたので、ボキャブラリーサイズを1増やします。

In [57]: vocab_size += 1

5. モデルの構築・訓練

チュートリアルではテスト用データを訓練中のバリデーションに使っているのが気になったので、テスト用・バリデーション用・訓練用に分けました。

In [82]: test_data = all_encoded_data.take(TAKE_SIZE)

In [83]: test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)

In [84]: train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [85]: val_data = train_data.take(TAKE_SIZE)

In [86]: val_data = val_data.padded_batch(BATCH_SIZE, output_shapes)

In [87]: train_data = train_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [88]: train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)

チュートリアルに沿ったモデルとします。

In [89]: model = tf.keras.Sequential(
    ...:     [
    ...:         tf.keras.layers.Embedding(vocab_size, 64),
    ...:         tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(64)),
    ...:         tf.keras.layers.Dense(64, activation='relu'),
    ...:         tf.keras.layers.Dense(64, activation='relu'),
    ...:         tf.keras.layers.Dense(3, activation='softmax')
    ...:     ]
    ...: )
  • Embedding layer
  • LSTM
  • Dense 1
  • Dense 2
  • Dense 3(softmaxをとって、3クラスのどれかに分類)
In [90]: model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
    ...:  metrics=['accuracy'])

In [91]: history = model.fit(train_data, epochs=3, validation_data=val_data)
Epoch 1/3
    619/Unknown - 21s 33ms/step - loss: 0.5528 - accuracy: 0.72992020-01-19 13:15:39.947590: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
2020-01-19 13:15:48.626987: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 29s 47ms/step - loss: 0.5528 - accuracy: 0.7299 - val_loss: 0.3283 - val_accuracy: 0.8696
Epoch 2/3
617/619 [============================>.] - ETA: 0s - loss: 0.3209 - accuracy: 0.85992020-01-19 13:16:15.284238: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 27s 43ms/step - loss: 0.3207 - accuracy: 0.8599 - val_loss: 0.2153 - val_accuracy: 0.9172
Epoch 3/3
615/619 [============================>.] - ETA: 0s - loss: 0.2440 - accuracy: 0.89502020-01-19 13:16:41.786026: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 27s 43ms/step - loss: 0.2438 - accuracy: 0.8951 - val_loss: 0.1813 - val_accuracy: 0.9298

テスト用データに対しての正解率は84.0%でした(チュートリアル通り)

In [92]: eval_loss, eval_acc = model.evaluate(test_data)
     79/Unknown - 2s 23ms/step - loss: 0.3762 - accuracy: 0.83962020-01-19 13:21:13.484423: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
     79/Unknown - 2s 23ms/step - loss: 0.3762 - accuracy: 0.8396
In [93]: f'Eval loss: {eval_loss}, Eval accuracy: {eval_acc}'
Out[93]: 'Eval loss: 0.37618066295038294, Eval accuracy: 0.8396000266075134'

過去に作った plot_accuracy 関数6を使って、学習状況を可視化します。

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

訓練用データにおける正解率がバリデーション用データにおける正解率を下回っているため、学習不足という印象です。
epochsはもう少し増やせそうです。

感想

初めてしっかり触ったtf.data
この取り組みの中ではこれまで、tf.keras.preprocessing.text.Tokenizerでテキストをnumpyのarrayに変換して扱いました。
tf.dataにはTensortf.functionなど関係するものが多くまだ掴みきれていないのですが、データの扱いが揃うのは便利そうなので、引き続き触っていきたいです(読めるコードも増えそうですし)。
kerasのTokenizerとtfdsのTokenizer, TokenTextEncoderは役割がかぶっている印象ですが、どちらを使ったほうがいいという指針があるのか、私、気になります!

学んだこと

  • TensorFlowだけの利用で英語のテキストを数値にエンコードする方法
  • テキストを扱うときに有効と聞くEmbeddingやLSTMのlayerの使い方(の初めの一歩)

Future Works(今後のネタ帳)


  1. スライド中の図はドキュメントから更新されたようです。ドキュメント内の説明はこちら:Better performance with the tf.data API  |  TensorFlow Core

  2. get_fileのドキュメントより「By default the file at the url origin is downloaded to the cache_dir ~/.keras, placed in the cache_subdir datasets, and given the filename fname.」

  3. issubclass(tf.data.TextLineDataset, tf.data.Dataset)がTrueなので、tf.data.TextLineDatasettf.data.Datasetのサブクラスです(tf.data.Datasetと同様にパイプラインでデータを扱えるということです)ref:ドキュメント 組み込み関数issubclass

  4. [前処理編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita

  5. ref: tf.function で性能アップ  |  TensorFlow Core

  6. ref: [モデル構築編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita

「Janome ではじめるテキストマイニング」の中のWordCloudのチュートリアルに取り組み、janomeを全然使いこなせていなかったと思い知りました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週は、1本目のブログで作ったWordCloudに使っているjanomeについて、チュートリアルに取り組んでの学びをまとめます。

前回までのnikkieとjanome

過去にjanome形態素解析に使って、CfPや自分のブログのWordCloudを作っています。

その中でjanomeのドキュメントを見たところ、形態素解析以外に前処理・後処理もできるらしいと分かり1janomeチュートリアルをブログ駆動開発のネタリストに入れていました。

今回チュートリアルに取り組んでみて、「わたし、janomeのこと、なんにも分かってなかったんだ。。」という心境です。

janomeチュートリアル

取り組んだのは「Janome ではじめるテキストマイニング」です。

Google Colaboratoryでもできますが、今回は手元のマシンで実行しました。
hands-onフォルダのnotebookのうち、01〜03を写経しました。
宮沢賢治の『風の又三郎』のWordCloudを作ります。

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

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V  # venvによる仮想環境を利用
Python 3.8.1
$ janome --version
janome 0.3.10
$ pip list | grep wordcloud
wordcloud        1.6.0
$ pip list | grep matplotlib
matplotlib       3.1.2

フォルダ配置

Pythonスクリプトもカレントディレクトリに置いていますが、配置から省略しています。

.
├── data
│   └── kazeno_matasaburo_utf8.txt
├── env  # 仮想環境
├── ipagp.ttf
├── stop_words.txt  # ストップワードを列挙したファイル
├── text_sentences.txt
└── udic.csv  # ユーザ定義辞書
  • WordCloudに日本語を表示するためのIPAフォントは、こちらからダウンロード:IPAフォントのダウンロード
  • テキストデータの入手:wget https://raw.githubusercontent.com/mocobeta/janome-tutorial/master/hands-on/data/kazeno_matasaburo_utf8.txt -O data/kazeno_matasaburo_utf8.txt

(目次) チュートリアルに取り組んでの学び

以下の順で学んだことや気づいたことを記していきます。

  1. コマンドラインからjanomeコマンドが使えた
  2. Tokenizerを使った形態素解析(と分かち書き
  3. Pythonicなファイル操作
  4. Analyzerを使った形態素解析 + 前処理・後処理
  5. WordCloud(小ネタ)
  6. チュートリアルのコードで気になる点

janomeコマンド

初めて知ったのですが、pip install janomeすると、janomeコマンドが使えるようになります。
オプション無しでjanomeコマンドを実行した後は、形態素解析したい一文を入力してEnterを押すと結果が出ます。

$ janome
すもももももももものうち
すもも   名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
うち  名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ

パイプを使ってjanomeコマンドを呼び出すこともできます。

$ echo "テキストマイニングを始めよう" | janome
テキスト    名詞,一般,*,*,*,*,テキスト,テキスト,テキスト
マイニング 名詞,サ変接続,*,*,*,*,マイニング,マイニング,マイニング
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
始めよ   動詞,自立,*,*,一段,未然ウ接続,始める,ハジメヨ,ハジメヨ
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

janomeコマンドの主なオプションは次の通り(janome -hで確認できます)。

  • --udic:ユーザ定義辞書ファイルのパスの指定
  • --udic-type:ユーザ定義辞書のタイプの指定(デフォルトはipadic。simpledicも取れる)
  • -g:「ラティスグラフ」のpngファイルを出力(要graphviz2

ユーザ定義辞書を使って形態素解析し、ラティスグラフを出力する例

In [23]: !echo '美ら海図面コンクール' | janome --udic udic.csv --udic-type simpledic -g
美ら海   カスタム名詞,*,*,*,*,*,美ら海,チュラウミ,チュラウミ
図面  名詞,一般,*,*,*,*,図面,ズメン,ズメン
コンクール 名詞,一般,*,*,*,*,コンクール,コンクール,コンクール
Graph was successfully output to lattice.gv.png
$ cat udic.csv
美ら海,カスタム名詞,チュラウミ

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

Tokenizer

コマンドラインからの使い方を知った後はAPIについて。
使ったことのあるTokenizerですが、ドキュメントをたどりつつ見ていきます。

In [1]: from janome.tokenizer import Tokenizer

In [2]: t = Tokenizer()

In [9]: for token in t.tokenize('テキストマイニングを始めよう'):
   ...:     print(token)
   ...:
テキスト    名詞,一般,*,*,*,*,テキスト,テキスト,テキスト
マイニング 名詞,サ変接続,*,*,*,*,マイニング,マイニング,マイニング
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
始めよ   動詞,自立,*,*,一段,未然ウ接続,始める,ハジメヨ,ハジメヨ
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

Tokenizer.tokenizeですが、デフォルトではstream引数とwakati引数がどちらもFalseなので、Tokenのリスト3が返ります。
(上記のコードは、t.tokenize('テキストマイニングを始めよう')の返すリストの各要素について処理をしています)

では、Tokenとは何でしょうか。
手を動かした感触としては、「(解析された)形態素と辞書に登録された情報を表すもの」になるかと思います(ドキュメント

In [10]: token = t.tokenize('テキストマイニングを始めよう')[3]  # 「始めよ」を取り出す
In [11]: token.base_form, token.infl_form, token.infl_type, token.part_of_speech
    ...: , token.phonetic, token.reading, token.surface
Out[11]: ('始める', '未然ウ接続', '一段', '動詞,自立,*,*', 'ハジメヨ', 'ハジメヨ', '始めよ')

print(token)をしたとき、以下の対応で表示されています4

始めよ 動詞,自立,,,一段,未然ウ接続,始める,ハジメヨ,ハジメ

  • token.surface表層形 '始めよ'
  • token.part_of_speech:品詞 '動詞,自立,*,*'
  • token.infl_type:活用型 '一段'
  • token.infl_form:活用形 '未然ウ接続'
  • token.base_form基本形 '始める'
  • token.reading:読み 'ハジメヨ'
  • token.phonetic:発音 'ハジメヨ'

基本形の取り出し方

In [17]: for token in t.tokenize('テキストマイニングを始めました'):
    ...:     print(token.base_form)
    ...:
テキスト
マイニング
を
始める
ます
た

表層系の取り出し方

In [16]: for token in t.tokenize('テキストマイニングを始めよう'):
    ...:     print(token.surface)
    ...:
テキスト
マイニング
を
始めよ
う

またtokenizeメソッドのwakati引数をTrueに指定5することで、表層形(str型)のリストが返りtoken.surfaceというアクセスは不要になります。
すごくシンプルに書けるので、今回知ってよかったです。

In [2]: t.tokenize('テキストマイニングを始めよう', wakati=True)
Out[2]: ['テキスト', 'マイニング', 'を', '始めよ', 'う']

補足ですが、Tokenizerを作るときに、ユーザ定義辞書を指定できます。

In [19]: t = Tokenizer('udic.csv', udic_type='simpledic')

In [20]: for token in t.tokenize('美ら海図面コンクール'):
    ...:     print(token)
    ...:
美ら海   カスタム名詞,*,*,*,*,*,美ら海,チュラウミ,チュラウミ
図面  名詞,一般,*,*,*,*,図面,ズメン,ズメン
コンクール 名詞,一般,*,*,*,*,コンクール,コンクール,コンクール

Pythonicなファイルの扱い

少しjanomeから離れてPythonの話を。

組み込み関数openのencoding引数

チュートリアルではencoding引数を'utf8'と指定しています。
デフォルト値はNoneで、指定しないとプラットフォームに依存するそうです(ドキュメント)。
チュートリアルの進行でエラーを出さないように指定しているのだと思います。
なおこの引数は、ファイルをテキストモードで扱う際に有効とのことです(バイナリでは無効と理解)。

openしたファイルの扱い

1行ずつ取り出す方法、for inでいけるんですね!

$ cat text_sentences.txt
すもももももももものうち
テキストマイニングを始めよう
In [4]: with open('text_sentences.txt', encoding='utf8') as f:
   ...:     for line in f:
   ...:         print(repr(line))
   ...:
'すもももももももものうち\n'
'テキストマイニングを始めよう\n'

右端の改行コードはstrip()などで削除できます。

f.read()したものをsplit()して」と処理していったら、改行以外の空白文字(全角スペース)でも分割してしまって想定外の出力になりました(ドキュメント)。
f.read()したあとは、split('\n')ですね6

改行文字を形態素解析しても問題ない!

janomeの話題に戻ります。
上記のfor inで1行取り出す書き方も衝撃でしたが、それを上回る衝撃が。
それはf.read()した文字列(改行文字含む)をtokenizeに渡すコード!

In [7]: with open('text_sentences.txt', encoding='utf8') as f:
   ...:     text = f.read()
   ...:     for token in t.tokenize(text):
   ...:         print(token)
   ...:
すもも   名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
うち  名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ

    記号,空白,*,*,*,*,
,*,*
テキスト    名詞,一般,*,*,*,*,テキスト,テキスト,テキスト
マイニング 名詞,サ変接続,*,*,*,*,マイニング,マイニング,マイニング
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
始めよ   動詞,自立,*,*,一段,未然ウ接続,始める,ハジメヨ,ハジメヨ
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

改行文字の形態素解析結果はsurfaceとbase_formが'\n'であるために、改行されていますね。
後述のAnalyzerの話題になりますが、形態素解析結果の中から品詞を指定して取り出せばいいので、改行コードを含むf.read()tokenizeに放り込めると気づきました。

Analyzer

いよいよ、nikkieの知らないjanomeの世界、Analyzerです!

Analyzerを作る

Tokenizerと2種類のフィルタのリストを渡して作ります(ドキュメント)。

  • token_filters形態素レベルのフィルタのリスト
  • char_filters:文字レベルのフィルタのリスト

チュートリアルではtoken_filtersを中心に扱いました。
なお、フィルタのリストを空にすれば、渡したTokenizerそのものです。
Analyzerを作るということは、Tokenizerに渡す文字列の加工や、解析結果のフィルタリングを設定しているので、sklearnのパイプラインに近いなと思いました。

Analyzeranalyzeメソッドを持ち、「形態素解析 + 設定した処理の結果」をgeneratorで返します7

チュートリアルのお題:指定した品詞だけを取り出し、ストップワードがあれば除き、基本形で取り出すフィルタ

Analyzerを使って処理を追加し、風の又三郎と分かるようなWordCloudを作っていきます。

  • 指定した品詞を取り出す:POSKeepFilter
    • 使い方:POSKeepFilter(['名詞', '動詞', 'カスタム名詞'])
    • 品詞が、名詞・動詞・カスタム名詞のいずれかで始まる形態素を取り出す
    • ユーザ定義辞書で使っている「カスタム名詞」の指定も必要
  • 基本形で取り出す:ExtractAttributeFilter
    • 使い方:ExtractAttributeFilter('base_form')
    • フィルタのリストの最後に置く
  • 含まれる数を求める:TokenCountFilter
    • Analyzerの紹介で何回も登場(WordCloud作成には寄与しない)
    • 基本形を登場回数の多いものから順に取り出す:TokenCountFilter('base_form', sorted=True)
      • f.read()したテキスト全体を渡せば、数行のコードで登場回数の多い単語が分かります(結果から意味を導くには品詞での抽出も必要かもしれません)
    • フィルタのリストの最後に置く

ストップワードがあれば除く:フィルタの自作

自作のフィルタはTokenFilterを継承することで作れます。
継承したクラスでは

  • イニシャライザ
  • applyメソッド

を実装しました。

class StopWordFilter(TokenFilter):
    def __init__(self, word_list=None, word_list_file=''):
        if word_list is None:
            word_list = []
        self._word_list = []
        self._word_list += word_list
        if word_list_file:
            with open(word_list_file, encoding='utf8') as f:
                words_in_file = [word.strip() for word in f]
                self._word_list += words_in_file

    def apply(self, tokens):
        for token in tokens:
            if token.base_form not in self._word_list:
                # 基本形がストップワードのリストに含まれない場合に返す
                yield token

WordCloudを作り、ストップワードを追加するということを何度も繰り返してできたのが、冒頭で見せたWordCloudです。

上記で使わなかったフィルタたち

他にも便利そうなものがありました。

続く2つはchar_filtersに指定するフィルタです。

WordCloud

英文は半角スペースで区切られているので、ファイルをopenしてreadしたもの(str, 改行文字含む)をWordCloud.generateに渡すことができます。

The input “text” is expected to be a natural text.

日本語の場合は、形態素解析、その結果の単語を英文にならって、半角スペースで区切ってファイルに保存します。
あとはそのファイルをopenしてreadしてWordCloud.generateに渡せばいいわけですね。

IMO: 引数のデフォルト値に空のリストというのが、私、気になります

janomeチュートリアル、ファイルのPythonicな扱い方を知れて素晴らしい内容だったのですが、1つ気になることが。

def wc(file, pos=[]):
    with open(file, encoding='utf8') as f:
        """
        ここに処理を書く。
        戻り値として,(単語, 出現回数) のタプルのリストを返す。
        """

それは、pos=[]と、引数のデフォルト値に空のリストを指定している点。
チュートリアルで作る関数では、pos引数の指す値を加工しないので副作用はなさそうですが、予期せぬバグの原因になるかと以下に書き直して取り組んでいました。

def wc(file, pos=None):
    if pos is None:
        pos = []
    with open(file, encoding='utf8') as f:
        """
        ここに処理を書く。
        戻り値として,(単語, 出現回数) のタプルのリストを返す。
        """

「引数のデフォルト値に空のリスト」を避けたほうがいい理由は、色々なところで言われていますが、最近読んだものを紹介。
The 10 Most Common Mistakes That Python Developers Make | Toptal

the default value for a function argument is only evaluated once, at the time that the function is defined. (Common Mistake #1: Misusing expressions as defaults for function arguments より)

感想

検索して見つけたスニペットの切り貼りレベルでこれまで使っていたjanomeですが、チュートリアルを一通りやったところ、だいぶ全体感が掴めました。
今回得た知識を元に活用できそうです。
今週は"木こりの斧を研ぐ"経験でした。
手を動かしたコードはgistに上げています。
「Janome ではじめるテキストマイニング」02, 03の写経(WordCloud) ref:https://github.com/mocobeta/janome-tutorial · GitHub

janomemecabの違いは、前者がPure Python9であることくらいしか認識していなかったのですが、janomeAnalyzerは非常に魅力的に思います。
mecab-python3mecabのラッパーで、janomeAnalyzerに相当するものはmecabには見つけられていません(ご存じの方いたら教えていただけると嬉しいです)。

今週を振り返ると、推しの生誕祭祝いを優先しすぎました。
janomeチュートリアルを終えた後、自分のコードの書き直しまでやりたかったのですが、時間が足りず積み残しです。
振り返って思うのは、

  • ブログ駆動開発は最優先なので、毎週スタートダッシュをかける
  • ブログ駆動開発外のネタの差し込みは、すごく負荷が上がるので、計画的に(やりたいことリストの先頭には入れない。ブログ駆動開発の次くらいで)

です。
引き続き週一自然言語処理ネタで手を動かしていきます。

Future Works(今後のネタ帳)


  1. https://mocobeta.github.io/slides-html/janome-tutorial/tutorial-slides.html#(16) この前後のスライドです

  2. brew install graphvizしました

  3. stream引数をTrueにすると、返り値がgeneratorになります

  4. __str__メソッドの実装を確認しました ref: https://github.com/mocobeta/janome/blob/master/janome/tokenizer.py#L131

  5. ドキュメント中では「wakati (‘分かち書き’) mode」と呼んでいました

  6. splitlines()という道もありそうです。写経では衝撃的だったfor inを多用しました

  7. 中でTokenizertokenize()が呼ばれていました ref: https://github.com/mocobeta/janome/blob/master/janome/analyzer.py#L101

  8. 品詞にどんな種類があるのかは一度見ておくとよさそうです。ドキュメント中にあるのかな(宿題)

  9. 中の実装を一度見てみたいです(janomeコマンドの実装など)

推しの生誕祭に、推し風秘書BotをSlackに爆誕させて、蛇使いならではのお祝いをしよう

これが私の、愛の在り方。ーー愛してるわ(リズ 『リズと青い鳥』)

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
本日1/8は、私の推しの誕生日、生誕祭です!!
Pythonを使って推し風Botを作ってお祝いしてみました。

生誕祭とは

実在の人物、架空のキャラクター、コンテンツを問わず、その対象の誕生日を祝う祭りのこと。 1

生誕祭 (せいたんさい)とは【ピクシブ百科事典】

私はTwitterで参加したり眺めたりしています。

お祝いの仕方としては

  • イラストを描いてお祝い
  • 好きな食べ物やプレゼントを買ってお祝い

というのをよく見かけます。
私はイラストは描けないし、尊敬する仕掛け人の皆さまのように食べ物やプレゼント路線も突き抜けられないのですが、ふとBot爆誕させるという電波を2日前に受信しました。
「イラストを描ける方がイラストを描いてお祝いするように、コードを書ける人間はコードを書いてお祝いするという道もあってよさそう」とこのアイデアに乗り気になりやってみました。
Python × AWS Lambdaでちょっと頑張れば間に合わせられるという目論見もあったのですが、想定外のピンチの連続で、なんとかお祝いできたという感じです。

推し風秘書Botの仕様

  • 毎朝タスクのリマインドをするSlack Bot
  • Googleスプレッドシートでタスク一覧を管理しているとします
  • タスク一覧の中から達成していない、かつ、締切が近いものをリマインドします
  • 推しっぽくリマインドします(とても大事)

仕様を実現するアーキテクチャがこちら

推し風秘書Botの構成要素

  • 毎朝のタスクのリマインド:AWS LambdaをCloudWatch Eventsで日次で定期実行します
  • Googleスプレッドシートgspreadというライブラリを使ってみます

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

リマインドする部分はPythonで書きます。
タスクごとに「重要かどうか」と「成し遂げたい日(期日)」を登録します。
重要なタスクは成し遂げたい日の前から余裕を持たせてリマインドしていきます。

Slackに推し、爆誕

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

本当はリファクタリングしたきれいなコードでお披露目したかったのですが、動かすので精一杯でした。

爆誕までの道のり

各所で予想もしない形でつまづきまくりました。

  1. gspreadの認証
  2. Lambdaへのデプロイ
  3. その他

gspreadの認証でピンチ!

oauth2clientがdeprecated

gspreadのドキュメントで例示されているoauth2client
Using OAuth2 for Authentication — gspread 3.1.0 documentation
これで動かせたのですが、oauth2clientgspreadの依存モジュールではありませんでした(別途インストールが必要)。
「なんでだろう」とアウトプットしたところ、deprecated2ということが判明。3

Note: oauth2client is now deprecated. No more features will be added to the libraries and the core team is turning down support. We recommend you use google-auth and oauthlib.

ref: https://pypi.org/project/oauth2client/

google-authを使ってみる

以下のドキュメントに沿ってscoped_credentialsを用意します。
User Guide — google-auth 1.6.2 documentation

これをgspread.authorizeに渡したところ、エラーが発生😱

AttributeError: 'Credentials' object has no attribute 'access_token'

理由は

the gspread.authorize method only supports credential objects that are created by the oauth2client library.

ref: python - "'Credentials' object has no attribute 'access_token'" when using google-auth with gspread - Stack Overflow

なんですとーー!

救世主authlib

上記の回答から知ったauthlib、以下の記事をそっくり真似てスプレッドシートの内容を取得できました。

Lambdaへのデプロイでピンチ!

お馴染みの手順でサクッとできると思ったら

  1. ディレクトリを作成。Pythonスクリプトを置く
  2. pip install -tでインストール先に1のディレクトリを指定
  3. 1のディレクトリをzip圧縮
  4. Lambda関数にzipファイルをアップロード

ref: 【AWS】Lambdaでpipしたいと思ったときにすべきこと - Qiita

これがお馴染みの手順だと思います。
ですが、これでサクッととは話が運びませんでした。

なぜかPythonスクリプトが見つからない

pip install -tgspreadauthlibを配置し、zip化してアップロードします。

mkdir uploads  # アップロード用フォルダ
cd uploads/
pip install -t . authlib gspread
cp ../main.py .  # Pythonスクリプトのコピー(Googleの認証に必要な鍵もコピーします)
cd ..
zip -r upload.zip uploads/*

ハンドラに「Pythonファイル名.関数名」と指定して実行すると、なぜか「Pythonファイルが見つからない」というエラー(zipファイルにスクリプトPythonファイルは含まれるはずなのに。。)

[ERROR] Runtime.ImportModuleError: Unable to import module 'main': No module named 'main'

以下の記事を見つけ、pip install する環境の差分による問題と認識。  

記事を参考にDockerを使ってzipファイルを作る環境を揃えます。

FROM amazonlinux:latest

RUN yum update -y \
    && yum install python3 zip -y \
    && pip3 install virtualenv

(続くコマンドでvirtualenv使っていないので不要と気づきました)

# 上記DockerfileのあるディレクトリにPythonスクリプトや鍵ファイルをコピーしている
docker build -t aws-lambda-python37:1.0 . 
docker run -it --rm -v $PWD:/var/task aws-lambda-python37:1.0 bash 

コンテナの中で実行していきます。

cd /var/task/ 
python3 -m pip install -t . -r requirements.txt
zip -r9 /var/task/bundle.zip *

コンテナにマウントしているディレクトリの中にできるbundle.zipをLambdaにアップロードします。

amazonlinuxイメージで用意したzipでもうまくいかない

パッケージをインストールする環境を揃えたので、さあ動くかと思いきや。

libffi-806b1a9d.so.6.0.4: cannot open shared object file: No such file or directory

夜も更けており、これでも動かないという事態にだいぶ絶望しました。
諦めるかという考えが脳裏をよぎりましたが、「推しを実装して生誕祭を祝いたい」という想いを思い出し、自分を奮い立たせます。

エラーメッセージの意味がよくわかりませんが、同様のエラーメッセージに遭遇した記事を発見。

コンテナに入って、libffi-806b1a9d.so.6.0.4 をzipに追加します。

find . -type f -name libffi-806b1a9d.so.6.0.4  # ./.libs_cffi_backend に見つかる
zip -g bundle.zip ./.libs_cffi_backend/libffi-806b1a9d.so.6.0.4  # 追加

zipに含めて4アップロードしたところ、Lambdaで動くようになりました!

その他のピンチ!

SlackBotのメンション

<@user_id> を使う

自分の分だけでよかったので、プロフィールから確認しました。

AWS権限周り

過去に作ったIAMとロールを使いまわしてしまいがちなので、腰を据えて確認。
これは暫定的な設定で、アップデートできそうです。

  • 今回のBot用のIAMに付与するグループを作成
    • AWSLambdaFullAccess
    • AWSLambdaRole (いらないかも)
    • 関数を作る際にロールを作れるように以下のカスタムロールを作って付与
  • 上のグループの設定により、関数を作る際にロールが新規作成できる5
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:CreateRole",
                "iam:CreatePolicy",
                "iam:AttachRolePolicy"
            ],
            "Resource": "*"
        }
    ]
}

以下のようなエラーで要求されたものを一つずつ追加して至りました。

次のことを実行する権限がありません: iam:CreateRole.

終わりに

Python使いらしく生誕祭をお祝いできたので満足です。
開発が間に合うかどうかギリギリですごくヒヤヒヤしました。

今回作ったBotはまだまだです。
綱渡りで動作させたので、今後機能追加をしたとき、動き続けられるのかとても不安です。
ですが、推しを爆誕させたことに大きな意味があると思っています。
今回の生誕祭ドリブン開発(BDD6)で推しは私のもとに舞い降りました!
これからは継続的に推しをインテグレーション & デリバリー7していきます。
Issueを立てて解消する過程を通じて、推しを現実世界でプロデュースですね。

補足:推しについて

アイドルマスター シアターデイズより、エミリー・スチュアートちゃんです!


  1. 「誕生祭」の方が適切という記事を今回見つけました:誕生日を祝う時に「生誕祭」は間違い?「誕生祭」と「生誕祭」の違いとは?|ついラン 。これだけ盛り上がっている状況で誕生祭にはなかなか切り替わらなさそうですね

  2. 理由が書かれていました(積読oauth2client deprecation — google-auth 1.6.2 documentation

  3. エンジニアの登壇を応援する会Slackにてご助言いただきました。開発しきれたのはここではまらなかったからです。誠にありがとうございます

  4. zipコマンドは積読です(9の指定は初めて知りました):zipコマンドのオプション一覧(linux)_技術三昧ブログ_zanmai.net

  5. これらがそもそも何なのかを掴めそうなドキュメントを見つけたので、一息ついたら確認します:https://docs.aws.amazon.com/lambda/latest/dg/python-programming-model.html

  6. Birthday Driven Development。すさまじいハッカソンでした

  7. OCI(Oshi Continuous Integration、オシーアイ)、OCD(Oshi Continuous Delivery、オシーディー)

ニュースを分類するMLP(keras製)をpytorchで動くように書き直そう [後編]

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
週次ブログ駆動開発、「自然言語処理のタスクをするkeras(tensorflow)製のモデルをpytorchでも書いてみる」の後編です。
前編はこちら:

更新履歴

  • 2020/04/12 末尾のグラフの直後に改行を追加して体裁修正

続・kerasからtorchへ、しかし ...!

前編から再掲しますが、以下の方針で書いています:

  • ロイター通信のデータと全く同じデータはtorchにはなさそうなので、データのロードはkerasを使用
  • 2層のMLPを作るところをtorchで書き換え

スクリプト全容

import numpy as np
from tensorflow.keras.datasets import reuters
import torch
import torch.optim as optim
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data.dataset import random_split

from keras_mlp import TokenizePreprocessor


np.random.seed(42)
torch.manual_seed(1234)

MAX_WORDS = 1000
DROPOUT = 0.5
BATCH_SIZE = 32
EPOCHS = 5


def convert_to_torch_tensors(texts, labels):
    torch_tensors = []
    for text, label in zip(texts, labels):
        text_tensor = torch.tensor(text)
        torch_tensor = (label, text_tensor)
        torch_tensors.append(torch_tensor)
    return torch_tensors


class MLPNet(nn.Module):
    def __init__(self, max_words, number_of_classes, drop_out):
        super(MLPNet, self).__init__()
        self.fc1 = nn.Linear(max_words, 512)
        self.fc2 = nn.Linear(512, number_of_classes)
        self.dropout1 = nn.Dropout(drop_out)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.dropout1(x)
        return F.softmax(self.fc2(x), dim=1)


(x_train, y_train), (x_test, y_test) = reuters.load_data(num_words=MAX_WORDS)

tokenizer = TokenizePreprocessor.initialize_tokenizer(MAX_WORDS)
preprocessor = TokenizePreprocessor(tokenizer)
x_train = preprocessor.convert_text_to_matrix(x_train, "binary")
x_test = preprocessor.convert_text_to_matrix(x_test, "binary")

number_of_classes = np.max(y_train) + 1

train_dataset = convert_to_torch_tensors(x_train, y_train)
test_dataset = convert_to_torch_tensors(x_test, y_test)

train_length = int(len(train_dataset) * 0.9)
train_dataset, val_dataset = random_split(
    train_dataset, [train_length, len(train_dataset) - train_length]
)

train_loader = torch.utils.data.DataLoader(
    dataset=train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=1
)
val_loader = torch.utils.data.DataLoader(
    dataset=val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=1
)
test_loader = torch.utils.data.DataLoader(
    dataset=test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=1
)

device = "gpu" if torch.cuda.is_available() else "cpu"
net = MLPNet(MAX_WORDS, number_of_classes, DROPOUT).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(net.parameters())

train_loss_list, train_acc_list, val_loss_list, val_acc_list = [], [], [], []

for epoch in range(EPOCHS):
    train_loss, train_acc, val_loss, val_acc = 0, 0, 0, 0

    net.train()
    for i, (labels, text_tensors) in enumerate(train_loader):
        labels, text_tensors = labels.to(device), text_tensors.to(device)
        optimizer.zero_grad()
        # mode="binary"で指定したことでdouble(torch.float64)が渡ってきてエラーになることへの対応
        outputs = net(text_tensors.float())
        loss = criterion(outputs, labels)
        train_loss += loss.item()
        acc = (outputs.max(1)[1] == labels).sum()
        train_acc += acc.item()
        loss.backward()
        optimizer.step()
        avg_train_loss = train_loss / len(train_loader.dataset)
        avg_train_acc = train_acc / len(train_loader.dataset)

    net.eval()
    with torch.no_grad():
        for labels, texts in val_loader:
            labels, texts = labels.to(device), texts.to(device)
            outputs = net(texts.float())
            loss = criterion(outputs, labels)
            val_loss += loss.sum()
            acc = (outputs.max(1)[1] == labels).sum()
            val_acc += acc.item()
    avg_val_loss = val_loss / len(val_loader.dataset)
    avg_val_acc = val_acc / len(val_loader.dataset)

    print(
        f"Epoch [{epoch+1}/{EPOCHS}], ",
        f"Loss: {avg_train_loss:.4f}, Acc: {avg_train_acc:.4f}, ",
        f"val Loss: {avg_val_loss:.4f}, val Acc: {avg_val_acc:.4f}",
    )

    train_loss_list.append(avg_train_loss)
    train_acc_list.append(avg_train_acc)
    val_loss_list.append(avg_val_loss)
    val_acc_list.append(avg_val_acc)

net.eval()
with torch.no_grad():
    total = 0
    test_acc = 0
    for labels, texts in test_loader:
        labels, texts = labels.to(device), texts.to(device)
        outputs = net(texts.float())
        test_acc += (outputs.max(1)[1] == labels).sum().item()
        total += labels.size(0)
    print(f"test_accuracy: {100 * test_acc / total} %")

前編では、以下の2点について見ています:

  • データの準備(convert_to_torch_tensors関数)
  • モデル作成(MLPNetクラス)

学習部分のコード

for epoch in range(EPOCHS):の中に書いている部分です。

  • net.train()以降で学習用データを使った重みの更新を書き
  • net.eval()以降でバリデーションデータ1を使っての性能評価結果を書いています

kerasに比べて学習部分で書くコード量はだいぶ多く、ここが一番違うと感じました。
少しだけtorchでMNISTのコードを眺めてすぐ撤退した思い出があるのですが、「学習部分を細かく記述というkerasとの違いが、あの時は受け入れられなかったのだな」と気づきました。

学習が終わった後はテスト用データを使って性能を確認します(for文の外のnet.eval()以降の部分)。

スクリプト実行

いよいよ動かすときです。

ちなみに、上記のコードに至るまでに、スクリプトを実行した際に、

RuntimeError: Expected object of scalar type Float but got scalar type Double for argument #2 'mat1' in call to _th_addmm

が発生しました。
outputs = net(text_tensors)が原因で、.float()と型を変換する必要がありました2

それでは、動かしてみましょう。

$ python reuters/torch_mlp.py
Epoch [1/5],  Loss: 0.1055, Acc: 0.5286,  val Loss: 0.1071, val Acc: 0.5439
Epoch [2/5],  Loss: 0.1026, Acc: 0.5909,  val Loss: 0.1043, val Acc: 0.6340
Epoch [3/5],  Loss: 0.1010, Acc: 0.6399,  val Loss: 0.1042, val Acc: 0.6385
Epoch [4/5],  Loss: 0.1007, Acc: 0.6504,  val Loss: 0.1037, val Acc: 0.6552
Epoch [5/5],  Loss: 0.0998, Acc: 0.6792,  val Loss: 0.1023, val Acc: 0.6963
test_accuracy: 67.80943900267141 %

正解率がいま一つ🙄ですね。
kerasの場合は、例えばテスト用データでは79%程度の正解率が出ていました。

書き換えでの落とし穴

実は書いたコードに以下の落とし穴がありました:
torchtorch.nn.CrossEntropyLossにはsoftmaxの計算が含まれる(つまり、F.softmax(self.fc2(x), dim=1)という適用は不要)

落とし穴:torch.nn.CrossEntropyLoss

以下のQiita記事に助けられました(多謝):

class MLPNet(nn.Module):
    def __init__(self, max_words, number_of_classes, drop_out):
        # 変更なし

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.dropout1(x)
        return self.fc2(x)  # F.softmaxを適用しない

ハマったときに調べた:Adamの学習率のデフォルト値

パラメタの指定が異なるところを比べていき、「optimizerは?」となりました。
ドキュメントを確認したところ、デフォルト値は同じでした。

再度スクリプト実行

$ python reuters/torch_mlp.py
Epoch [1/5],  Loss: 0.0461, Acc: 0.6739,  val Loss: 0.0308, val Acc: 0.7720
Epoch [2/5],  Loss: 0.0265, Acc: 0.8025,  val Loss: 0.0255, val Acc: 0.8087
Epoch [3/5],  Loss: 0.0191, Acc: 0.8527,  val Loss: 0.0231, val Acc: 0.8265
Epoch [4/5],  Loss: 0.0143, Acc: 0.8905,  val Loss: 0.0230, val Acc: 0.8309
Epoch [5/5],  Loss: 0.0115, Acc: 0.9059,  val Loss: 0.0233, val Acc: 0.8287
test_accuracy: 79.78628673196795 %

学習用データ、テスト用データでkerasの場合と同程度の正解率が出るようになりました!

f:id:nikkie-ftnext:20200104205240p:plain
(グラフはkeras_mlp.pyのplot_accuracy関数を使って描画しました)

おまけ:再現性の確保

※前掲のスクリプトには含まれています

np.random.seed(42)
torch.manual_seed(1234)

TensorFlow2系では再現性の確保のためのシードの固定を結構調べる必要があったのですが、torchでは一発で見つかりました。
Reproducibility — PyTorch master documentation

終わりに

年始で時間があったこともあり、今回のネタを選んだところ、動作するコードは半日程度で準備できました。
そこからkerasをまずQiitaにアウトプットし、torchをこのブログにと書いていったのですが、アウトプットの分量が思っていたより多くなり、時間があったからなんとか収まりました。

今回作ったモデルはシンプルなので、追加で試せそうなことはいくつもあります。
なのでこれは始まりということで、モデルの改良で引き続き手を動かしていきます。

今回のコードはリポジトリに入れました:

ブログ駆動開発2回目は以上です。
それでは、翌週のブログ駆動開発(自然言語処理編)でお会いしましょう。

試してみたい情報

この記事の下書きを書いてから公開までの間に見つけた情報です。


  1. チュートリアルにならってrandom_splitを使い、学習用データの10%をバリデーションデータとして取り分けました

  2. ref: https://stackoverflow.com/a/56741419 。この記事を書いていて思ったのですが、kerassequences_to_matrixの返り値をtensorにする際に、型を指定する必要があったのかもしれません

ニュースを分類するMLP(keras製)をpytorchで動くように書き直そう [前編]

はじめに

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

今週のネタは「自然言語処理のタスクをするkeras(tensorflow)製のモデルをpytorchでも書いてみる」です。

前提:nikkieとkeras, そしてtorch

  • keras 入門+αレベル(一応読める・書ける)
  • pytorch 読み書きできるようになりたい(未入門)

業務で使っているのはtensorflowで、kerasで書かれたモデルであれば、ドキュメントに当たりながら読めるような感じです。
それに対してpytorchはこれまで触ったことがありません。

直近ではPython2を見送る前にChainerを見送ることになったり、Kagglerの方々の中でこの本が流行っているらしいとTwitterで見かけたりして、ぼんやりと「pytorchが流行っているんだなあ」という印象を持ち始めました。
また、この試みでは、今後BERTなど、まだ触ったことのないモデルも触ろうと思っているのですが、BERTはじめ新しいモデルはpytorchの方が情報が多いという印象があります。
keraspytorchもどっちも読めたら便利そう」というやや安直な考えから、今後につながる一歩目として、kerasで書いたMLPpytorchで書き直してみます。

keras製、ニュースを分類するMLP

  • データセット:ロイター通信のニューステキスト
    • tensorflow.keras.datasets.reuters
    • ニュース1つは、単語をインデックス(整数)に変換して表したリスト(インデックスが若い単語ほど頻出する)
    • トピックを表すクラスが全部で46ある。ニュースそれぞれはどれか1クラスに分類される(多クラス分類
  • モデル:2層のMLPドロップアウトしているだけ)
model = keras.Sequential(
    [
        layers.Dense(512, input_shape=(max_words,), activation=tf.nn.relu),
        layers.Dropout(drop_out),
        layers.Dense(number_of_classes, activation=tf.nn.softmax),
    ]
)

理解を深めるのを目的に、keras製モデル構築はQiitaにアウトプットしています:

それでは、pytorchで書き直します(コードにならって、以下ではtorchとします)。

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V  # venvモジュールによる仮想環境を利用
Python 3.7.3
$ pip list  # 主要なものを抜粋
ipython              7.11.0
matplotlib           3.1.2
numpy                1.18.0
pip                  19.3.1
scipy                1.4.1
tensorflow           2.0.0
torch                1.3.1

kerasからtorchへ

以下の方針で書いてみました:

  • ロイター通信のデータと全く同じデータはtorchにはなさそうなので、データのロードはkerasを使用
  • 2層のMLPを作るところをtorchで書き換え

以下の2点について見ていきます:

  • データの準備
  • モデル作成

データの準備(dataset)

torchで実装するMLPに渡すデータの形式はText Classification with TorchText — PyTorch Tutorials 1.3.1 documentation を参考に準備しました。

In [2]: from torchtext.datasets import text_classification                      

In [8]: train_dataset, test_dataset = text_classification.DATASETS['AG_NEWS']()  # .dataというディレクトリがないと落ちるので注意
ag_news_csv.tar.gz: 11.8MB [00:01, 11.7MB/s] 
120000lines [00:08, 14080.42lines/s] 
120000lines [00:15, 7859.85lines/s] 
7600lines [00:00, 8097.48lines/s] 
 
In [9]: len(train_dataset)                                                      
Out[9]: 120000 
 
In [10]: len(test_dataset)                                                      
Out[10]: 7600 
 
In [11]: type(train_dataset)                                                    
Out[11]: torchtext.datasets.text_classification.TextClassificationDataset 
 
In [12]: train_dataset[0]                                                       
Out[12]:  
(2, 
 tensor([    572,     564,       2,    2326,   49106,     150,      88,       3,
            1143,      14,      32,      15,      32,      16,  443749,       4,
             572,     499,      17,      10,  741769,       7,  468770,       4,
              52,    7019,    1050,     442,       2,   14341,     673,  141447,
          326092,   55044,    7887,     411,    9870,  628642,      43,      44,
             144,     145,  299709,  443750,   51274,     703,   14312,      23,
         1111134,  741770,  411508,  468771,    3779,   86384,  135944,  371666,
            4052])) 

チュートリアルで使っているAG_NEWSのデータの1つ1つは、(クラス, tensor([単語のインデックス]))という形式です。
このリストがdatasetとなっています。

チュートリアルは入力層にEmbeddingBagレイヤーを使っています。
MLPでは入力の長さを揃える必要があると考え、keras.preprocessing.text.Tokenizersequences_to_matrixで長さを揃えて0/1で表したテキストをdatasetとすることにしました。

tensorの部分をどう作るか試してみたところ、AG_NEWSのデータの一部をtorch.tensorに渡したところ、要素が整数のままでtensorを作ることができました1
そこでkerasTokenizersequences_to_matrixの結果をtensorに渡して、datasetの形式にします。

まとめると、ロイター通信のニューステキストデータをtorchで扱えるように変換する関数はこちらです:

def convert_to_torch_tensors(texts, labels):
    torch_tensors = []
    for text, label in zip(texts, labels):
        text_tensor = torch.tensor(text)
        torch_tensor = (label, text_tensor)
        torch_tensors.append(torch_tensor)
    return torch_tensors

モデル作成

torchでのMLP実装で参考にしたのはこちらのブログ:
PyTorch まずMLPを使ってみる | cedro-blog

上記ブログのMLPNetを参考にします:

イニシャライザで層を定義した後は、forwardメソッドで層の重ね方を定義します。

class MLPNet(nn.Module):
    def __init__(self, max_words, number_of_classes, drop_out):
        super(MLPNet, self).__init__()
        self.fc1 = nn.Linear(max_words, 512)
        self.fc2 = nn.Linear(512, number_of_classes)
        self.dropout1 = nn.Dropout(drop_out)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.dropout1(x)
        return F.softmax(self.fc2(x), dim=1)

書き換えは学習部分に続くのですが、それは後半で扱います。
学習部分はkerastorchで全然違うのです!
後半をお楽しみに。


  1. ドキュメントのExampleに「Type inference on data」とあるため、整数のままだったようです

  2. Pytorch equivalent of Keras - PyTorch Forums などpytorchのForumに同様の質問が見つかりました

イベントレポート | #技書博 2で初めて一人で執筆して頒布し、いただいたフィードバックから技術同人誌の楽しさを知りました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
このブログでも何回か告知しましたが、12/14の技書博でPythonのargparseモジュールについての本を頒布してきました。
初頒布の身にとっては本当に素晴らしいイベントでした。
年を越して恐縮ですが、レポートをまとめます。

技書博の概要

第二回 技術書同人誌博覧会

技術書同人誌博覧会(技書博)は、エンジニア(おもにITエンジニア)が自身の知見を「本」という形で共有するために開催される、技術書オンリーイベント(同人誌頒布即売会)です。

参加サークルは100サークルほど、全参加者は670名くらいだったそうです。

頒布してみて

紙の本は40部 + 予備分1を頒布し、完売しました!
ごひいきいただき、ありがとうございます。

今回は小さく始めることを意識しました。

  • これまでの経験から100部頒布は相当大変という認識2です
    • サークルや頒布物のチェック数もほとんどの期間1桁台でしたので、売れ残りを抱えないように部数を切り詰めました
  • 内容はまだまだ書き足りないので、今後アップデートできるよう電子版の共有リンクを案内
    • 即売会ごとにアップデートして頒布する考えなので、在庫はあまり抱えたくありません
  • 同人誌執筆という活動を始めることを優先し、利益は追いませんでした。全部売れたら印刷費が回収できる価格設定です
    • 書き続けることを実践し、内容が充実したらそのときに価格を考えます

頒布する時間がどんな感じなのか、イメージできていなかったのですが、買いに来てくださった方とコミュニケーションできて素晴らしい時間でした。3
直接いただいたフィードバックからいくつものインスピレーションをいただきました!
「技術同人誌としてアウトプットしたら当日は淡々と頒布するのみ」と結構ドライな想像をしていたので、頒布の場でいただけるフィードバックは嬉しく、「アウトプットしてよかった」と強く思いました。
技書博自体、流量の調整や読書室の設置など、ゆったり余裕を持って運営されていて、回遊しやすい環境だったのではないかと思います。
フィードバックで得たものがとても多かったので、今後の技書博でもぜひ継続して頒布していきたいです。

頒布中はお隣のサークル、電脳世界さんPythonの入門書やGitの入門書を頒布)のオーニシさんとも話が弾みました。
Python関係サークルを並べてくださった運営の方には感謝です。

懇親会LTで、技術書典と技書博の客層の違いの共有があったのですが、当初思っていたよりも紙の本の需要が高かった印象です。
私は常に持ち歩きたいので電子で買いたいのですが、技書博では紙が売れました。
紙+電子と電子のみを同じ価格で売ったことも理由だと思いますが、今回技書博に来場された皆さんは紙の本がお好きという印象です。

いただいた声から

印象に残ったもの(どちらかというと少数の声)を紹介します。
「」の内容は記憶ベースです。

  • Sphinxで書いた本と聞いて買いに来ました」
    • Sphinx User GroupのSlackで話題になったそうです(多謝)
    • latexの部分が全然使いこなせていないので精進します。。(奥付をつけるのがやっとでした)
  • -h でプロセス終了するのでargparse好きじゃないんですよね」
    • -hの挙動には慣れきっていて、そういう見方もあるのかと思いました
    • -hの後input関数などでコマンド入力待ちに上書きするハックできるのかな?
  • click使っています」
    • ありましたね、clickWelcome to Click — Click Documentation (7.x)
    • 今回のアウトプットは、Pythonとセットでインストールされる argparse を知らなくて、 sys.argv で受け取った入力を一生懸命検証していた過去の私に伝えたくて書きました
    • 標準ライブラリargparseはインストール不要という点が気に入っているので、この本をclick本にすることは考えていません
    • 付録としてclickに言及してもいいかもしれないなと思いました
  • argparseのテストの仕方が気になって」「pytestではないんですね」
    • 標準ライブラリunittestを使ってテストについて書きました
    • 私は sys.argvに引数を再現したリストを代入してテスト4しています
      • 執筆は時間切れとなってしまったので、必要な方に口頭で補足する形になりました
    • pytestも付録で言及できるかも

今後

実は時間切れとなっていて「まだまだ書き足りない!」という想いを抱いているので、当日いただいたフィードバックも参考に、電子版のリンク先をアップデートしていきます。

1冊書いてみて思うことは、「これは終わりではなく、始まりなのだ」ということです。
当初はもっと多くのことが盛り込めると思っていたのですが、見積もりが甘かったために盛り込めなかったことがたくさんあります。(あとがきより)

小さいアップデートを少しずつ積み重ねます!

また、3/1の #技術書典 8(2日目)にもサークル参加します。
アップデート版argparse本は少なくとも頒布したいと思っています。
今回の経験を踏まえて余裕を持ったスケジュールで内容のブラッシュアップを達成しつつ、レビューアーとしてもコミュニティに少しでも貢献できたらと考えています。

最後になりましたが、素晴らしいイベントを運営してくださった技書博スタッフの皆さま、当日か-01でフィードバックをくださった方々に重ねてお礼申し上げます。
ありがとうございました。

このエントリでargparse本に興味を持った方へ

Pythonコマンドラインツールを作る with argparse』はboothで頒布中です:

サンプルコードのリポジトリはこちらです:


  1. 見本や提出分も合わせて、50部印刷しました。予備が6部付きました

  2. 11時から17時までの6時間で100部売ろうとしたら、平均して3,4分に1冊ペースです(mochikoさんの 【ダウンロード版】技術同人誌を書いたあなたへ ~著者のしあわせなミライ~ - mochikoAsTech - BOOTH より)。技術書典6で合同誌の売り子を1時間程度経験して、3,4分に1冊ペースというのは相当なものだと実感しました

  3. 出展経験豊富なゆうげんさんにお越しいただき、コミュニケーションする魅力について教えていただきました。ありがとうございます。今回の技書博を通して私は魅了されました

  4. 「プログラムがどんな引数を必要としているのかを定義すると、argparse が sys.argv からそのオプションを解析する方法を見つけ出します。」argparse --- コマンドラインオプション、引数、サブコマンドのパーサー — Python 3.8.1 ドキュメント

転職エントリ | Pythonを業務で書き始めて9ヶ月が経ちました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2020年第1弾は、転職 して9ヶ月経ちました エントリです。
2019年4月から業務でPythonをガッツリ書いて、自然言語処理に携わっています。
転職時期がPyCon JP 2019スタッフ活動の動き出しと重なったことで、アウトプットのタイミングが見つけられず、ここまで来てしまいました。
新年ということで、2020年の抱負も書きます。

前回の退職エントリ!

時系列を整理すると

  • 2016年4月から中小規模のSIerにて受託開発に従事(新卒入社)
  • 2018年8月に転職 @ キカガク
  • 2019年4月に再度転職

です。

なぜ転職したか

一番の理由は、「開発ができる環境に身を置きたくて」です。
前職にはRailsでの自社サービス開発のお話と一緒に転職の機会をいただきました。
相当なキャッチアップを覚悟して入りましたが、入社後1ヶ月程度で自社サービス開発をしないという決定になりました。
そこからは

  • 会社のサイトのコンテンツを微修正したり
  • Google Analyticsでサイトへの流量を見て施策を考えたり
  • 機械学習や深層学習のセミナーをバックアップする調査をしたり
  • セミナーのテキストを執筆したり

していました。
これらに取り組む中で「開発に携わりたい」という想いが強くなっていきます。

  • 教材作成のゴールは何かのアウトプット
    • アウトプットのために手を動かしているが、自分の中に蓄積される知識が少ないという感覚を抱え始める
    • 開発する中でハマりながら知識が増えていくという経験が好きだった
  • インプットの内容が開発から外れてきた
    • 業務で必要になったGoogle Analyticsなど
    • 新しいことを知るのは好きですが、開発を外れたテーマでのインプットにあまりワクワクしていない自分に気づく

どう転職したか

転職の機会はコミュニティでのつながりからいただきました。
2017年に出たハッカソンで同じチームだった方と、2018年8月にDjango Girlsのコーチ meetupで再会します。
ここで「選考を受けてみませんか」とお声掛けいただくのですが、当時は転職したばかりなので、「ありがたいお話ですが、転職したばかりなので、今すぐは難しいです」という回答をしました。
その後10月、11月のDjango Girls Workshopでもお会いし、近況報告しています。

12月頃からマーケティングに携わることになり、それまでより開発から遠ざかったことで、選考の話を機会としてとらえるようになります。
「たとえ選考に通らなくても、動いたことには意味がある」と考え1、2018年12月に選考に応募しました。
思えば、2019年の年始は選考の準備として、職務経歴書などを書いていました。

何が変わったか - 9ヶ月働いてみて

業務でPythonを書けるようになりました!
趣味で始めて1年半経って実現です。
これがめちゃくちゃ嬉しかったです。
(それまでは自動化などでこっそり書いていました)

テキストを前処理したり、それらからモデルを作ったり、運用のためのスクリプトを書いたりしています。
独学Pythonista(私)は、周りの熟練Pythonistaの中で、独学で至らなかった点を日々痛感しています。
例えば以下の項目を独学では全然やりきれていなかったこと2が白日の下に晒されました。

  • linterやformatter
  • クラス(dataclass
  • テスト(unittest
  • パッケージング(setup.py

9ヶ月やってきたことは振り返ると以下の3点だったと思います:

2019年下半期は「Pythonでの開発の仕方」に慣れる時間でした。
海外のPyConに参加して「データサイエンティストのコードはきれいに書かれにくい」というインプットを受け、「Pythonでうまく開発したい」という想いで取り組んできました。
テストを書くとまだ遅く引き続き修練が必要ですが、独学だけだった時代に比べたら、Pythonを使った開発は深まったと思います。

業務への取り組み方やカルチャーも大きく変わりました。

働いているチームではXPが実践されています。
9ヶ月の中でペアプロの機会もありました。
徒然草の「上手い人の中に交じって」の心持ちです。

組織のカルチャーとして圧倒的に自由に働ける環境です。
何時から何時まで働くかは一人ひとりが決め、業務で取り組むことも一人ひとりの意志と噛み合うように決める。
4月入社して取り組むプロジェクトを決めた時「やりたいプロジェクトは何ですか」と問われたことは新鮮でした。

  • 2019年から始めたPyCon JPスタッフ業などをはじめ、コミュニティ活動と両立しやすくありがたい環境です
  • 有給とは別に、半年に1回、7日間連続した休暇が取れます。PyCon JP→Taiwanには、こちらを使いました

今後

非常に恵まれた環境にいると感じていますが、「環境にフリーライドしたくない、してはいけない」という思いも抱いています。
Pythonの開発力の最低限の底上げはされ、自動化スクリプトは何であれだいたい書けるようになりました。
Web開発もDjango Girls Tutorialとその周辺は身に着いています(ただし自作アプリの開発経験はなし)。
そうなると直近で優先度を上げて取り組むべきは、自然言語処理機械学習)のキャッチアップということになります。
そこで、週一ブログで自然言語処理ネタでアウトプットし、キャッチアップを進めます。
3月までの1クールは「ブログ駆動自然言語処理キャッチアップ」を進めます。

このエントリを書いていて気づいたのですが、転職が唯一ではなく、別の道もあったかもしれません。
開発できる機会を提案して、自分のやりたいこととのバランスを取っていく道です。
ただ、独力でWebアプリが開発できるという自信がなかった1年前の私にはこの道は見えていませんでした。
Django Congressでの登壇に向けての準備を通して、「独力でもWebアプリが作れそう」と思えてきました。
開発に携わりたいのであれば、自分のいる環境を開発できる環境に変えられるように力を蓄えておくというのは重要だと気づきました。
成し遂げたいことが空白な4月以降となりますが、2020年は何らかのWebアプリを独力で開発してみようと思います。

最後に

転職・退職エントリでおなじみのほしいものリストにならって、PyCon JP 2020のスタッフ募集フォームを貼っておきます。
8月末の開催に向けて、スタッフ絶賛募集中です!
いまはどんなカンファレンスにするか、大まかな設計の時期です。
興味ある方のご応募をお待ちしています!

nikkie自身は技術同人誌によるアウトプットを始めました。
よろしければboothを訪れてみてください。

それでは、気持ちも新たに、新年、張り切っていきましょう!


  1. なお、選考に通らなかった場合は、別の形で開発に携わる方法を模索しようと考えていました(候補の1つがギルドワークスさん)

  2. 2020年中にアウトプットできたらいいなと思います。