Python♪ラムダ式でクラスのインスタンス変数を操作するテクニック

ラムダ式「f = damdba 変数: 式」の変数にインスタンス変数、式にクラスメソッドを指定すると、クラスのインスタンス変数をパラメータとしてクラスを操作することができます。これはディープラーニングの学習にも利用できるテックニックです。

なぜなら、ディープラーニングの重みやバイアスはクラスのインスタンス変数として定義されることが多いからです。

ラムダ式の変数に、インスタンス変数の名前を渡すことで、操作するパラメータの対象を指定することができるので、計算の流れが明快になります。

この記事では、このテクニックを「ラムダ式を使ったテクニック」と呼ぶことにします。

0.「ゼロから作るDeep Learning」のポイント整理シリーズ

この記事は「ゼロから作るDeep Learning」のポイント整理シリーズの記事です。シリーズの名前のとおり私の自習用のメモです。書籍では説明されない基礎文法や補足・関連事項などを説明しており、書籍がなくてもわかる内容になっています。

1.ラムダ式を使ったテクニック

具体例がなければ、何を言っているのか全く分からないと思いますので、サンプルコードを紹介します。

コード01は、2人で選んだ数字から1~6の値を返すサイコロプログラムです。40行目で配列xに2つの数字を入力します。実行すると出力01のようにサイコロの目の結果が出力されます。

Class_diceでは2行3列と3行6列の行列w1、w2が用意され、w1、w2の各要素はそれぞれ0以上1未満の少数で初期化されます。そしてcal_dice()を実行すると入力したxと行列w1、w2から、6つの数字が求められ、その6つの数字の中で最も大きい数字のインデックスからサイコロの目を決定します。

なお、「ラムダ式を使ったテクニック」により、22, 23行目ではサイコロの目を計算するだけではなく、インスタンス変数w1, w2をパラメータとして指定し、w1, w2の要素をランダムな値に初期化し直しています。

この様に「ラムダ式を使ったテクニック」により、クラスの変数やメソッド、def関数などを組み合わせて、すっきりしたコードを記述することが可能です。

#コード01
import numpy as np

class Class_dice():
    '''2人で決めた2つの数値から1~6の値を返すサイコロプログラム
    
    2人で決めた数値を配列x(1次元配列、配列の長さ2)を入力する
    '''
    def __init__(self):
        '''xに乗ずる2次元配列w1,w2の初期化'''
        self.w1 = np.random.randn(2, 3)
        self.w2 = np.random.randn(3, 6)
    
    def cal_dice(self, x):
        '''x,w1,w2より、1~6の値を返す'''
        tmp = np.dot(x, self.w1)
        tmp = np.dot(tmp, self.w2)
        return np.argmax(tmp) + 1
    
    def result_dice(self, x):
        f = lambda W: self.cal_dice(x)
        result = change_dice(f, self.w1)
        result = change_dice(f, self.w2)
        print('選択した数字:',x)
        print('サイコロの目:',result)

def change_dice(f, x):
    '''NumPy配列xの要素をランダムな値(0以上1未満)に変更し、f(x)を実行
    
    xは1次元配列、あるいは2次元配列
    '''
    #print(x)
    if x.ndim == 1:
        x[:] = np.random.rand(x.shape[0])
    if x.ndim == 2:
        x[:] = np.random.rand(x.shape[0], x.shape[1])
    return f(x)

dice = Class_dice()
x = np.array([1., 2.])  #2人で選んだ数値を配列に代入
dice.result_dice(x)  #サイコロの結果を出力
#出力01
選択した数字: [1. 2.]
サイコロの目: 2

しかし、一見、簡単なコードに見えますが、じっくりコードを読むと以下のような疑問がわいてきます。

  • 21行目の「f = lambda W: self.cal_dice(x)」において、式self.cal_dice(x)に変数Wが使われていない。「f = lambda x: self.cal_dice(x)」ならば見慣れたラムダ式ですが、変だと思いませんか?
  • 22, 23行目でchange_diceに引数としてself.w1を渡していますが、これが最終的にcal_dice(self, x)のxに代入されるのであれば、cal_dice(self, x)のxは2行3列となり、16行目では2行3列の行列同士の積でエラーになるのでは?
  • 読めば読むほどresult_dice()、change_dice()、change_dice()のデータの関係が分からない。

このように、「ラムダ式を使ったテクニック」はコードの手順は明快になるものの、データの流れが複雑になるため、その流れを理解しなければ自分自身でコーディングするのは困難です。

以下、データの流れをつかむために必要な知識を、順を追って説明したいと思います。

1.引数には参照先のアドレスがコピーされる

「ラムダ式を使うテクニック」を理解するには、まず、関数の仮引数には実引数の参照先のアドレスがコピーされることを知っておく必要があります。

コード02の出力が、なぜ出力02のようになるのか分からない場合には以下の記事を参照してください。

Python♪本当は変数には参照先のアドレス(id)しか入っていない

2行目の仮引数a, b, cには11行目の実引数a, b, cの参照先のアドレスがコピーされます。従って、NumPy配列やlistの要素を変更した場合には関数内の変更が関数の外にも影響を与えますが、4行目のようにlistをまるごと再定義した場合や、5行目のように数値やタプルといった変更不能体を変更した場合には関数の外側には影響を与えません。

#コード02
def xxx(a, b, c):
    a[0] = 99
    b = [99, 11, 12]
    c = 99

a = [0, 1, 2]
b = [10, 11, 12]
c = 0

xxx(a, b, c)
print(a)
print(b)
print(c)
#出力02
[99, 1, 2]
[10, 11, 12]
0

次にコード03を見てください。今度は14行目で関数abc_change()の実引数abc.a, abc.b, abc.cの参照先アドレスが8行目の仮引数a, b, cに渡されます。従って、コード02と同様に9行目の変更のみが関数の外に影響を与えます。

「ラムダ式を使ったテクニック」では、このコード03の方法を使うことになりますので、覚えておいてください。

#コード03
class Abc():
    def __init__(self):
        self.a = [0, 1, 2]
        self.b = [10, 11, 12]
        self.c = 0

def abc_change(a, b, c):
    a[0] = 99
    b = [99, 11, 12]
    c = 99

abc = Abc()
abc_change(abc.a, abc.b, abc.c)
print(abc.a)
print(abc.b)
print(abc.c)
#出力03
[99, 1, 2]
[10, 11, 12]
0

参考までにコード04も見てください。コード04では関数abc_change()に引数としてクラスAbc()のインスタンスabcを渡し、関数内でインスタンス変数a, b, cを変更しました。関数の外でabc.a, abc.b, abc.cを参照しても、関数内の変更が反映されます。

コード03と混同しないようにしましょう。

#コード04
class Abc():
    def __init__(self):
        self.a = [0, 1, 2]
        self.b = [10, 11, 12]
        self.c = 0

def abc_change(abc):
    abc.a[0] = 99
    abc.b = [99, 11, 12]
    abc.c = 99

abc = Abc()
abc_change(abc)
print(abc.a)
print(abc.b)
print(abc.c)
#出力04
[99, 1, 2]
[99, 11, 12]
99

2.ラムダ式を使わない場合のサイコロプログラム

まず、「ラムダ式を使ったテクニック」を使わない場合のサイコロプログラムを考えてみます。

コード05はコード01と同様に2つの数字を与えると、1~6の値を返すサイコロです。ただ、w1, w2の初期化は32行目でインスタンスが生成されるときだけなので、37行目、38行目の出力結果はどちらも4です。

一方、40行目の「change_dice(dice.cal_dice, x_choice)」では、x_choiceの値[1. 2.]の内容をランダムな値で初期化します。change_diceに渡された引数x_choiceは、そのままcal_dice(self, x)の引数xに渡され、1~6の値が返されます。

ここで、45行目のように「change_dice(dice.cal_dice, dice.w1)」とすると、change_diceでdice.w1が初期化されますが、dice.w1が30行目でf(x)のx、つまり14行目のcal_dice(self, x)のxに渡されるため、16行目の計算で2行3列と2行3列の行列の積となり、エラーになります。

コード05は文法的に分かりやすいコードですが、インスタンス生成後にchange_diceで初期化できるのはx_choiceだけであり、w1やw2を初期化するためには新しく関数を作る必要があります。

#コード05
import numpy as np

class Class_dice():
    '''2人で決めた数値から1~6の値を返すサイコロ
    
    2人で決めた数値を配列x(1次元配列、配列の長さ2)を入力する
    '''
    def __init__(self):
        '''xに乗ずる2次元配列w1,w2の初期化'''
        self.w1 = np.random.randn(2, 3)
        self.w2 = np.random.randn(3, 6)
    
    def cal_dice(self, x):
        '''x,w1,w2より、1~6の値を返す'''
        tmp = np.dot(x, self.w1)
        tmp = np.dot(tmp, self.w2)
        return np.argmax(tmp) + 1
        
def change_dice(f, x):
    '''NumPy配列xの要素をランダムな値(0以上1未満)に変更し、f(x)を実行
    
    xは1次元配列、あるいは2次元配列
    '''
    #print(x)
    if x.ndim == 1:
        x[:] = np.random.rand(x.shape[0])
    if x.ndim == 2:
        x[:] = np.random.rand(x.shape[0], x.shape[1])
    return f(x)

dice = Class_dice()

#2人で選ぶ数値の配列x_choiceを自動で計算
x_choice = np.array([1., 2.])  #2人で選んだ数値を入力
print('選択した数字:', x_choice)
print('サイコロの目:', dice.cal_dice(x_choice))
print('サイコロの目:', dice.cal_dice(x_choice))

number = change_dice(dice.cal_dice, x_choice)
print('選択した数字:', x_choice)
print('サイコロの目:', number)

x_choice = np.array([1., 2.])  #2人で選んだ数値を入力
#print(change_dice(dice.cal_dice, dice.w1))  #これはエラー
#出力05
選択した数字: [1. 2.]
サイコロの目: 4
サイコロの目: 4
選択した数字: [0.92357478 0.27291817]
サイコロの目: 6

3.ラムダ式を使ったテクニック

コード06は「ラムダ式を使ったテクニック」を使用したコードです。32行目まではコード05と同じです。34行目のラムダ式がポイントです。(34行目のラムダ式は35, 36行目のように書き換えることもできます。)

ラムダ式を使えば、同じ関数change_dice(f, x)を使って、x_choiceだけではなくw1, w2も初期化することが可能です。

なお、34行目のラムダ式は「f = lambda W: dice.cal_dice(x_choice)」の変数Wが式dice.cal_dice(x_choice)に含まれていないことが重要です。

35, 36行目のdef関数を見た方が分かりやすいですが、f(W)のWに何を入れたとしても関数の計算には影響しないことが分かります。

つまり39行目のchange_dice(f, dice.w1)では、引数として渡されたdice.w1は29行目で初期化され、30行目のf(x)のxに渡されます。しかし、ラムダ式「f = lambda W: dice.cal_dice(x_choice)」のWはdice.cal_dice(x_choice)に影響を与えないので、30行目のf(x)に渡されたdice.w1は14行目のcal_dice(self, x)のxに渡されるわけではありません。

14行目のcal_dice(self, x)のxには34行目の「f = lambda W: dice.cal_dice(x_choice)」により、x_choiceが渡されます。

つまり、39, 41, 42行目のchange_dice( )の2番目の引数が何であったとしても、14行目のcal_dice(self, x)のxにはx_choiceが渡されるのです。

一方、39, 41, 42行目のchange_dice()の2番目の引数に渡されたNumPy配列w1, w2, x_choiceは、関数change_dice()の中で配列の要素が初期化されます。コード04で説明したように、関数内でlistやNumPy配列の要素を変更するとその変更は関数の外にも影響を与えます。

したがって、w1やw2が変更不能体では同じ事はできませんし、27, 29行目の「x[:] =」を「x =」に変更するとxはローカル変数となりw1やw2の変更が関数の外に影響を与えないことには注意しなければなりません。

つまり、w1, w2, x_choiceは30行目のf(x)から、14行目のcal_dice(self, x)に変更結果が渡されるわけではなく、関数change_dic()の中でw1, w2, x_choiceの変更が完結しているのです。

#コード06
import numpy as np

class Class_dice():
    '''2人で決めた数値から1~6の値を返すサイコロ
    
    2人で決めた数値を配列x(1次元配列、配列の長さ2)を入力する
    '''
    def __init__(self):
        '''xに乗ずる2次元配列w1,w2の初期化'''
        self.w1 = np.random.randn(2, 3)
        self.w2 = np.random.randn(3, 6)
    
    def cal_dice(self, x):
        '''x,w1,w2より、1~6の値を返す'''
        tmp = np.dot(x, self.w1)
        tmp = np.dot(tmp, self.w2)
        return np.argmax(tmp) + 1
        
def change_dice(f, x):
    '''NumPy配列xの要素をランダムな値(0以上1未満)に変更し、f(x)を実行
    
    xは1次元配列、あるいは2次元配列
    '''
    #print(x)
    if x.ndim == 1:
        x[:] = np.random.rand(x.shape[0])
    if x.ndim == 2:
        x[:] = np.random.rand(x.shape[0], x.shape[1])
    return f(x)

dice = Class_dice()

f = lambda W: dice.cal_dice(x_choice)
#def f(W):
#    return dice.cal_dice(x_choice)
x_choice = np.array([1., 2.])  #2人で選んだ数値を入力
print('選択した数字:', x_choice)
print('サイコロの目:', change_dice(f, dice.w1))
print('選択した数字:', x_choice)
print('サイコロの目:', change_dice(f, dice.w2))
number = change_dice(f, x_choice)
print('選択した数字:', x_choice)
print('サイコロの目:', number)
#出力06
選択した数字: [1. 2.]
サイコロの目: 3
選択した数字: [1. 2.]
サイコロの目: 2
選択した数字: [0.81552272 0.10464951]
サイコロの目: 4

なお、コード06はラムダ式がClass_dice()や、関数change_dice(f, x)の外にありますが、コード01のようにクラスの中にラムダ式を組み込むこともできます。データの流れが分かりにくいので、私は同じクラス内で完結させた方がベターだと思います。

このように、「ラムダ式を使ったテクニック」により、クラスのインスタンス変数をパラメータとしてクラスを操作することができます。

「ゼロから作るDeepLearning」のサンプルコードでもニューラルネットワークの勾配を求めるときに「ラムダ式を使ったテクニック」を利用しているので、サンプルコードを繰り返し読んで、この便利なテクニックを自分のものにしてください。

私が実際に購入した教材のご紹介

以下、私が実際に購入したPythonの教材をまとめてみました。 Pythonを学習する上で、少しでもお役に立つことができればうれしいです。

Python♪私が購入したPythonの書籍のレビュー
UdemyのPythonの動画講座を書籍を買う感覚で購入してみた

その他

Twitterへのリンクです。SNSもはじめました♪

以下、私が光回線を導入した時の記事一覧です。
 (1) 2020年「光回線は値段で選ぶ」では後悔する ←宅内工事の状況も説明しています。
 (2) NURO光の開通までWiFiルーターを格安レンタルできる
 (3) NURO光の屋外工事の状況をご紹介。その日に開通!
 (4) 光回線開通!実測するとNURO光はやっぱり速かった
 (5) ネット上のNURO光紹介特典は個人情報がもれないの?