⛸️

Sequentialモデルの次に進んだ人が陥るTensorFlowパターン7選

2022/12/18に公開約9,800字

この記事は「Python Advent Calendar 2022」の18日目の記事です!

はじめに

専攻は生物学ですが、趣味(?)で地震学の研究室にも行ってる大学4年生です。
研究室では、ディープラーニングを活用して地震を検出しています。

ちなみに、地震学というよりディープラーニング興味があります。

運よく実験の結果が出たので学会に参加してきたのですが、直前まで上手くいかずに泣きながらコードを書いていたので、記念に自分がハマった7つのパターンを挙げてみます。

Sequentialモデルの次に進んだ人が陥る7つのパターン

パターン1:グラフとeager executionが分からない

バージョン2のTensorFlowは基本的にeager executionとして動くため、TensorFlowを通常のPythonコードと同様に扱えます。しかし、モデルをコンパイルするとgraph executionとして実行されてしまうため、この違いを把握できていないと混乱してしまいます。

eager modeで動いている場合は、printでデバッグしたりTensor.numpyでテンソルの値を取得したりすることができます。

def func(tensor):
    print(tensor)  # tf.Tensor([1 2 3], shape=(3,), dtype=int32)
    print(tensor.numpy())  # [1 2 3]

func(tf.constant([1, 2, 3]))

一方、tf.functionでPythonコードをラップするとgraph modeで動くようになります。graph modeは初回実行時のみPythonのように動き、同時に実行グラフを生成します。2回目以降の実行時は生成されたグラフをもとに動くため、printTensor.numpyは使うことができません。

@tf.function
def func(tensor):
    print(tensor)

func(tf.constant([1, 2, 3]))  # Tensor("tensor:0", shape=(3,), dtype=int32)
func(tf.constant([1, 2, 3]))  # グラフとして動くため、Pythonのprintでは出力されない

値を出力したい場合は、printの代わりにtf.printを使いましょう。

また、kerasモデルは基本的にgraph modeで動きます。ただしgraph modeではデバッグしづらいため、eager modeで挙動を確認してからgraph modeで実行するのが良いでしょう。以下のように書くことで、開発段階のモデルをeager modeで実行することができます。

  # コンパイルの引数でモードを指定する
- model.compile(run_eagerly=False)  # graph execution(デフォルト)
+ model.compile(run_eagerly=True)  # eager execution

  # もしくはグローバルでモードを指定する
- tf.config.experimental_run_functions_eagerly(False)  # graph execution(デフォルト)
+ tf.config.experimental_run_functions_eagerly(True)  # eager execution

eager executionやgraph execution、tf.functionについてはこちらで詳しく説明しています。

https://zenn.dev/yuji207/scraps/c3fa10e533b821

また、カスタムオブジェクトを使うときに__init__buildcallの違いに混乱することがあるかもしれません。これらのメソッドは、以下のように呼び出されるタイミングが異なります。

class SimpleDense(Layer):

    def __init__(self, units=32):
        """インスタンス生成時に呼び出される。"""
        super(SimpleDense, self).__init__()
        self.units = units

    def build(self, input_shape):
        """モデルの初回実行時に呼び出される。"""
        self.w = self.add_weight(shape=(input_shape[-1], self.units),
                                 initializer='random_normal',
                                 trainable=True)
        self.b = self.add_weight(shape=(self.units,),
                                 initializer='random_normal',
                                 trainable=True)

     def call(self, inputs):
         """モデルの実行時に毎回呼び出される。"""
         return tf.matmul(inputs, self.w) + self.b

パターン2:Dataset.mapのtf.py_functionが分からない

tf.datatf.data.Dataset.map内の関数がグラフであるか否かにかかわらず、graph executionとして実行します。

前述の通りgraph executionではPythonコードを扱えないので、もしeager executionで実行したい場合(外部ライブラリなどを使用したい場合など)は以下のいずれかの方法を取りましょう。

  • AutoGraphを用いてPythonコードをグラフに変換する。
  • tf.py_functionを使う(AutoGraphと比較してパフォーマンスは低下する)。
  • tf.numpy_functionを使う。

パフォーマンスこそ低下しますが、tf.py_functionを使うと簡単にDataset.mapの中の処理をeager executionにすることができます。tf.py_functionでは以下のように、Dataset.mapに渡したい関数をラップして使用します。

  def func(x):
-     # graph executionなのでprintは初回のみ実行され、numpytは使えない
-     print(x)
+     # eager executionなのでprintが使え、numpyで値にアクセスできる
+     print(x.numpy())
      return x, x + 5
  
  
  def map_func(x):
      """"
       tf.py_functionでラップする。
       funcはDataset.mapに渡す関数、inpはfuncの引数、Toutはfuncの戻り値の型。
       """
      return tf.py_function(func=func, inp=[x], Tout=[tf.int64, tf.int64])
  
  
  dataset = tf.data.Dataset.range(5)
- dataset = dataset.map(func)
+ dataset = dataset.map(map_func)  # tf.py_functionでラップした関数を渡す
  
  for data in dataset:
-     pass  # Tensor("args_0:0", shape=(), dtype=int64)
+     pass  # 0, 1, 2, 3, 4

tf.numpy_functionは基本的にtf.py_functionと同様に使えます。ただ、tf.numpy_functionではTensor.numpy()のように値にアクセスする必要はなく、直接値にアクセスすることができます。

AutoGraphは使ったことないのでよく分かりませんでした

パターン3:データセットの順番(対応関係)が崩れる

詳しくは別記事で紹介していますが、以下のようなコードには注意が必要です。

def func():
    """和が10になる値のタプルを返す関数"""
    n = random.randint(0, 10)  # Dataset.map内でrandomを扱っている
    return n, 10 - n

def map_func(data):
    """mapの中で任意のPythonを書くためのラッパー"""
    return tf.py_function(func=func, inp=[], Tout=[tf.int32, tf.int32])

def create_dataset():
    """入力用データセットと出力用データセットの作成"""
    dataset = tf.data.Dataset.range(5)
    dataset = dataset.shuffle(buffer_size=5)  # Dataset.shuffleを使っている
    dataset = dataset.map(map_func)
    return dataset

dataset = create_dataset()
x = dataset.map(lambda x, y: x)  # タプルのデータセットを入力と出力に分割
y = dataset.map(lambda x, y: y)  # データセットを複数回呼び出している

for x, y in zip(x, y):
    print(x, y)

Dataset.shuffleDataset.map内でrandomを使っている場合は、気を付けましょう。

パターン4:データセットが想定外の挙動をする

スコープが広いですが、Datasetに関しては他にも注意すべきことがあります。

例えばDataset.shuffleDataset.batchを使うときは、先にshuffleした方が良いでしょう。

dataset = dataset.shuffle(buffer_size)
dataset = dataset.batch(batch_size)  # シャッフルしてからバッチ化する 

また、少量のデータセットで検証するときにdrop_remainder=Trueにしていると、データを取得できずにハマります。

- # バッチサイズに満たないは数のデータセットは切り捨てられる
- dataset = dataset.batch(batch_size, drop_remainder=True)
+ dataset = dataset.batch(batch_size, drop_remainder=False)

さらに、データのコンタミにも注意が必要です。

https://twitter.com/kurozumi_jp/status/1378340207099928583

パターン5:モデルのインスタンス変数が正しく更新されない

バッチ内の処理で完結せず学習全体で管理したい値には、tf.Variableを使いましょう。

  class Model(tf.keras.models.Model):
      """入力をそのまま返すだけのモデル"""
      def __init__(self):
          super(Model, self).__init__()
-         self.counter = 0  # バッチの数をカウントする
+         self.counter = tf.Variable(0, trainable=False)  # tf.Variableを使う
  
      def call(self, data):
-         self.counter += 0  # カウント
+         self.counter.assign_add(1)  # Tensorはイミュータブルなので、assign_addを使う
          return data
  
  # モデルの学習
  model = Model()
  model.compile(loss=loss)
  model.fit(dataset)
  
- print(model.counter)  # 3
+ print(model.counter.numpy()  # 5

上記の例ではバッチの数が5つですから、tf.Variableを使わない場合に挙動がおかしくなることが分かります。

また、Tensorがイミュータブルであることを忘れないようにしましょう。
tf.Variableを更新するときにはassign_addを使います。

パターン6:カスタムモデルが保存・読み込みできない

TensorFlow2では、モデルをSavedModelまたはHDF5として保存することができます。ところがSavedModel形式で保存しようとしたところ、公式ドキュメントの文章(以下)を読んだことでハマってしまいました。

カスタムのオブジェクト (クラスを継承したモデルやレイヤー) は保存や読み込みを行うとき、特別な注意を必要とします。以下のカスタムオブジェクトの保存*を参照してください。

......

カスタムオブジェクトの保存
SavedModel 形式を使用している場合は、このセクションをスキップできます。

上記の文章だと、SavedModel形式を使う場合はカスタムオブジェクトが必要ないように思えます。しかし、実際はカスタムオブジェクトを別途保存する必要がありました。

(ドキュメントの書き方には今も納得してない…)

  class Model(tf.keras.models.Model):
      """線形回帰するだけのモデル"""
      def __init__(self):
          super(Model, self).__init__()
          self.relu = tf.keras.layers.Dense(1)
  
      def call(self, data):
          return self.relu(data)
  
+     def get_config(self):  # カスタムオブジェクトを取得するメソッド
+         config = super(Model, self).get_config()
+         return config
  
  
  x = tf.data.Dataset.from_tensors(np.random.rand(100, 1))  # 0-1のランダムな数値
  y = x.map(lambda x: x * 1.2 + 0.3)  # xを1.2倍して0.3を足す
  dataset = tf.data.Dataset.zip((x, y))
  dataset = dataset.batch(64)
  
  model = Model()
  model.compile(tf.keras.optimizers.Adam(learning_rate=0.1), "mse")
  model.fit(dataset, epochs=100)  # loss: 1.9847e-06
+ custom_objects = model.get_config()  # カスタムオブジェクトの取得
  
  model.save("/content/model")
  model = tf.keras.models.load_model(
      "/content/model",
+     custom_objects={"Model": Model}  # カスタムオブジェクトの指定
  )
  model.evaluate(dataset)  # loss: 1.9847e-06

上記のように、サブクラスの中でカスタムオブジェクトを取得するメソッドを定義しましょう。その後、実際に取得したカスタムオブジェクトをモデル読み込み時に渡すことで、モデルを保存・読み込みすることができます。

パターン7:PyTorchに浮気したくなる

最後のパターンは、数字を縁起の良い7に合わせるためだけに入れました。
たぶんTensorFlowに冷たくあしらわれた、多くの人が経験するパターンでしょう。

https://twitter.com/fkyish/status/1333774102683738113

おわりに

泣きながらコードを書く人が少しでも減りますよう祈っています。
実験は計画的に。

明日の「Python Advent Calendar 2022」の記事を担当するのは@aipacommanderさんです。お楽しみに!

参考

Discussion

ログインするとコメントできます