※本記事は、スタンフォード大学CS224N「NLP with Deep Learning」コースのPython/NumPyチュートリアル動画の内容を基に作成されています。チュートリアルの講師はManasi Sharma氏です。本コースやチュートリアルの詳細情報については、https://online.stanford.edu/courses/c... および http://web.stanford.edu/class/cs224n/ でご覧いただけます。
本記事では、チュートリアルの内容を要約しておりますが、原著作者の見解を正確に反映するよう努めています。ただし、要約や解釈による誤りがある可能性もありますので、正確な情報や文脈については、オリジナルの動画をご視聴いただくことをお勧めいたします。
スタンフォード大学の人工知能専門・大学院プログラムについての詳細は https://stanford.io/ai でご覧いただけます。
講師紹介: Professor Christopher Manning氏: Thomas M. Siebel機械学習教授、言語学・コンピュータサイエンス教授、スタンフォード人工知能研究所(SAIL)所長 Been Kim氏: https://beenkim.github.io/
スタンフォードオンラインについて: スタンフォードオンラインは、スタンフォード大学工学部が提供する学術・専門教育のポータルサイトです。スタンフォード大学の教員によって開発された様々な学位プログラム、単位取得可能な教育、専門認定プログラム、無料のオープンコンテンツを提供しています。詳細は https://online.stanford.edu/ をご覧ください。
1. イントロダクション
1.1. チュートリアルの目的
こんにちは皆さん、CS224N NLPのPythonレビューセッションへようこそ。このセッションの目的は、Pythonとその中でも特にNumPyの基礎をお伝えすることです。これらの知識は皆さんが取り組む第2回以降の宿題で広く使用することになります。
このチュートリアルは、プログラミング言語に全く触れたことがない方から経験者まで、幅広い背景を持つ人を想定しています。既にプログラミング経験がある方には基礎的な部分は速めに進め、NumPyに進むようにします。
何よりも、このセッションは実際にここに来ている皆さんのためのものです。もし私のペースが速すぎたり遅すぎたりする場合や、何か明確にしてほしいことがあれば、どうぞ質問してください。このセッションをインタラクティブなものにしたいと考えています。
1.2. 対象者について
このチュートリアルセッションでは、プログラミング言語に触れたことがない方から、ある程度経験のある方まで幅広く対象としています。経験者の方には基礎的な内容はかなり速く進めていき、NumPyにも進んでいきます。何よりもこのセッションは、実際に会場にいる皆さんのためのものです。速度を上げたり下げたりしてほしい場合や、何か明確にしてほしいことがあれば、ぜひ質問してください。これは本当に皆さんのためのセッションであり、インタラクティブなものにしたいと思っています。
2. Pythonを選ぶ理由
2.1. 高レベル言語としての特徴
最初に、なぜPythonを選ぶのかについて説明します。プログラミングを始めたばかりの多くの方はJavaから入ったかもしれませんし、他の分野ではMatlabを使う人も多いでしょう。では、なぜPythonなのでしょうか。
Pythonは非常に高水準な言語です。非常に英語に近い記述ができるため、特に初心者にとって扱いやすい言語となっています。プログラミングを始めたばかりの人にとって、理解しやすく操作しやすい言語なのです。高レベル言語とは、人間が理解しやすい言語という意味で、コンピュータが直接理解できる機械語からは遠い位置にあります。これは私たちがコードを書く際には便利ですが、コンピュータ側には多くの翻訳作業が必要になるという側面もあります。
2.2. 科学計算機能
Pythonには、Matlabに似た多くの科学的計算機能が備わっています。特にNumPyフレームワークを使うと、数学や行列に関わる非常に迅速かつ効率的な操作が可能になります。これは深層学習などの応用において非常に有用です。NumPyが提供するこれらの機能は、行列計算や数学的操作を効率良く実行するための基盤となり、特に深層学習のようなデータ処理を大量に行う分野では重要な役割を果たします。後ほど詳しく説明しますが、NumPyはC/C++の高速サブルーチンを利用しているため、大量のデータに対する計算処理が非常に高速に行えるという利点があります。
2.3. ディープラーニングフレームワークとの互換性
深層学習において特に重要な点として、PyTorchやTensorFlowなどの多くのフレームワークが直接Pythonとインターフェースしていることが挙げられます。これらの主な理由から、一般的に深層学習の分野ではPythonが使われる傾向にあります。深層学習フレームワークがPythonを採用していることで、ユーザーは複雑なモデル構築や訓練のプロセスを比較的シンプルに記述できるようになり、機械学習プロジェクトの開発効率が向上しています。深層学習の実装においてPythonは事実上の標準言語となっており、NLPの分野でも広く活用されています。
3. 言語の基本
3.1. 変数と代入
Pythonでは、変数を使って複数の値を保持することができます。変数への代入操作は等号(=)を使用して行います。Pythonの優れた点の一つは、変数の型を最初に宣言する必要がなく、その後も特定の型の値だけを代入する必要がないという柔軟性です。
例えば、他の言語では最初に「この変数Xは整数型のみ」と宣言し、それ以外の型の値を代入するとエラーになる場合がありますが、Pythonはとても柔軟です。最初にx = 10
と代入し、その後5行後にx = "hi"
という文字列を代入しても問題ありません。
また、プラスや除算などの単純な数学的演算も可能です。2つの値の累乗計算(xのy乗)は二重アスタリスク(**)を使います。浮動小数点除算を確実に行うための型キャストもできます。整数同士を除算した結果を整数として明示的に扱いたい場合は、結果をカッコで囲んでint()
を使用することもできます。
さらに、整数から文字列への型変換も可能です。例えば、数学的な操作として「10 + 3」を行うのではなく、「10 + 3」という文字列を書きたい場合、XとYの値を文字列に変換し、「+」記号も文字として追加することで文字列を作成できます。
3.2. データ型と型変換
Pythonでは、ブール値のTrueとFalseは常に大文字で始まります。他の言語では小文字かもしれませんが、これは覚えておくべき点です。また、Pythonにはnull値がなく、同等のものとしてNoneがあります。例えば、if文での条件チェックなどで、ある値が存在しないことを示したい場合にNoneを割り当てることができます。
Noneはnullの代わりとなるもので、実際には何も値を返さないことを意味します。これは0とは異なるものです。関数もNone値を返すことができます。
また、Pythonのもう一つの良い点は、リストが可変であることです。これについては後ほど詳しく説明しますが、リストは変更可能で、整数、None値、文字列などあらゆる型を含むことができます。
Pythonの英語に近い特性として、他の言語では「&&」などの二重記号を使うところを、Pythonでは実際に「and」と書くことができます。例えば「xが3に等しく、かつyが4に等しい場合はtrueを返す」というように記述できます。「and」「or」「not」を使用できるのは非常に便利です。
また、等価比較には「==」や「!=」などの演算子を使います。これは多くの言語で標準的なものであり、Pythonでも同様に使えます。等号一つ(=)は代入演算子、等号二つ(==)は等価をチェックする演算子であることを覚えておいてください。
3.3. 算術演算
Pythonでは基本的な数学操作が簡単に行えます。プラス記号とマイナス記号を使った加減算、アスタリスク(*)を使った乗算、スラッシュ(/)を使った除算などが可能です。また、二重アスタリスク(**)を使って累乗計算も行えます。これはある値を別の値で累乗する操作で、例えばxのy乗を計算する場合に使います。
浮動小数点数の除算を確実に行うための型キャストも可能です。整数同士の除算結果が浮動小数点数になるようにしたい場合は、値をfloat型にキャストできます。逆に、結果を明示的に整数にしたい場合は、結果をint型にキャストすることもできます。
さらに、整数から文字列への型変換もできます。例えば、10 + 3という数学的演算を行うのではなく、「10 + 3」という文字列を作りたい場合、XとYの値を文字列に変換し、「+」記号も文字として追加することで実現できます。
3.4. ブール値と比較
Pythonでは、ブール値であるTrueとFalseは常に大文字で始まります。これは他の言語では小文字かもしれませんが、Pythonの特徴の一つです。また、Pythonにはnull値が存在せず、その代わりとしてNoneを使います。例えば、if文での条件チェックなどで「この値には値がない」と言いたい場合、Noneを割り当てることができます。
Noneはnullに相当するもので、「実際には何も返していない」「値がない」ということを意味します。これは0とは異なります。また、関数もNone値を返すことができます。
Pythonでは、他の言語で使われる「&&」や「||」などの二重記号の代わりに、英語のような「and」「or」「not」を使います。この特性はPythonが非常に英語に近いという点の一例です。例えば「xが3に等しく、かつyが4に等しければtrueを返す」というように記述できます。
等価比較には「==」(等しい)や「!=」(等しくない)などの演算子を使います。これは多くの言語で標準的で、Pythonでも同様に使えます。重要なのは、単一の等号(=)が代入演算子であるのに対し、二重等号(==)は等価性をチェックする演算子であるという違いです。
3.5. インデントの使用
Pythonでは括弧を使用しません。代わりに、スペースかタブを使用します。基本的に2つか4つのインデントを使って、関数内の内容や、if文、for文、あるいはループなどに含まれる内容を区切ります。
重要なことは、2つのスペースにするか4つのスペースにするかは選べますが、コードベース全体で一貫して同じインデントを使用する必要があります。そうしないとエラーが発生します。
Pythonの構文は、このインデントによってコードのブロックを定義している点が特徴的です。多くの他の言語では波括弧{}などを使いますが、Pythonではインデントの深さによってコードの階層構造を表現します。これによりコードの可読性が高まりますが、一貫したインデントルールを守ることが重要です。
4. データ構造
4.1. リスト(可変配列)
リストは可変配列です。可変とは変更できるという意味で、一度宣言した後でも追加や削除ができます。リストはその目的のために最適化されており、頻繁に変更されることを想定しています。後ほどNumPy配列についても触れますが、それらは基本的に固定されており、変更する場合は追加情報を持つ新しい配列を作成する必要があります。
リストは変更に高度に最適化されているので、例えばループ内で異なる要素を追加するような場合には、リストを使うのが良いでしょう。変更が頻繁に発生するからです。
リストの使い方を見ていきましょう。まず、ZachとJayを含む名前の配列から始めます。インデックスを使ってリストの要素にアクセスできます。つまり、リスト内の要素の位置に基づいて要素を列挙できます。0は最初の要素を指します。Pythonは「ゼロインデックス」と呼ばれるものを使用しているため、0から始まり、次が1になります。ここでは0がZackになります。
リストの末尾に何かを追加したい場合は、「append」という用語を使います(addではありません)。appendを使うと、追加された最後の要素を持つ元のリスト自体を含む別のリストを作成できます。現在の長さはいくつでしょうか?3つの要素があるため3になります。これはLen関数(lengthではなく、3文字のLen)を使って簡単に取得できます。
さらに便利なのは、Pythonがリストを連結するために「+」演算をオーバーロードしていることです。ここでは別のリストがあります。リスト定義に必要なのは角括弧だけです。これは、変数に保存していなくても、AbbyとKevinを含む完全に別のリストです。「names += [Abby, Kevin]」とすることもできます。これは「names = names + [Abby, Kevin]」を意味し、これでフルリストが出力されるはずです。
リストは単に角括弧を置くか、既存のリストを使って作成できます。また、リスト内にさまざまな型を含めることができます。このリストには整数値、リスト値(リストのリストを好きなだけ持てます)、浮動小数点値、None値が含まれており、これはPythonでは完全に有効です。
スライシングとは、リストの一部のみにアクセスする方法です。例えば、この数字配列で0、1、2だけが欲しい場合、スライシングはその部分だけを抽出する方法です。スライシングの仕組みは、最初の要素は含まれ、最後の要素は除外されます。ここでは0、1、2、3から始まりますが、3は含まれないため、0、1、2が出力されます。
また、省略形もあります。配列の最初の要素から始めることがわかっている場合(0、1、2から始まり、0から始まる場合)、最初のインデックスを含める必要はなく、除外される最後のインデックスだけを含めることができます。これは「[:3]」となります。末尾の場合も同様で、例えば配列の5番目と6番目の要素から最後までを取りたい場合は、「[5:]」とできます。
面白い事実として、セミコロンだけを使うと、リスト全体を取得しますが、メモリ内に複製も作成します。これは非常に有用なことです。時にはリストを配列として渡す場合(このチュートリアルの範囲外ですが)、そのリストへの参照のみを渡すことができます。変更すると元の配列も変更されますが、これにより同じ配列の完全に別のコピーがメモリに作成されます。変更しても元の配列には影響しません。これは非常に便利な方法です。
Pythonのもう一つのユニークな機能は、負のインデックスを使用できることです。負のインデックスは配列の後ろからインデックス付けを意味します。-1は配列の最後の要素、-3は3番目から最後の要素を指します。-1が返すのは6で、-3が返すのはこの場合、すべてです。-3の要素から最後まで(-1、-2、-3)を取得するからです。
「[3:-2]」は少し混乱するかもしれませんが、これは3から始めて(0、1、2、3)、-1、-2まで行きます。リスト内で最後が除外されるため、3と4だけが得られます。これがこの意味です。
4.2. タプル(不変配列)
タプルは不変の配列です。つまり、一度宣言したらその値を変更することができません。リストと同様にZackとJayからなるタプルで始めるとします。リストと同じようにインデックスでアクセスすることができ、names[0]
のようにタプルの要素を出力できますが、もし変更しようとするとエラーが発生します。タプルは一度インスタンス化すると変更できないのです。
空のタプルを作成するには、単にタプル記号を使うか、多くの場合は単に括弧()を使用できます。例えば、ここで行ったように括弧を使ってタプルをインスタンス化することができます。
また、後で形状の話をするときに出てきますが、単一の値を持つタプルを作ることもできます。その場合は値を入れてからカンマを付けるだけです。これは、一つのアイテムだけを持つタプル(不変のリスト)があることを示しています。リストですが、一つの項目だけのものです。
4.3. 辞書(ハッシュマップ/ハッシュテーブル)
他の言語に詳しい方にとっては、これはハッシュマップやハッシュテーブルに相当するものです。辞書が本質的に役立つのは、ある値を別の値に非常に素早くマッピングすることです。例えば、宿題でよく行うことになる「文字列をインデックスにマッピングする」などの場合に非常に便利な方法です。
辞書を使うと、Zackが対応する文字列値に対応するというように辞書をインスタンス化できます。そしてその文字列値を取得したいときには、この辞書を使って、辞書の中でZackをインデックスにすることで、対応する値を出力します。これは非常に素早く行えます。
辞書は非常に役立ち、特に文字列のリストやアイテムのリストがあり、それらに対応するインデックスを持ちたい場合に非常によく使われます。NLPではしばしばインデックスや数値を扱うことになりますので、これは文字列形式から数値インデックス値に移行するための優れた方法です。
辞書では他にもいくつかのことができます。特定の要素が含まれているかどうかを確認できます。例えば、「phonebook[Monty]」のようにインデックスを試みると、その電話帳辞書にMontyという文字列がないためエラーが発生します。そのため、値を抽出する前に確認を行いたい場合があるでしょう。「Monty in phonebook」をプリントすれば、falseと表示されるはずです。同様に「Kevin in phonebook」もfalseですが、実際に辞書に存在するZackはtrueとなります。
辞書からエントリを削除したい場合は、delコマンドを使用するだけです。
5. ループ
5.1. for文の基本
ループは同じ種類の操作を最適化するための優れた方法です。また、先ほど説明したリスト型やアレイ型のオブジェクトを順番に処理するのにも優れた方法です。例えば、名前のリストがあるとして、それらすべてにどうアクセスすればよいのでしょうか。ループはそのための優れた方法です。
Pythonでは、他の言語で混乱しがちな部分の多くが抽象化されています。例えば、数値に対してループを実行する場合、range関数を呼び出します。range(5)
とすると、この関数は0、1、2、3、4という値を返し、それがこのi値に格納されます。ここでは単にそのi値を出力しています。例えば、サイズ10のリストの長さに対してループを実行したい場合は、for i in range(10)
と記述し、そのリストの対応する部分にインデックスを付けるだけです。
5.2. rangeの使用
Pythonでは、ループを使うときにrange関数が非常に便利です。この関数を呼び出すと、指定した範囲の数値シーケンスが生成されます。例えば、range(5)
と記述すると、この関数は0、1、2、3、4という値を返します。これらの値は変数(例えばi)に順番に格納され、ループの中で使用できます。
リストの長さに対してループを実行したい場合、例えばサイズ10のリストであれば、for i in range(10)
と記述し、そのリストの対応する部分にインデックスを付けることができます。この方法を使えば、特定のインデックス位置にあるリスト要素に順番にアクセスして処理を行うことができます。
range関数は非常に柔軟で、開始値、終了値、ステップサイズも指定できますが、最も基本的な使い方は上記のように終了値だけを指定する方法です。この場合、0から始まり、指定した値の一つ前まで(つまり終了値は含まない)の整数シーケンスが生成されます。
5.3. リストと辞書のイテレーション
技術的には、先ほど説明したようにリストの長さを取得してrange操作を使う必要はありません。Pythonでは、リストの要素に直接アクセスすることができるからです。ここでは、Zac、Jay、Richardという名前のリストがあります。リストの長さを取得してrange操作を行う代わりに、単に「for name in names」と記述し、その名前を出力するだけで良いのです。こうすると、リスト内の各要素に直接アクセスします。
しかし、時には両方が必要な場合もあります。つまり、Zachという要素とその配列内の位置の両方が必要な場合があります。そのような場合には、enumerate関数を使用できます。enumerateは基本的にこれら二つの値をペアにして、値(ここではname)とその配列内の対応するインデックスの両方を提供します。これは、例えばrange操作を使って範囲を取得し、それからリストにインデックスを付けるという少し複雑な方法と比べて非常に便利です。
辞書をイテレートする方法についてですが、辞書の「キー」(辞書に最初に入れたすべての最初の項目)をイテレートしたい場合は、リストと同じ方法でイテレートできます。例えば「for name in phonebook」と記述すれば、キーを出力できます。辞書に格納されている「値」をイテレートしたい場合は、「dictionary.values()」を使用する必要があります。両方が必要な場合は、「.items()」関数を使用します。これにより、キーと値の両方が出力されます。
5.4. enumerateの使用
リストの要素とそのインデックス位置の両方が必要な場合、enumerate関数が非常に便利です。enumerateは基本的にこれら二つの値をペアにして、値(例えば名前)とその配列内の対応するインデックスの両方を提供します。これは、例えばrange操作を行い、それからリストにインデックスを付けるという少し複雑な方法と比べて非常に便利です。
例えば、名前のリストがあり、それぞれの名前とその位置を出力したい場合、次のように書けます:
for index, name in enumerate(names):
print(index, name)
これにより、各イテレーションで位置(インデックス)と値(名前)の両方が提供されます。この方法は、リストの要素を処理しながらその位置情報も必要とする場合に非常に効率的です。
6. NumPyの基礎
6.1. NumPyの目的と利点
NumPyについて説明していきます。NumPyは基本的に数学的操作のために最適化されたライブラリです。研究者が数学的演算に非常に便利なMatlabを好む理由の一つですが、Pythonの解決策は、C言語やC++で書かれたサブルーチン(スクリプト)を利用して効率性に高度に最適化された別のライブラリを持つことです。
CやC++がPythonよりもはるかに高速な理由は、それらが「マシン言語」と呼ばれるコンピュータが読み取るものに近いからです。先ほど述べたように、Pythonの良い点の一つは高レベルで英語のように見えることですが、コンピュータが理解するまでにはより多くの翻訳が必要です。私たちが理解できるコードを書くときには便利ですが、大量のデータに対して多くの操作を実行するときには少し効率が落ちます。
NumPyの真の利点は、特定の形式でメモリとデータがある場合、別の言語でこれらのCのスクリプトやサブルーチンを呼び出して非常に高速にすることです。これがNumPyを使用する真の利点であり、NLPの分野ではほぼ全員がこれに非常に精通しています。例えば、非常に大きな共起行列などで多くの操作を実行するため、時間を最適化することが非常に有用です。
これが本当にNumPyの利点であり、NumPyは基本的にこれらの数学や行列、ベクトル計算のすべてに関わっています。NumPy配列はリストとは異なりますが、リストとNumPy配列の間は簡単に変換できます。NumPy配列は特にこれらのサブルーチンで使用するために設計されているため、特定の形式を持ち、異なる方法でインスタンス化されます。これとスタンダードなリストの間の変換は簡単ですが、NumPy操作はNumPy配列でのみ動作することを知っておく必要があります。NumPy操作をリストに直接行うことはできず、まず変換する必要があります(実際は簡単で、numpy.array関数を使用するだけです)。それらはNumPy配列でのみ動作することを覚えておいてください。
6.2. C/C++サブルーチンの活用
NumPyの真の強みは、CやC++で書かれたサブルーチン(サブスクリプト)を活用している点にあります。Pythonが高レベルで英語に近い言語であることは、コードを書く私たちにとっては素晴らしいことですが、コンピュータがそれを理解するまでには多くの翻訳作業が必要になります。そのため、大量のデータに対して多くの操作を実行する際には効率が落ちることがあります。
これに対してCやC++はマシン言語に近いため、Pythonよりもはるかに高速です。NumPyはこの利点を活かし、メモリとデータが特定の形式で存在する場合に、これらの高速な言語で書かれたサブルーチンを呼び出します。これにより処理が非常に高速になり、時間効率が大幅に向上します。
例えば、NLPでよく扱う共起行列のような非常に大きな行列に対して多くの操作を実行する場合、処理時間を最適化することが非常に重要です。NumPyはこのような数学的操作や行列計算において、CやC++のサブルーチンを背後で使用することで高速化を実現しています。
このため、NumPy配列は特別な形式を持っており、これらのサブルーチンで使用するために特別に設計されています。標準的なPythonのリストとNumPy配列の間の変換は簡単ですが、NumPy操作はNumPy配列でのみ動作することを覚えておく必要があります。
6.3. 行列とベクトルと張量の違い
行列とは基本的に数値の長方形構造で、特定のルールに従って異なる種類の対象間の操作が可能です。大量のデータを扱う際、個別に値を掛け合わせるのではなく、この長方形形式で保存し、行列乗算や行列数学と呼ばれる特定のルールを使って別の行列と相互作用させることができます。
ベクトルは一般的に一次元の行列と考えられます。これは慣例的なものであり、厳密なルールではありませんが、通常ベクトルは行ベクトルまたは列ベクトルを指し、一次元にのみ値のリストを持ちます。例えば、NumPy配列[1, 2, 3]は一次元のリストであり、これがベクトルと呼ばれるものです。対して、[[6, 7], [8, 9]]のような二次元配列は行と列の両方を持つため、二次元と呼ばれます。
テンソルは慣例的に二次元以上の高次元オブジェクトを指します。二次元ではなく、例えば5次元や6次元の[2, 2, 2, 2, 2]のような形状を持つこともでき、これらに対しても数学的操作が可能です。これらは一般的にテンソルと呼ばれています。
次のPyTorchチュートリアルでも触れますが、これらの大きなテンソルはGPU上で効率的に使用するために最適化されています。GPUで高速処理を行うためにはこれらのテンソルをPyTorchなどのパッケージで直接使用するため、より具体的な意味でテンソルと呼ばれています。
これらが行列、ベクトル、テンソルの間の基本的な用語の違いです。
6.4. 配列の表現と次元の扱い方
NumPyにおける行列とベクトルの表現方法を見ていきましょう。これは形状についての質問、例えば「3」と「1, 3」の違いに関わることです。
通常、NumPy配列では「3」は単に[1, 2, 3]のような3つの値を持つ一つのリストを意味します。一方「1, 3」は、リストのリストがあることを意味します。二次元がある場合、それは常にリストのリストがあることを示しています。
例えば、「1, 3」は1行3列を意味します。つまり、本質的に[3, 4, 5]という3つの値を持つ1行があるということです。それぞれが別々の列になります。
これらは簡単に形を変えることができます。これらは基本的に同じ形式ですが、NumPyの観点からは、後で説明するブロードキャスティングのような操作では、この「1, 3」形式や「3, 1」形式にする必要がある場合があります。
「3」は単に3つの数字を表し、「1, 3」は3つの要素を持つ1行を意味し、「3, 1」は各列に別々の配列があることを意味します。それぞれの周りに箱が見えるでしょう。
Xと Y の違いを見ると、一方は1つの括弧だけで、1つのリスト、つまり1つだけのリスト[1, 2, 3]であることを示しています。もう一方は2つの括弧で、1つのリストを持つリストであることを示しています。リストのリストがある場合、それが本当にこれら2つの表現の主な違いです。
例えば、別のものを作ることができます。要素は同じですが、これは「1, 3」になります。なぜなら行を示す1つの外側リストと、それらの各値を持つ1つの内側リストがあることを示しているからです。
この形状の利点は、後でブロードキャスティングについて説明する際に出てきますが、本質的には、どの次元をマッチングさせたいかを決定するのに役立ちます。時には他の行列の行にのみ「1, 3」を適用したい場合があります。時には列にのみ適用したい場合もあります。
例えば、ゼロだけの別の行列があり、結果として行に沿って「1, 2, 3, 1, 2, 3, 1, 2, 3」という行列を作りたい場合と、列に沿って「1, 1, 1, 2, 2, 2, 3, 3, 3」という行列を作りたい場合とでは、これら2つを生成する方法の違いは形状、つまりそれらの形状をどう表現するかの違いになります。同じ「1, 2, 3」の値でも、「1, 2, 3」の値を繰り返すことによって生成される結果の配列には形状の違いが必要になります。これについては後ほどブロードキャスティングの説明で触れます。これらの配列を生成するこのプロセスをブロードキャスティングと呼びます。形状を理解する真の利点はここにあります。同じ「1, 2, 3」の値は同じですが、他の配列に関連してどのように使用されるかが異なるのです。
7. NumPy配列の操作
7.1. 配列の形状と変形
ベクトルは通常、n次元のn×1または1×n次元として表現でき、これにより異なる動作が生じることがあります。行列は通常2次元で、m×nとして表現されます。これらはただの例です。
例えば、10個の値を持つ1次元リストであるこの行列aを始めとして、これを5×2の行列に変形することができます。ただし、元のサイズになるように次元が一致することを確認する必要があります。つまり、次元を掛け合わせると元のサイズになるようにします。
10の行列から始めた場合、2×5の行列や5×2の行列を作ることができます。また、10×1や1×10も作れますが、例えば3×5にはできません。元のサイズに収まらないからです。このような操作には「reshape」が非常に便利です。
なぜ括弧が2つあるのか疑問に思うかもしれません。reshapeの仕組みは基本的にタプルを受け取ることです。先ほどタプルについて説明したように、これらは不変のオブジェクトで、括弧で定義されます。外側の括弧は関数に入力するものを表し、入力するのはタプルなので、2番目の括弧のセットを使用します。
7.2. 要素ごとの操作
NumPyでは、アスタリスク(*)を要素ごとの乗算として使用できます。アスタリスクは、ある行列の各値を別の行列の対応する値と比較することを意味します。この操作では、行列が同じサイズである必要があります。これは要素ごとの行列演算であり、行列の乗算ではありません。
例えば、[1, 2, 3, 4]と[3, 3, 3, 3]がある場合、この要素ごとの乗算は1×3、2×3、3×3、4×3というように各要素を対応する要素と掛け合わせます。これにより、両方の行列の対応する位置にある要素同士の乗算結果が得られます。
このような要素ごとの操作は、同サイズの行列間で各要素に同じ操作を適用したい場合に非常に便利です。加算、減算、除算などの他の算術演算も同様に要素ごとに適用できます。
7.3. 行列乗算と内積
行列乗算は全く異なる操作です。行列乗算に馴染みのない方のために説明すると、一つの行列の行と別の行列の列を掛け合わせることになります。そのためには、最初の配列の2番目の次元が2番目の配列の最初の次元と等しい必要があります。
つまり、行列乗算を行うには、例えばa×b形状の行列とb×c形状の行列があるとき、これらの内側の次元であるbが同じである必要があります。これは行列乗算を行う際に覚えておくべき重要なポイントです。行列乗算を行う場合、次元が同じであることを確認する必要があるからです。
例えば、この操作は有効です。ただし、時々エラーが発生することもありますので、行列乗算を行う場合は、これらの次元が正確に等しいことを確認するために、形状を出力して確認すると良いでしょう。
行列乗算を行うには、いくつかの関数を使用できます。一つはnp.matmul
(NumPy行列乗算)です。また、@
演算子を使用することもできます。どちらも同じ操作を行うためオーバーロードされていますので、どちらを選んでも同じ結果が得られます。
この例では1×2
と3×4
の行列があります。これにより、1×3
と2×3
を掛け、それらの値を足すという行列乗算が行われます。
内積(ドット積)は、2つのベクトルを受け取ります。通常ベクトル、つまり1次元の行列に対して動作します。これは3×1や4×1のような単なる行列です。内積は2つの異なるベクトル間で要素ごとに掛け合わせ、それらの値を合計します。
例えば、ここでの内積は1×1 + 2×10 + 3×100
となります。NumPyでは、これをnp.dot
で2つのベクトルに対して実行できます。
1次元ベクトルに対してはこの操作が直接機能しますが、多次元行列の場合、np.dot
関数は行列乗算として扱われます。つまり、2×2行列と2×2行列のドット積は合計値を返すのではなく、行列乗算を返します。
最も分かりやすい例を挙げると、エラーが発生しているのは3×2と3を組み合わせているからです。先ほど述べたように、最後の次元が最初の次元と一致する必要があります。この問題を解決するには、例えば2を2×3に変更するなど、次元を合わせる必要があります。
要点としては、1次元ベクトルに対しては直接np.dot
を使用すれば内積値が得られますが、高次元行列に対しては行列乗算として扱われるということです。高次元値でも内積を得たい場合は、先ほどの例のように次元を合わせる必要があります。
7.4. max/minとaxisパラメータ
配列操作について説明していきましょう。例として、この配列Xがあります。単純な操作、例えば最大値を求める操作(Max)を適用する場合、時には配列全体の最大値が必要なこともあります。配列全体の最大値は何でしょうか?もちろん6です。単純にnp.max(X)
を実行すると、1つの値が返され、それは6になります。
しかし、各行の最大値が欲しい場合はどうでしょうか?各行において、例えば2、次に4、そして6という最大値が欲しい場合、どうすれば良いでしょうか?NumPyのほとんどの関数には通常「axis」という変数があり、この変数は最大値を求める次元を指定します。
考え方としては少し難しいかもしれませんが、axisとは関数を適用したい、または縮小したい次元のことを指します。つまり、元の配列の形状が3×2だとして、axis=1(または0インデックスのため0、1なので、2番目の次元)に対して最大値を適用したいということは、2番目の次元、つまり列の次元に対して最大値を求めるということです。行次元ではなく列次元に沿って比較します。
つまり、この列全体とこの列全体を比較します。軸(axis)については、通常axis=0が行軸を指し、axis=1が列軸を指します。もし覚えたくなければ、元の次元のどれを指しているかだけを覚えておけば良いです。それが比較したい、または縮小したい次元です。
少し理解するのが難しいかもしれませんが、通常、最も良い方法は、min、maxなどのさまざまな操作で試してみることです。覚えておくべきことは、axisは比較したい次元を指し、結果として得られるものではないということです。axis=1は列を意味し、列間で比較したいということです。例えば、1と2、3と4、5と6を比較するという意味です。
これが理解できたでしょうか?そして、numpy.axisを使うと、これらの列を比較しているので、基本的に結果の列を返します。axis=1で比較しているので、3つの値が返されます。列を比較し、各列には3つの値があるからです。行を比較すると、2つの値が返されることになります。
これはただのタプルですが、リストのリストではなく単なるリストであることを示します。しかし、リストのリストが欲しい場合はどうでしょうか?元の形状を保ちながら操作したい場合は、reshapeという選択肢もありますが、「keep_dims」という機能も使えます。これにより、元の次元(この場合は2次元)を維持し、3×1になりますが、単にリストだけを返すのではなく、元のX形式のコンテキストで列を維持し、2次元の値として保持します。
8. NumPy配列のインデクシング
8.1. スライシングの応用
NumPyでは、リストと同様にインデクシングを行うことができます。先ほどリストで説明したように、セミコロンだけを使うと同じ配列を取得しますが、メモリ内に複製も作成します。これは深いコピーを返し、メモリ内に完全に別のコピーが作成されることを意味します。
インデクシングについてもう少し詳しく説明していきます。例えば、3×4の行列があり、0番目と2番目の行だけを選択したい場合はどうすればよいでしょうか。便利なのは、NumPyでは異なる次元を別々にインデクスで処理できることです。
セミコロンは、その次元のすべてを選択することを意味します。例えば、ここでは2番目の次元にセミコロンがあり、すべての列の値を取っていることを意味します。それに対して、最初の次元には「numpy.array([0, 2])」があり、0インデックスと2インデックスだけ、つまり0番目の行と2番目の行だけを選択しています。
これを視覚的に表現すると、行列があって、0番目の行と2番目の行だけを選択し、すべての列を選択することになります。
同様に、列次元で選択したい場合、例えば最初と2番目の行の最初の列だけを選択したい場合も、次元ごとに別々にインデックスを付けることができます。いくつの列が欲しいか、いくつの行が欲しいかを考え、それぞれ別々にインデックスを付けます。これは、テンソル内のどれだけの次元が欲しいかに関わらず、すべての次元に適用できます。
8.2. 複数次元でのインデクシング
NumPyでは異なる次元を別々にインデックスで処理できることが大きな利点です。例えば、3×4の行列があり、0番目と2番目の行だけを選択したい場合、次のようにできます。行列のインデックスは[行, 列]の形式で指定します。
例えばX[[0, 2], :]
と書くと、0番目の行と2番目の行を選択し、すべての列(セミコロンで表される)を選択することを意味します。結果として得られるのは、元の行列から0番目と2番目の行だけを抽出した新しい行列です。
同様に、列の選択も行えます。例えばX[:, [0]]
と書くと、すべての行を選択し、0番目(最初の)列だけを選択することになります。あるいは、X[[0, 1], [0]]
とすれば、0番目と1番目の行の0番目の列の要素だけを選択できます。
このような複数次元でのインデクシングを使えば、大きな行列やテンソルから必要な部分だけを抽出し、効率的に処理することができます。これはテンソル内のどれだけの次元に対しても同様に適用でき、非常に柔軟な操作が可能です。
8.3. ブール型インデクシング
NumPyでは、条件に基づいて値を選択するブール型インデクシングも可能です。例えば、0.5より大きいすべての値をX配列から取りたい場合、それを行うことができます。
具体的には、X[X > 0.5]
のようにして、X配列内で0.5より大きいものすべてにインデックスを付けることができます。これは非常に直接的な方法で、0.5より大きい値だけを配列全体から出力します。
このブール型インデクシングは非常に強力で、複雑な条件式も使用できます。例えば、X[(X > 0.5) & (X < 0.8)]
のようにして、0.5より大きく0.8未満のすべての値を選択することも可能です。このような条件式を使ってデータをフィルタリングするのは、データ分析や処理においてよく使われる手法です。
ブール型インデクシングを使うと、条件に合致する要素だけを含む新しい配列が生成されるため、データの前処理や分析において非常に便利なツールとなります。
8.4. numpy.newaxis
numpyにはnumpy.newaxis
という機能があり、これは配列の形状を変更するための別の方法です。この機能を使うと、配列に新しい次元(axis)を追加することができます。例えば、3×4の配列を3×4×1の配列に変更したいような場合に役立ちます。
実際の例を考えてみましょう。単一のリスト(一次元配列)があり、それを二次元または三次元の配列に変換したい場合があります。例えば、3要素のリストを3×1の配列に変換したい場合、numpy.newaxis
を使うことができます。
視覚的に説明すると、これは単一のリストからリストのリストへの変換を可能にします。つまり、元のリストの各要素が、新しい二次元配列の中の独立したリストになります。
この機能はreshape
操作と同様の効果を持ちますが、特に新しい次元を追加したい場合には、より直感的に使用できることがあります。例えば、2×2の配列を2×2×1や2×1×2のような形状に変更したい場合に便利です。
numpy.newaxis
は特に、後述するブロードキャスティング操作と組み合わせる際に非常に便利です。配列の形状を調整することで、異なる形状の配列間で効率的な演算を行うことができるようになります。
9. ブロードキャスティング
9.1. 異なる形状の配列の操作
ブロードキャスティングはnumpyの最も優れた機能の一つで、異なる形状の配列間で効率的に演算を行うことができます。この機能により、異なる形状の配列でも特定のルールに従っていれば、それらを一緒に操作することが可能になります。
例えば、ゼロで満たされた配列があり、それに対して[1, 2, 3]という一次元配列を加算したい場合を考えてみましょう。ブロードキャスティングなしでは、各要素ごとに個別に加算する必要があります(element 0,0 + 1、element 0,1 + 2など)。しかし、ブロードキャスティングを使えば、[1, 2, 3]というベクトルを一度指定するだけで、行方向に沿って、または列方向に沿って自動的に複製されて加算されます。
具体的には、[1, 2, 3]というベクトルを持っていて、それを行方向に複製するか列方向に複製するかによって、異なる結果が得られます。視覚的に説明すると、[1, 2, 3]という(1,3)形状のベクトルがあり、それを列方向に複製すると、各列に[1, 2, 3]が繰り返される結果になります。一方、行方向に複製すると、各行に同じ値(例:すべての行の最初の列が1)が現れます。
numpyはこれらの操作をバックエンドで自動的に処理するため、私たちが明示的に新しい配列を作成してから加算する必要はありません。これにより、コードがシンプルになり、メモリ使用量も効率的になります。
ブロードキャスティングは特にディープラーニングで頻繁に使用されます。例えば、異なる画像のバッチに同じ重み行列を適用する場合、重み行列を何百または何千回も複製する代わりに、ブロードキャスティングによって効率的に処理されます。この機能は、第2回目の宿題で実装するフィードフォワードネットワークでも活用することになります。
9.2. ブロードキャスティングのルール
ブロードキャスティングがいつどのように実行されるかを理解するために、numpyには明確なルールがあります。ブロードキャスティングの主な2つのルールは以下の通りです。
- ブロードキャスティングは、2つの配列間のすべての次元が互換性を持つ場合にのみ可能です。
- 次元が互換性を持つとは、次元の値が等しいか、または一方が1である場合を指します。
例えば、(3,4)の形状を持つx配列があるとします。この場合、(3,1)の形状を持つy配列との演算は互換性があります。なぜなら、最初の次元はどちらも3で同じであり、2番目の次元は4と1ですが、片方が1なので互換性があると判断されます。
この場合、numpyは加算などの演算(例:x + y)を行う際に、yを2番目の次元(列方向)に沿って複製すると判断します。つまり、内部的にyを(3,4)の形状に拡張してから加算を行います。これにより、効率的に演算が実行されます。
別の例として、(1,4)の形状を持つz配列がある場合、x配列との乗算(x * z)も互換性があります。第1次元は3と1で片方が1、第2次元は4と4で同じです。この場合、numpyはzを第1次元(行方向)に複製し、(3,4)の形状にしてから乗算を行います。
複数の次元を持つ配列でも同じルールが適用されます。例えば、(3,1)と(1,3)の形状を持つ配列の演算は、どちらの次元も片方が1なので互換性があり、両方の次元で複製が行われます。
ただし、互換性のない次元同士の演算(例:6と3のような単に割り切れる関係の次元)は直接ブロードキャスティングができません。そのような場合は、reshapeを使って一方の配列を変形し、互換性を持たせる必要があります。
これらのルールを理解して活用することで、大きなデータ行列を扱う際に効率的なコードを書くことができます。ループを使った処理は通常、numpyの最適化された演算よりも何百倍も遅いため、可能な限りnumpyのブロードキャスティング機能を活用することをお勧めします。
9.3. 互換性のある次元
ブロードキャスティングでは、配列の次元が互換性を持つことが重要です。二つの次元が互換性を持つとは、それらが同じ値であるか、またはそのうちの一方が1である場合です。この条件が満たされていれば、numpyは自動的にブロードキャスティングを適用します。
例えば、(3,4)の形状のX配列と(3,1)の形状のY配列があるとします。これらは互換性があります。最初の次元は両方とも3で同じであり、2番目の次元は4と1ですが、Yの次元が1なので互換性があります。この場合、演算を行うとYは2番目の次元(列方向)に沿って複製され、最終的な形状は(3,4)になります。
同様に、(1,4)の形状のZ配列とX配列の演算も互換性があります。最初の次元は3と1で、Zの次元が1。2番目の次元は両方とも4で同じです。この場合、Zは最初の次元(行方向)に沿って複製され、結果も(3,4)の形状になります。
より複雑な例として、(3,1)の形状のB配列とその転置(1,3)との加算を考えてみましょう。転置するとB.transposeは(1,3)の形状になります。これらの配列は、各次元で片方が1なので互換性があります。この場合、演算を行うと両方の次元で複製が行われ、結果は(3,3)の形状になります。
ただし、互換性のないサイズの次元同士(例えば6と3)は、たとえ一方が他方の倍数であっても直接ブロードキャスティングはできません。そのような場合は、reshapeを使って互換性を持たせる必要があります。たとえば、(6,3)の配列を(18,1)にreshapeしてから演算し、後で元の形状に戻すといった方法が考えられます。
このように、互換性のある次元を理解することで、効率的なnumpy操作が可能になります。特に大きなデータセットを扱う際には、ループを避けてnumpyの最適化された演算機能を活用することが、処理速度の面で大きな差をもたらします。
9.4. 応用例と実装
ブロードキャスティングの実用的な例として、大規模なデータ行列に対する効率的な操作があります。例えば、1000×1000の行列Xがあり、100行目以降のすべての要素に5を加えたい場合を考えてみましょう。
視覚的に説明すると、大きな行列があり、その特定の部分(100行目から1000行目まで)のすべての要素に5を加えるような操作です。これをループを使って実装すると以下のようになります:
# ループを使った実装
for i in range(100, 1000):
for j in range(1000):
X[i, j] += 5
一方、numpyのブロードキャスティングを活用すると:
# numpyを使った実装
X[np.arange(100, 1000), :] += 5
このnp.arange(100, 1000)は、100から999までの整数を生成し、それを使って行を選択します。そして選択した部分全体に5を加えます。このアプローチは非常に簡潔であり、内部的にはC/C++で最適化された操作が行われるため、ループを使った方法と比較して数百倍速くなります。
また、ディープラーニングでの応用例として、バッチ処理があります。例えば、複数の画像(バッチ)に同じ重み行列を適用する場合、各画像に対して重み行列を複製する代わりに、ブロードキャスティングを使うことで、効率的に計算できます。
第2回の宿題では、このようなブロードキャスティングの技術を活用してフィードフォワードネットワークをnumpyで実装することになります。入力画像に対して重み行列Wを適用し、複数の画像(バッチ)に対して同じ操作を効率的に行うことが求められます。
重要なのは、大きなデータ構造を扱う際には、可能な限りnumpyの最適化機能を活用し、ループ処理を避けることです。numpy配列の操作は内部的にC/C++で最適化されており、特に行列演算などの数学的操作においては、Pythonのループよりも桁違いに高速です。
10. 効率的なコーディング
10.1. ループよりNumPy操作の活用
大規模なデータ行列を扱う際には、可能な限りループを避け、NumPyの最適化された操作を活用すべきです。ループは通常、NumPyの最適化された操作と比較して約100倍遅くなります。これは、NumPyが内部的にC/C++で記述された高度に最適化されたサブルーチンを使用しているからです。
具体的な例を見てみましょう。1000×1000の行列Xがあり、100行目以降のすべての要素に5を加える操作を考えます。これをPythonのループを使って実装すると、すべての要素に対して繰り返し処理を行う必要があります:
# ループを使った実装方法
for i in range(100, 1000):
for j in range(1000):
X[i, j] += 5
一方、NumPyを使った実装は以下のようになります:
# NumPyを使った実装方法
X[np.arange(100, 1000), :] += 5
NumPyの実装では、np.arange(100, 1000)が100から999までの整数の配列を生成し、これを使って行を選択します。そして選択した行すべてに対して一度に加算操作を行います。この方法は非常に簡潔であり、処理速度も格段に速いです。
これがNumPyが機械学習やNLPなどの分野で広く使われる理由の一つです。大規模な共起行列などの操作を効率的に行うためには、このような最適化が不可欠です。
効率的なコーディングのために覚えておくべき重要なポイントは、NumPyを使う際にはベクトル化した操作(配列全体に対する操作)を心がけ、個々の要素に対するループ処理は避けるということです。これにより、コードは簡潔になり、実行速度も大幅に向上します。
10.2. 効率的なコードの例
効率的なNumPyコードと非効率的なループコードの比較例を見てみましょう。この例では、1000×1000の大きな行列Xがあり、100行目以降のすべての要素に5を加える操作を実装します。
まず、ループを使った実装方法です:
# ループを使った実装方法
for i in range(100, 1000):
for j in range(1000):
X[i, j] += 5
この方法では、各要素に対して個別にアクセスして値を変更するため、合計900,000回(900行×1000列)の操作が必要になります。Pythonは解釈型言語であるため、これらの操作は非常に遅くなります。
一方、NumPyを使った実装は以下のようになります:
# NumPyを使った実装方法
X[np.arange(100, 1000), :] += 5
この方法では、np.arange(100, 1000)が100から999までの整数配列を生成し、これを使って該当する行をすべて一度に選択します。そして、選択したすべての要素に対して一度に加算操作を行います。
NumPyの実装は内部的にC/C++で最適化されたコードを実行するため、ループを使った方法と比較して何百倍も速く実行されます。また、コードも非常に簡潔で読みやすくなります。
実際のパフォーマンス比較を行うと、大規模な行列(1000×1000以上)の操作では、NumPyの方法がループよりも数百倍速いことがわかります。特に機械学習やディープラーニングのような計算集約的なタスクでは、この速度の差が非常に重要になります。
効率的なコードを書くために、常に可能な限りNumPyの配列操作を使用し、Pythonのループは避けるようにしましょう。これは特に、第2回の宿題で実装するフィードフォワードネットワークのような計算集約的なタスクで重要になります。
10.3. 大規模配列操作の最適化
大規模な配列操作を最適化する際に、NumPyの強みを最大限に活用することが重要です。特に大きなデータセットを扱う場合、メモリ使用量と計算速度の両方を考慮した最適化が必要になります。
NumPyを使った大規模配列操作の最適化のポイントは以下の通りです。
まず、可能な限りブロードキャスティングを活用しましょう。例えば、大きな行列に対して特定の操作を行う場合、ループを使わずにブロードキャスティングを使うことで、コードが簡潔になるだけでなく、処理速度も大幅に向上します。
また、配列のビュー(view)とコピー(copy)の違いを理解することも重要です。NumPyの多くの操作はビューを返すため、元の配列のメモリを共有します。大規模な配列を扱う場合、不要なコピーを避けることでメモリ使用量を削減できます。ただし、元の配列に影響を与えたくない場合は、明示的にコピーを作成する必要があります。
さらに、NumPyの関数を使ってベクトル化した操作を行うことで、Python自体のループを避けることができます。例えば、np.sumやnp.meanなどの集計関数、np.whereなどの条件付き操作を使うことで、コードを簡潔にしつつ処理速度を向上させることができます。
特に機械学習やNLPでは、共起行列や大規模な単語埋め込み行列など、非常に大きな配列を扱うことが多いです。このような場合、メモリ効率の良いデータ型(例:np.float32)を使用したり、疎行列(sparse matrix)を活用したりすることも効果的な最適化手法です。
最後に、本当に必要な場合のみ、numbaやCythonなどのJITコンパイラを活用して、さらなる最適化を図ることもできます。これらのツールを使うことで、Python/NumPyコードをC/C++に近い速度で実行することが可能になります。
大規模配列操作の最適化は、特に第2回の宿題で実装するフィードフォワードネットワークのように、大量のデータと複雑な数学的操作を扱う場合に非常に重要です。効率的なNumPy操作を習得することで、データ分析やモデル開発の生産性と性能を大幅に向上させることができます。