最新の C++ でゼロから作るディープラーニング [1/8]: 基本

1. 説明

        機械学習フレームワークと研究および産業との関連性について言及します。現在、スケーラビリティと柔軟性の理由から、Google TensorFlowまたはMeta PyTorchを使用していないプロジェクトはほとんどありません。そうは言っても、機械学習アルゴリズムを最初から、つまり基盤となるフレームワークをまったく使用せずにコーディングするのに時間を費やすのは直観に反しているように思えるかもしれません。しかし、そうではありません。アルゴリズムを自分でコーディングすると、アルゴリズムがどのように機能するのか、モデルが実際に何をしているのかを明確かつ確実に理解できます。

      このシリーズでは、単純な最新の C++ のみを使用して、畳み込み、バックプロパゲーション、活性化関数、オプティマイザー、ディープ ニューラル ネットワークなど、知っておくべきディープ ラーニング アルゴリズムを作成する方法を学びます。

        最新の C++ 言語機能と、深層学習および機械学習モデルのコーディングに関連するプログラミングの詳細を学ぶことから、ストーリーの旅を始めます。

        他のストーリーもチェックしてください:

1 — C++ での 2D 畳み込みのコーディング

2 — Lambda を使用したコスト関数

3 — 勾配降下法の実装

4 — アクティベーション関数

...その他も近日公開予定です。

私に作れないものは、わかりません。— リチャード・ファインマン

2. 新しいスタイルの C++ と ヘッダー<algorithm><numeric>

        かつては古代の言語だった C++ は、過去 10 年間で劇的に変化しました。大きな変更点の 1 つは、関数型プログラミングのサポートです。ただし、より良く、より速く、より安全な機械学習コードを開発するために、他にもいくつかの改善が導入されました。

        ここでのタスクのために、便利な共通ルーチンのセットが C++ とヘッダーに含まれています。例示的な例として、次のようにして 2 つのベクトルの内積を取得できます。<numeric><algorithm>

#include <numeric>
#include <iostream>

int main()
{
    std::vector<double> X {1., 2., 3., 4., 5., 6.};
    std::vector<double> Y {1., 1., 0., 1., 0., 1.};
 
    auto result = std::inner_product(X.begin(), X.end(), Y.begin(), 0.0);
    std::cout << "Inner product of X and Y is " << result << '\n';
    return 0;
}

次のような関数を使用します。accumulatereduce

std::vector<double> V {1., 2., 3., 4., 5.};

double sum = std::accumulate(V.begin(), V.end(), 0.0);

std::cout << "Summation of V is " << sum << '\n';

double product = std::accumulate(V.begin(), V.end(), 1.0, std::multiplies<double>());

std::cout << "Productory of V is " << product << '\n';

double reduction = std::reduce(V.begin(), V.end(), 1.0, std::multiplies<double>());

std::cout << "Reduction of V is " << reduction << '\n';

ヘッダーには、、、、、、などの便利なルーチンが多数含まれています。わかりやすい例を見てみましょう。algorithmstd::transformstd::for_eachstd::countstd::uniquestd::sort

#include <algorithm>
#include <iostream>

double square(double x) {return x * x;}

int main() 
{
    std::vector<double> X {1., 2., 3., 4., 5., 6.};
    std::vector<double> Y(X.size(), 0);
 
    std::transform(X.begin(), X.end(), Y.begin(), square);
    std::for_each(Y.begin(), Y.end(), [](double y){std::cout << y << " ";});
    std::cout << "\n";
    
    return 0;
}

最新の C++ では、 or ループを明示的に使用する代わりに、 、 、 などの関数を使用して、ファンクター、ラムダ、さらにはバニラ関数を引数として渡すことができることがわかりました。forwhilestd::transformstd::for_eachstd::generate_n

上記の例は、  GitHub のこのリポジトリにあります

ちなみにラムダです。次に、関数型プログラミングとラムダについて話しましょう。[](double v){...}

3. 関数型プログラミング

        C++ はマルチパラダイム プログラミング言語です。つまり、これを使用して、OOP、手続き型、最近では関数型など、さまざまな「スタイル」を使用するプログラムを作成できます。

        関数型プログラミングの C++ サポートはヘッダーから始まります。<functional>

#include <algorithm> // std::for_each 
#include <functional> // std::less, std::less_equal, std::greater, std::greater_equal
#include <iostream> // std::cout

int main() 
{

    std::vector<std::function<bool(double, double)>> comparators 
    {
        std::less<double>(), 
        std::less_equal<double>(), 
        std::greater<double>(), 
        std::greater_equal<double>()
    };

    double x = 10.;
    double y = 10.;
    auto compare = [&x, &y](const std::function<bool(double, double)> &comparator)
    {
            bool b = comparator(x, y);
            std::cout << (b?"TRUE": "FALSE") << "\n";
    };

    std::for_each(comparators.begin(), comparators.end(), compare);

    return 0;
}

ここでは、ポインターの代わりに多態性呼び出しの例として、 、 、 、 を使用します。std::functionstd::lessstd::less_equalstd::greaterstd::greater_equal

すでに説明したように、C++11 には関数型プログラミングをサポートするための言語のコアへの変更が含まれています。これまでにそのうちの 1 つを見てきました。

auto compare = [&x, &y](const std::function<bool(double, double)> &comparator)
{
    bool b = comparator(x, y);
    std::cout << (b?"TRUE": "FALSE") << "\n";
};

このコードはラムダを定義し、ラムダは関数オブジェクト、つまり呼び出し可能なオブジェクトを定義します。

これはラムダ名ではなく、ラムダが割り当てられる変数の名前であることに注意してください 。実際、ラムダは匿名オブジェクトです。compare

このラムダは、キャプチャ リスト ( )、パラメータ リスト ( )、本体 (中かっこ間のコード) の 3 つの句で構成されます。[&x, &y]const std::function<boll(double, double)> &comparator{...}

パラメーター リストと本体句は、通常の関数と同様に機能します。Capture 句は、ラムダの本体でアドレス指定できる外部変数のセットを指定します。

ラムダは非常に便利です。古いスタイルのファンクターのように宣言して渡すことができます。たとえば、  L2 正規化 ラムダを定義できます。

auto L2 = [](const std::vector<double> &V)
{
    double p = 0.01;
    return std::inner_product(V.begin(), V.end(), V.begin(), 0.0) * p;
};

そしてそれを引数としてレイヤーに返します。

auto layer = new Layer::Dense();
layer.set_regularization(L2)

デフォルトでは、ラムダは副作用を引き起こしません。つまり、ラムダは外部メモリ空間内のオブジェクトの状態を変更できません。ただし、必要に応じてラムダを定義できます。次のモメンタムの実装を考えてみましょう。mutable

#include <algorithm>
#include <iostream>

using vector = std::vector<double>;

int main() 
{

    auto momentum_optimizer = [V = vector()](const vector &gradient) mutable 
    {
        if (V.empty()) V.resize(gradient.size(), 0.);
        std::transform(V.begin(), V.end(), gradient.begin(), V.begin(), [](double v, double dx) 
        {
            double beta = 0.7;
            return v = beta * v + dx; 
        });
        return V;
    };

    auto print = [](double d) { std::cout << d << " "; };

    const vector current_grads {1., 0., 1., 1., 0., 1.};
    for (int i = 0; i < 3; ++i) 
    {
        vector weight_update = momentum_optimizer(current_grads);
        std::for_each(weight_update.begin(), weight_update.end(), print);
        std::cout << "\n";
    }

    return 0;
}

        パラメータとして同じ値を渡しても、各呼び出しの結果は異なる値になります。これは、キーワード を使用しているために発生します。momentum_optimizer(current_grads)mutable

        私たちの現在の目的にとって、関数型プログラミングのパラダイムは特に価値があります。関数型機能を使用することで、より少ない量でより堅牢なコードを記述し、より複雑なタスクをより速く実行できるようになります。

4. 行列および線形代数ライブラリ

        まあ、私が「純粋な C++」と言っても、それは完全には真実ではありません。ソリッド線形代数ライブラリを使用してアルゴリズムを実装します。

        行列とテンソルは、機械学習アルゴリズムの構成要素です。C++ には組み込みの行列実装はありません (また、実装すべきではありません)。幸いなことに、 Eigen や Armadilloなど、成熟した優れた線形代数ライブラリがいくつか利用可能です 

        私はEigenを何年も使っています。Eigen (Mozilla Public License 2.0 に基づく) はヘッダーのみであり、サードパーティのライブラリには依存しません。したがって、このストーリー以降では線形代数のバックエンドとして Eigen を使用します。

5、一般的な行列演算

最も重要な行列演算は、行列間の乗算です。

#include <iostream>
#include <Eigen/Dense>

int main(int, char **) 
{
    Eigen::MatrixXd A(2, 2);
    A(0, 0) = 2.;
    A(1, 0) = -2.;
    A(0, 1) = 3.;
    A(1, 1) = 1.;

    Eigen::MatrixXd B(2, 3);
    B(0, 0) = 1.;
    B(1, 0) = 1.;
    B(0, 1) = 2.;
    B(1, 1) = 2.;
    B(0, 2) = -1.;
    B(1, 2) = 1.;

    auto C = A * B;

    std::cout << "A:\n" << A << std::endl;
    std::cout << "B:\n" << B << std::endl;
    std::cout << "C:\n" << C << std::endl;

    return 0;
}

        一般にこの操作の計算量はO(N³)と 呼ばれます機械学習で広く使用されているように、私たちのアルゴリズムは行列のサイズに強く影響されます。mulmatmulmat

別のタイプの行列の乗算について話しましょう。場合によっては、係数行列の乗算だけが必要な場合もあります。

auto D = B.cwiseProduct(C);
std::cout << "coefficient-wise multiplication is:\n" << D << std::endl;

もちろん、係数乗算ではパラメータの次元が一致している必要があります。同様に、行列を加算または減算できます。

auto E = B + C;
std::cout << "The sum of B & C is:\n" << E << std::endl;

最後に、3 つの非常に重要な行列演算、 、 、および: について説明します。transposeinversedeterminant

std::cout << "The transpose of B is:\n" << B.transpose() << std::endl;
std::cout << "The A inverse is:\n" << A.inverse() << std::endl;
std::cout << "The determinant of A is:\n" << A.determinant() << std::endl;

逆行列、転置、行列式はモデルを実装するための基礎です。もう 1 つの重要なポイントは、関数を行列の各要素に適用することです。

auto my_func = [](double x){return x * x;};
std::cout << A.unaryExpr(my_func) << std::endl;

上記の例はここにあります

6. ベクトル化について一言

        最新のコンパイラとコンピュータ アーキテクチャは、ベクトル化と呼ばれる機能拡張を提供します。つまり、ベクトル化により、複数のレジスタを使用して独立した算術演算を並行して実行できるようになります。たとえば、次の for ループは次のとおりです。

for (int i = 0; i < 1024; i++) 
{
    A[i] = A[i] + B[i];
}

        サイレントにベクトル化されたバージョンに置き換えます。

for(i=0; i < 512; i += 2) 
{ A[i] =
 A[i] + B[i];
A[i + 1] = A[i + 1] + B[i + 1 ];
}

        コンパイラによって。重要なのは、命令が命令と同時に実行されることです。これが可能なのは、2 つの命令が互いに独立しており、基礎となるハードウェアに重複したリソース、つまり 2 つの実行ユニットがあるためです。A[i + 1] = A[i + 1] + B[i + 1]A[i] = A[i] + B[i]

        ハードウェアに 4 つの実行ユニットがある場合、コンパイラは次のようにループを展開します。

for(i=0; i < 256; i += 4) 
{ A[i] =
 A[i] + B[i] ;
A[i + 1] = A[i + 1] + B[i + 1]; 
 A[i + 2] = A[i + 2] + B[i + 2]; 
 A[i + 3] = A[i + 3] + B[i +  3];
}

        このベクトル化バージョンにより、プログラムは元のバージョンより 4 倍高速に実行されます。このパフォーマンスの向上は、元のプログラムの動作には影響しないことに注意してください。

        ベクトル化はコンパイラ、OS、ハードウェアによって内部的に実行されますが、ベクトル化を許可するようにコーディングする場合は注意する必要があります。

  • プログラムのコンパイルに必要なベクトル化フラグを有効にします。
  • ループを開始する前に、ループの境界が動的または静的であることがわかっている必要があります。
  • ループ本体ディレクティブは前の状態を参照しないでください。たとえば、場合によっては、現在の命令の呼び出し中にコンパイラがベクトル化が有効かどうかを安全に判断できないため、このようなことによりベクトル化が妨げられる可能性があります。A[i] = A[i — 1] + B[i]A[i-1]
  • ループ本体は単純な直線コードで構成されている必要があります。関数呼び出しや以前にベクトル化された関数も許可されます。ただし、複雑なロジック、サブルーチン、ネストされたループ、関数呼び出しにより、ベクトル化が機能しないことがよくあります。inline

場合によっては、これらのルールに従うのが簡単ではありません。複雑さとコード サイズを考慮すると、コンパイラがコードの特定の部分をいつベクトル化したかを判断するのが難しい場合があります。

経験則として、コードがコンパクトで単純であればあるほど、ベクトル化が容易になります。したがって、 、 、 、および STL コンテナの標準関数を使用すると、ベクトル化される可能性がより高いコードが表示されます。<numeric>algorithmfunctional

7. 機械学習におけるベクトル化

        ベクトル化は機械学習において重要な役割を果たします。たとえば、バッチはベクトル化された方法で処理されることが多く、大きなバッチを含むトレインは、小さなバッチを含む (またはバッチを含まない) トレインよりも高速に実行されます。

        私たちの行列代数ライブラリはベクトル化を徹底的に使用するため、処理を高速化するために行データをバッチに集約することがよくあります。次の例を考えてみましょう。

ベクトル化の例 — 著者

        6 つのベクトルのそれぞれと 1 つのベクトルの間で 6 つの内積を実行して 6 つの出力を取得する代わりに、入力ベクトルをスタックして 6 行の行列をマウントし、1 回の乗算で 1 回実行できます。XiVY0Y1MmulmatY = M*V

        出力はベクトルです。最終的に要素のバインドを解除して、必要な 6 つの出力値を取得できます。Y

8. 結論と次のステップ

        これは、最新の C++ を使用して深層学習アルゴリズムを作成する方法に関する入門的な話です。関数型プログラミング、代数微積分、ベクトル化など、高性能機械学習プログラムの開発における非常に重要な側面をカバーします。

        GPU プログラミングや分散トレーニングなど、現実世界の ML プロジェクトに関連するプログラミング トピックの一部は、ここでは取り上げられていません。これらのテーマについては今後のストーリーで説明します。

次のストーリーでは、深層学習の最も基本的な演算である 2D 畳み込みをコーディングする方法を学びます。

九、参考人

C++ リファレンス

特性線形代数ライブラリ

C++ のラムダ式

インテルのベクトル化の要点

おすすめ

転載: blog.csdn.net/gongdiwudu/article/details/132161198