nikkie-ftnextの日記

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

推しの生誕祭に、推し風秘書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でも書いてみる」の後編です。
前編はこちら:

続・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年中にアウトプットできたらいいなと思います。

イベントレポート | 年の瀬の多用な時期ではありますが #spzcolab のもくもく会にて Django 3系でDjango Girls Tutorialのコードが動くか確認してきました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
年の瀬ではありますが、もくもく会に行ってきました。
手を動かして分かったことと、他の方の取り組みから知ったことをまとめます

勉強会の概要

【途中入退室可能】さぼらないようもくもく会【今年最後の日曜日】 - サポーターズCoLab

実家に帰る方もいらっしゃると思いますが、年末年始って意外と"暇"
今年最後の日曜日、一緒に自己研鑽しませんか?

Kaggleのタイタニックコンペハンズオンで何回か登壇させていただいたサポーターズCoLabさん。
年末年始の時期にももくもく会を開いていただきありがたい限りです。
家で過ごす日が続くとだらけがちなので、人の目がある場所で気合を入れてもくもくします。

取り組んだこと

Django Girls Tutorialのコード(Django 2.2.xを想定)がPython 3.8系、Django 3.0系で動くか確認する

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V  # venvモジュールで仮想環境を作っています
Python 3.8.1
$ django-admin --version
3.0.1

検証結果

ローカルではチュートリアルのコードから書き換えなくても動作しました!

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

ちなみに、チュートリアルで使っているBootstrapが3.2なので、3系の最新3.3.7を試しました。
Getting started · Bootstrap

調べたこと

.gitignoreの書き方が気になって

デプロイ! · Django Girls Tutorial.gitignore には

myvenv
/.vscode
/static

が出てきます。
先頭に / が付くか付かないかの違いですが、

  • /が付かないmyvenvの場合:.gitignoreのあるディレクトリの下のmyvenvの他、directory/myvenvも無視される
  • /が付く/.vscode/staticの場合:.gitignoreのあるディレクトリの下にある.vscodeやstaticだけが無視される

そうです1
確かにblog.cssのあるblog/static/css/は無視されていませんね。

Django URLs の裏の仕組みを少しだけ

URL ディスパッチャ | Django ドキュメント | Django

  • settings.ROOT_URLCONFでmysite/urls,pyが指定済み(ここだったのか)
  • mysite/urls,pyからurlpatternsという変数をDjangoが探す

includeについて
URL ディスパッチャ | Django ドキュメント | Django

ModelFormの instance 引数

編集する際には instance 引数に現在のインスタンスを渡しています(例:form = PostForm(request.POST, instance=post))。2

モデルからフォームを作成する | Django ドキュメント | Django

モデルのオブジェクトに付属したモデルフォームのインスタンスは、instance 属性を持ち、メソッドが特定のモデルのインスタンスにアクセスできるようにします。

編集するときは特定のインスタンスにアクセスする必要があるので使っているということですね。

他の方の取り組みから

Java, KotlinやRubyなど言語は多岐にわたっていました。

もくもくの背景:2月に静岡で話します!

今回のもくもくは2月の #pycon_shizu 向けDjangoトークの地歩固めの第一歩でした。

確認結果を踏まえて、Django 3.0.xで話を進められそうです。

このトークはWeb開発を知らない方を含めてAll向けとしています。
トークではDjangoのクイックツアーをやろうとしていて、Web開発に出てくる概念を簡潔に紹介する必要があると思っています。
モデルやテンプレートに比べて、URLやビューって簡潔に説明できていないと気づきました。
Web開発全般を扱った本を参考に、わかりやすく簡潔な説明を探してみます。

また、「図がほしい」という声も見かけたので、発表には図を入れます。

終わりに

もくもくはかどりましたー。
積んでいたタスクが終わって気持ちいいです。
この気持ちよさは麻薬的なのですが、まとまった休みは掃除などができる機会でもあるので、バランスには気をつけないといけませんね。

参加者の皆さま、運営のussyさん、ありがとうございました。
年末年始のもくもく会、引き続きよろしくお願いします。

自分が書いたはてなブログの記事でWordCloudを作り、2019年と2018年を比較する

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
先日「エンジニアの登壇を応援する会」の忘年LT大会にて、週1でブログを書くブログ駆動開発を知りました。
今週のネタ「自分の今年のブログのWordCloudを作る」についてブログを書きます。

宣言:私のブログ駆動開発

ブログ駆動開発がよさそうに思ったのは、「本当に学びたいことをブログ記事にする」という点です。
2019年4月から業務で自然言語処理に取り組んでいますが、この分野のキャッチアップはまだまだと感じます。
そこで直近1クール(2020年3月末まで)は、自然言語処理のネタで毎週1本ブログを書くことにします。

目的は自然言語処理へのキャッチアップですので、イベントレポートや雑記はブログ駆動開発としてはカウントしないという制約を追加します。

[ここから本題] WordCloudを作ってみた

忘年LT大会で知った「自分のブログのWordCloud」に取り組みました。

WordCloudとは

文章中で出現頻度が高い単語を複数選び出し、その頻度に応じた大きさで図示する手法。ウェブページやブログなどに頻出する単語を自動的に並べることなどを指す。文字の大きさだけでなく、色、字体、向きに変化をつけることで、文章の内容をひと目で印象づけることができる。

デジタル大辞泉 より

1年のブログをWordCloudにすることで、どんなことについて、頻繁に書いた1年だったかが掴めると考えています。

今回は公開している記事のタイトルと本文を対象にしました

  • 2019年:32件
  • 2018年:78件

2019年のブログのWordCloud

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

出現頻度が高いものは

ですね。
「PyCon」は、PyCon JPのスタッフ活動を始めたり、海外のPyConに参加したりと、私の2019年を言い表している単語のように思います。

2018年のブログのWordCloud

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

出現頻度が高いものは

といったところでしょうか。
2018年はDjango Girls Tutorialの翻訳に取り組んだ年で、ブログにもコードを載せて翻訳中の気づきをアウトプットしています(postやcommentはTutorialのコードをカウントしたためと思われます)。
Dockerは2019年はめっきり登場しなくなっていますが、使わなくなったわけではありません(最近は業務の中で、コンテナ内のuserとホストマシンのuserとの重ね合わせやコンテナの/etc/hostsの書き換えでハマりました)

2018年からずっとこのブログはPythonについてアウトプットしていて、改めて「Pythonに夢中になっているんだなあ」としみじみ思いました。

どのように手を動かしたか

WordCloudは(このブログでは未アウトプットですが、)過去の #pyhack で実装したコードがあります。

今回は前処理に手を入れて使っています。

はてなブログの記事の取得は新規に実装しました。
ソースコードはこちら

今回の開発で学んだのは大きく2点です:

  • はてなブログの記事一覧(XML)のパース
  • ブログ記事から不要な部分の除去(re.sub

はてなブログの記事一覧のパース

長くなったのでQiitaにまとめました。

今回、XMLの解析とは別の、思ってもみないところでつまづきました。
それがdatetimeタイムゾーン周りで発生した以下のエラーです:

TypeError: can't compare offset-naive and offset-aware datetimes

原因ですが、

わけです。

取得した記事は別々のファイルに分かれてフォルダに格納されます。
一方、私の実装の都合により、後続のWordCloudのスクリプトにはテキストを1ファイルで渡す必要があります。
そこでシェルからcatで1ファイルにまとめます:cat 2019/*.txt > 2019_all_blog.txt

正規表現を使ってブログ記事から不要な部分の除去

これまでのコードでは、前処理が不十分なためにURLに使われる単語もカウントされていました(先のツイートのhttpsやcom)。
これを削除することに取り組みました。

また、はてなブログの記事では [awesome_link:embed]といった埋め込みや [^1] のような脚注も登場します。 この [] で囲まれた部分も削除しました。

さらに、2018年の記事を見返すと、Markdown記法ではなく見たまま記法で書いていた時期がありました。
そのためにWordCloudにdivやpxなどのHTMLタグに使われる語が登場していました。
見たまま記法で作ったHTMLからHTMLタグも削除しました。

text = fin.read()  # ファイルから読み込み
square_bracket_removed = re.sub(r'\[.+?\]', '', text)  # はてな記法の[]を削除
uri_removed = re.sub(  # リンクのURIを削除
    r'https?://[\w/:%#$&?~.=+-]+', '', square_bracket_removed)
# <div>や</blockquote>などのHTMLタグを削除
html_tags_removed = re.sub('</?.+?>', '', uri_removed)

このコードがどのように動くのか、理解したことを以下に書きます。

正規表現. は「改行以外の任意の文字」にマッチします1
.+ は「改行以外の任意の文字を 1 回以上繰り返したもの」です。
.+?最小のマッチにします。
最小とはどういうことかと言うと、[^1]: mofu [hoge:title] のような文字列を考えた時、[^1][hoge:title] にマッチするということです。
?をつけない.+の場合は、[^1]: mofu [hoge:title]全体にマッチします(続く処理により、mofu の部分が捨てられてしまいます)2

URIを表す正規表現正規表現サンプル集 を参考にしました。
https?でhttpもhttpsも表せていて、うまいなと思います(?は「直前の正規表現を 0 回か 1 回繰り返し」を表す)。
[]は「文字の集合を指定」します。
この中では「特殊文字はその特殊な意味を失」うそうです(バックスラッシュによるエスケープが不要でした)。

HTMLタグを表す正規表現</?.+?>は、URIの場合を真似ました。
<h2>日記の見出し</h2>という文字列では、<h2></h2>だけを除いてほしいので、?を使って最小マッチにしています。

こうして指定した正規表現を使い、re.subで空文字列に置き換えて除去します。
今回の開発で re.sub の使い方にはだいぶ慣れました。

不要な部分を除去した後は、kz_moritaさんから教わった参考記事を真似て

  • STOPWORDの追加
  • 品詞の絞り込み

を試しました。
その結果が先ほどお見せした2枚のWordCloudです。

通して実行した様子

# はてなブログの記事一覧を取得するスクリプトのディレクトリに移動
cd ~/hatenablog-atompub-python
source env/bin/activate
python main.py nikkie-ftnext nikkie-ftnext.hatenablog.com 2019 --output output/2019
cd output
cat 2019/*.txt > 2019_all_blog.txt
deactivate
# WordCloudのスクリプトのディレクトリに移動
cd ~/cfp_wordcloud
source env/bin/activate
python draw_cloud.py ~/hatenablog-atompub-python/output/2019_all_blog.txt  # ブログ記事をまとめたファイルを渡す

その他のリソース

手を動かす中や動かした後に見つけたリソースを挙げます。
janomeチュートリアルはブログ駆動開発(自然言語処理編)の1つのネタとしておきます

終わりに

初回のブログ駆動開発は以上です。
迫るタイムリミットで平日はプレッシャーを感じるものですね。

実装したコードにはブラッシュアップできる点がいくつか浮かぶので、Issue管理して整えていきます。
それでは、翌週のブログ駆動開発(自然言語処理編)でまたお会いしましょう。


  1. 正規表現については re --- 正規表現操作 — Python 3.8.1 ドキュメント を参照しています

  2. Pythonによるあたらしいデータ分析の教科書』5章の青空文庫のテキストの前処理で見たコードを参考にドキュメントを引きながら手を動かしました。今回ようやく意味がつかめました