2019/05/16


これまで Go で TensorFlow を扱うのは推論しかやった事が無かったけど、API を眺めていたら学習できそうだったので試してみた。

Go による機械学習 推論フレームワークの最新動向 2019 - Qiita

Golang で推論 昨今では「機械学習と言えば Python」「Python と言えば機械学習」と思われがちなのですが、推論用途であれば学習済みモデルを利用して色々なプログラミング言語から扱えます。...

https://qiita.com/mattn/items/b01f9bb5c2fa3678734a

ただし色々調べてみたのだけどグラフ定義を出力するのは難しかった。難しかったというか Protocol Buffer 形式のファイルを出力するまでは出来たのだけど Python 版でいうオペレータの名付けであったり勾配降下法(GradientDescentOptimizer)の作り方がいまいち分からなかった。また Go でグラフ定義を出力すると、Python が吐く様な init, train, save/control_dependency, save/restore_all といったモデルの保存オペレータが生成されない。なのでこの記事では Python でグラフ定義を出力し、そこから入力と出力を与える様なミニバッチを走らせ、checkpoint を保存するという形で学習させた。

ベースは asimshankar さんが書いたC言語による実装。

Training TensorFlow models in C - GitHub

Training TensorFlow models in C Python is the primary language in which TensorFlow models are typica...

https://gist.github.com/asimshankar/7c9f8a9b04323e93bb217109da8c7ad2

まず Python でグラフ定義を作る。

x = tf.placeholder(tf.float32, shape=[None,1,1], name='input')
y = tf.placeholder(tf.float32, shape=[None,1,1], name='target')
y_ = tf.identity(tf.layers.dense(x, 1), name='output')
loss = tf.reduce_mean(tf.square(y_ - y), name='loss')
optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.01)
train_op = optimizer.minimize(loss, name='train')
init = tf.global_variables_initializer()
saver_def = tf.train.Saver().as_saver_def()

with open('graph.pb''wb'as f:
  f.write(tf.get_default_graph().as_graph_def().SerializeToString())

この Python が出力したグラフ定義には、モデルを操作する為のオペレータも一緒に出力されているので、それを以下の様に参照して呼び出せる様にする。

func createModel(graph_def_filename string) (*model_t, error) {
    model := &model_t{}

    model.graph = tf.NewGraph()

    var err error

    // create the session.
    sessionOpts := &tf.SessionOptions{}
    model.session, err = tf.NewSession(model.graph, sessionOpts)
    if err != nil {
        return nil, err
    }

    b, err := ioutil.ReadFile(graph_def_filename)
    if err != nil {
        return nil, err
    }

    err = model.graph.Import(b, "")
    if err != nil {
        return nil, err
    }

    model.input.Op = model.graph.Operation("input")
    model.input.Index = 0
    model.target.Op = model.graph.Operation("target")
    model.target.Index = 0
    model.output.Op = model.graph.Operation("output")
    model.output.Index = 0

    model.initOp = model.graph.Operation("init")
    model.trainOp = model.graph.Operation("train")
    model.saveOp = model.graph.Operation("save/control_dependency")
    model.restoreOp = model.graph.Operation("save/restore_all")

    model.checkpointFile.Op = model.graph.Operation("save/Const")
    model.checkpointFile.Index = 0

    return model, nil
}

これで init というオペレータを実行すると空のモデルとして初期化される。

func initializeModel(model *model_t) error {
    _, err := model.session.Run(
        nil,
        nil,
        []*tf.Operation{model.initOp})
    return err
}

また save や restore で checkpoint を更新したり学習済みモデルを保存する事ができる。

func createCheckpoint(model *model_t, checkpoint_prefix string, save boolerror {
    t, err := tf.NewTensor(checkpoint_prefix)
    if err != nil {
        return err
    }

    var op *tf.Operation
    if save {
        op = model.saveOp
    } else {
        op = model.restoreOp
    }

    _, err = model.session.Run(
        map[tf.Output]*tf.Tensor{model.checkpointFile: t},
        nil,
        []*tf.Operation{op})
    if err != nil {
        return err
    }
    return nil
}

ここまで出来ればあとはセッションを実行するだけになる。モデルとしては「3かけて2を足す」計算。3層なので配列操作が若干面倒。

func train(model *model_t) error {
    var inputs [10][1][1]float32
    var targets [10][1][1]float32
    for i := 0; i < len(inputs); i++ {
        inputs[i][0][0= rand.Float32()
        targets[i][0][0= 3.0*inputs[i][0][0+ 2.0
    }
    x, err := tf.NewTensor(inputs)
    if err != nil {
        return err
    }
    y, err := tf.NewTensor(targets)
    if err != nil {
        return err
    }

    _, err = model.session.Run(
        map[tf.Output]*tf.Tensor{
            model.input:  x,
            model.target: y,
        },
        nil,
        []*tf.Operation{model.trainOp})
    return err
}

また predict は以下の通り。

func predict(model *model_t, batch []float32error {
    b := make([][1][1]float32len(batch))
    for i, v := range batch {
        b[i][0][0= v
    }

    t, err := tf.NewTensor(b)
    if err != nil {
        return err
    }

    result, err := model.session.Run(
        map[tf.Output]*tf.Tensor{model.input: t},
        []tf.Output{model.output},
        nil)
    if err != nil {
        return err
    }
    predictions := result[0].Value().([][][]float32)

    println("Predictions:")
    for i := 0; i < len(predictions); i++ {
        fmt.Printf("\t x = %f, predicted y = %f\n", batch[i], predictions[i][0][0])
    }
    return nil
}

このプログラムを動かすと、始めは計算を予測できないのでデタラメな結果が返る。

Initial predictions
Predictions:
         x = 1.000000, predicted y = 0.349265
         x = 2.000000, predicted y = 0.698529
         x = 3.000000, predicted y = 1.047794

上記の学習用ミニバッチ train を200回呼び出すと、おおよそ「3かけて2を足す」に近い結果がでる。

Training for a few steps
Updated predictions
Predictions:
         x = 1.000000, predicted y = 4.589828
         x = 2.000000, predicted y = 6.679979
         x = 3.000000, predicted y = 8.770130

このプログラムはミニバッチを実行した結果を checkpoint として保存するので、実行する毎に前回のモデルを参考に期待の結果に近い値を返す様になる。10回プログラムを実行(学習は200回ずつなので計2000回)した結果は以下の通り。

Predictions:
         x = 1.000000, predicted y = 4.947572
         x = 2.000000, predicted y = 7.833277
         x = 3.000000, predicted y = 10.718983

おおよそ「3かけて2を足す」に近い結果になった。グラフ定義の出力こそ出来ていないが、これは今後必要なインタフェースが無いと分かれば TensorFlow に pull-request を投げるかもしれない。

出来上がったソースコードは Gist に上げておいた。

main.go - GitHub

You signed in with another tab or window. Reload to refresh your session. You signed out in another ...

https://gist.github.com/5ae333847399209d75f1d3e52631f002
Posted at by



2019/05/12


なんとなく SQLite3 でロジスティック回帰できたら面白そうと思ったので作ってみた。

データセットは iris、sqlflow の DDL を使わせて頂いた。

sqlflow/example/datasets at develop · sql-machine-learning/sqlflow - GitHub

It should print the number of rows as the following: count(*) 10 Troubleshooting It usually takes ab...

https://github.com/sql-machine-learning/sqlflow/tree/develop/example/datasets
CREATE DATABASE IF NOT EXISTS iris;
DROP TABLE IF EXISTS iris.train;
CREATE TABLE iris.train (
       sepal_length float,
       sepal_width  float,
       petal_length float,
       petal_width  float,
       class int);
INSERT INTO iris.train VALUES(6.4,2.8,5.6,2.2,2);
INSERT INTO iris.train VALUES(5.0,2.3,3.3,1.0,1);
INSERT INTO iris.train VALUES(4.9,2.5,4.5,1.7,2);
INSERT INTO iris.train VALUES(4.9,3.1,1.5,0.1,0);
INSERT INTO iris.train VALUES(5.7,3.8,1.7,0.3,0);
INSERT INTO iris.train VALUES(4.4,3.2,1.3,0.2,0);
INSERT INTO iris.train VALUES(5.4,3.4,1.5,0.4,0);
INSERT INTO iris.train VALUES(6.9,3.1,5.1,2.3,2);
INSERT INTO iris.train VALUES(6.7,3.1,4.4,1.4,1);
INSERT INTO iris.train VALUES(5.1,3.7,1.5,0.4,0);
INSERT INTO iris.train VALUES(5.2,2.7,3.9,1.4,1);
INSERT INTO iris.train VALUES(6.9,3.1,4.9,1.5,1);
INSERT INTO iris.train VALUES(5.8,4.0,1.2,0.2,0);
INSERT INTO iris.train VALUES(5.4,3.9,1.7,0.4,0);
INSERT INTO iris.train VALUES(7.7,3.8,6.7,2.2,2);
INSERT INTO iris.train VALUES(6.3,3.3,4.7,1.6,1);
INSERT INTO iris.train VALUES(6.8,3.2,5.9,2.3,2);
INSERT INTO iris.train VALUES(7.6,3.0,6.6,2.1,2);
INSERT INTO iris.train VALUES(6.4,3.2,5.3,2.3,2);
INSERT INTO iris.train VALUES(5.7,4.4,1.5,0.4,0);
INSERT INTO iris.train VALUES(6.7,3.3,5.7,2.1,2);
INSERT INTO iris.train VALUES(6.4,2.8,5.6,2.1,2);
INSERT INTO iris.train VALUES(5.4,3.9,1.3,0.4,0);
INSERT INTO iris.train VALUES(6.1,2.6,5.6,1.4,2);
INSERT INTO iris.train VALUES(7.2,3.0,5.8,1.6,2);
INSERT INTO iris.train VALUES(5.2,3.5,1.5,0.2,0);
INSERT INTO iris.train VALUES(5.8,2.6,4.0,1.2,1);
INSERT INTO iris.train VALUES(5.9,3.0,5.1,1.8,2);
INSERT INTO iris.train VALUES(5.4,3.0,4.5,1.5,1);
INSERT INTO iris.train VALUES(6.7,3.0,5.0,1.7,1);
INSERT INTO iris.train VALUES(6.3,2.3,4.4,1.3,1);
INSERT INTO iris.train VALUES(5.1,2.5,3.0,1.1,1);
INSERT INTO iris.train VALUES(6.4,3.2,4.5,1.5,1);
INSERT INTO iris.train VALUES(6.8,3.0,5.5,2.1,2);
INSERT INTO iris.train VALUES(6.2,2.8,4.8,1.8,2);
INSERT INTO iris.train VALUES(6.9,3.2,5.7,2.3,2);
INSERT INTO iris.train VALUES(6.5,3.2,5.1,2.0,2);
INSERT INTO iris.train VALUES(5.8,2.8,5.1,2.4,2);
INSERT INTO iris.train VALUES(5.1,3.8,1.5,0.3,0);
INSERT INTO iris.train VALUES(4.8,3.0,1.4,0.3,0);
INSERT INTO iris.train VALUES(7.9,3.8,6.4,2.0,2);
INSERT INTO iris.train VALUES(5.8,2.7,5.1,1.9,2);
INSERT INTO iris.train VALUES(6.7,3.0,5.2,2.3,2);
INSERT INTO iris.train VALUES(5.1,3.8,1.9,0.4,0);
INSERT INTO iris.train VALUES(4.7,3.2,1.6,0.2,0);
INSERT INTO iris.train VALUES(6.0,2.2,5.0,1.5,2);
INSERT INTO iris.train VALUES(4.8,3.4,1.6,0.2,0);
INSERT INTO iris.train VALUES(7.7,2.6,6.9,2.3,2);
INSERT INTO iris.train VALUES(4.6,3.6,1.0,0.2,0);
INSERT INTO iris.train VALUES(7.2,3.2,6.0,1.8,2);
INSERT INTO iris.train VALUES(5.0,3.3,1.4,0.2,0);
INSERT INTO iris.train VALUES(6.6,3.0,4.4,1.4,1);
INSERT INTO iris.train VALUES(6.1,2.8,4.0,1.3,1);
INSERT INTO iris.train VALUES(5.0,3.2,1.2,0.2,0);
INSERT INTO iris.train VALUES(7.0,3.2,4.7,1.4,1);
INSERT INTO iris.train VALUES(6.0,3.0,4.8,1.8,2);
INSERT INTO iris.train VALUES(7.4,2.8,6.1,1.9,2);
INSERT INTO iris.train VALUES(5.8,2.7,5.1,1.9,2);
INSERT INTO iris.train VALUES(6.2,3.4,5.4,2.3,2);
INSERT INTO iris.train VALUES(5.0,2.0,3.5,1.0,1);
INSERT INTO iris.train VALUES(5.6,2.5,3.9,1.1,1);
INSERT INTO iris.train VALUES(6.7,3.1,5.6,2.4,2);
INSERT INTO iris.train VALUES(6.3,2.5,5.0,1.9,2);
INSERT INTO iris.train VALUES(6.4,3.1,5.5,1.8,2);
INSERT INTO iris.train VALUES(6.2,2.2,4.5,1.5,1);
INSERT INTO iris.train VALUES(7.3,2.9,6.3,1.8,2);
INSERT INTO iris.train VALUES(4.4,3.0,1.3,0.2,0);
INSERT INTO iris.train VALUES(7.2,3.6,6.1,2.5,2);
INSERT INTO iris.train VALUES(6.5,3.0,5.5,1.8,2);
INSERT INTO iris.train VALUES(5.0,3.4,1.5,0.2,0);
INSERT INTO iris.train VALUES(4.7,3.2,1.3,0.2,0);
INSERT INTO iris.train VALUES(6.6,2.9,4.6,1.3,1);
INSERT INTO iris.train VALUES(5.5,3.5,1.3,0.2,0);
INSERT INTO iris.train VALUES(7.7,3.0,6.1,2.3,2);
INSERT INTO iris.train VALUES(6.1,3.0,4.9,1.8,2);
INSERT INTO iris.train VALUES(4.9,3.1,1.5,0.1,0);
INSERT INTO iris.train VALUES(5.5,2.4,3.8,1.1,1);
INSERT INTO iris.train VALUES(5.7,2.9,4.2,1.3,1);
INSERT INTO iris.train VALUES(6.0,2.9,4.5,1.5,1);
INSERT INTO iris.train VALUES(6.4,2.7,5.3,1.9,2);
INSERT INTO iris.train VALUES(5.4,3.7,1.5,0.2,0);
INSERT INTO iris.train VALUES(6.1,2.9,4.7,1.4,1);
INSERT INTO iris.train VALUES(6.5,2.8,4.6,1.5,1);
INSERT INTO iris.train VALUES(5.6,2.7,4.2,1.3,1);
INSERT INTO iris.train VALUES(6.3,3.4,5.6,2.4,2);
INSERT INTO iris.train VALUES(4.9,3.1,1.5,0.1,0);
INSERT INTO iris.train VALUES(6.8,2.8,4.8,1.4,1);
INSERT INTO iris.train VALUES(5.7,2.8,4.5,1.3,1);
INSERT INTO iris.train VALUES(6.0,2.7,5.1,1.6,1);
INSERT INTO iris.train VALUES(5.0,3.5,1.3,0.3,0);
INSERT INTO iris.train VALUES(6.5,3.0,5.2,2.0,2);
INSERT INTO iris.train VALUES(6.1,2.8,4.7,1.2,1);
INSERT INTO iris.train VALUES(5.1,3.5,1.4,0.3,0);
INSERT INTO iris.train VALUES(4.6,3.1,1.5,0.2,0);
INSERT INTO iris.train VALUES(6.5,3.0,5.8,2.2,2);
INSERT INTO iris.train VALUES(4.6,3.4,1.4,0.3,0);
INSERT INTO iris.train VALUES(4.6,3.2,1.4,0.2,0);
INSERT INTO iris.train VALUES(7.7,2.8,6.7,2.0,2);
INSERT INTO iris.train VALUES(5.9,3.2,4.8,1.8,1);
INSERT INTO iris.train VALUES(5.1,3.8,1.6,0.2,0);
INSERT INTO iris.train VALUES(4.9,3.0,1.4,0.2,0);
INSERT INTO iris.train VALUES(4.9,2.4,3.3,1.0,1);
INSERT INTO iris.train VALUES(4.5,2.3,1.3,0.3,0);
INSERT INTO iris.train VALUES(5.8,2.7,4.1,1.0,1);
INSERT INTO iris.train VALUES(5.0,3.4,1.6,0.4,0);
INSERT INTO iris.train VALUES(5.2,3.4,1.4,0.2,0);
INSERT INTO iris.train VALUES(5.3,3.7,1.5,0.2,0);
INSERT INTO iris.train VALUES(5.0,3.6,1.4,0.2,0);
INSERT INTO iris.train VALUES(5.6,2.9,3.6,1.3,1);
INSERT INTO iris.train VALUES(4.8,3.1,1.6,0.2,0);

DROP TABLE IF EXISTS iris.test;
CREATE TABLE iris.test (
       sepal_length float,
       sepal_width  float,
       petal_length float,
       petal_width  float,
       class int);
INSERT INTO iris.test VALUES(6.3,2.7,4.9,1.8,2);
INSERT INTO iris.test VALUES(5.7,2.8,4.1,1.3,1);
INSERT INTO iris.test VALUES(5.0,3.0,1.6,0.2,0);
INSERT INTO iris.test VALUES(6.3,3.3,6.0,2.5,2);
INSERT INTO iris.test VALUES(5.0,3.5,1.6,0.6,0);
INSERT INTO iris.test VALUES(5.5,2.6,4.4,1.2,1);
INSERT INTO iris.test VALUES(5.7,3.0,4.2,1.2,1);
INSERT INTO iris.test VALUES(4.4,2.9,1.4,0.2,0);
INSERT INTO iris.test VALUES(4.8,3.0,1.4,0.1,0);
INSERT INTO iris.test VALUES(5.5,2.4,3.7,1.0,1);

僕が作ってる Go の SQLite3 ドライバはユーザ関数を Go で書く事が出来る。

    sql.Register("sqlite3_custom"&sqlite3.SQLiteDriver{
        ConnectHook: func(conn *sqlite3.SQLiteConn) error {
            if err := conn.RegisterAggregator("logistic_regression_train", createLogisticRegressionTrain(conn), true); err != nil {
                return err
            }
            if err := conn.RegisterFunc("logistic_regression_predict", createLogisticRegressionPredict(conn), true); err != nil {
                return err
            }
            return nil
        },
    })

    db, err := sql.Open("sqlite3_custom"":memory:")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    _, err = db.Exec(`attach "iris.sqlite" as iris`)
    if err != nil {
        log.Fatal(err)
    }

ユーザ関数とアグリゲート関数は動作が異なっていて、ユーザ関数は SELECT で使うと行毎に呼び出され、行毎の結果が返る。アグリゲート関数は行毎に Step メソッドが呼ばれ、最後に Done メソッドが呼ばれる。つまり集計関数になる。アグリゲート関数で以下の様に SELECT した結果を全て貰いモデルを作る。モデルは JSON 形式で出力する様にした。文字列を持ったテーブルにそのまま突っ込める。これを logistic_regression_train という関数名にした。

    _, err = db.Exec(`
    drop table if exists iris.model;
    create table iris.model(config text);
    insert into iris.model
    select
        logistic_regression_train('{
                "rate":    0.1,
                "ntrains": 5000
            }',
            sepal_length,
            sepal_width,
            petal_length,
            petal_width,
            class
        )
    from
        iris.train
    `)
    if err != nil {
        log.Fatal(err)
    }

次にこの JSON からモデルに戻し、引数で渡されたテストデータから推論する関数 logistics_regression_predict を作った。

    rows, err := db.Query(`
    select
        logistic_regression_predict('iris.model',
            sepal_length,
            sepal_width,
            petal_length,
            petal_width
        ), class
    from
        iris.test
    `)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        var predicted, class float64
        err = rows.Scan(&predicted, &class)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println(math.RoundToEven(predicted), class)
    }

ロジスティック回帰そのものは gonum を使って書いた。

func (s *logistic_regressionDone() (stringerror) {
    ws := make([]float64, s.X[0].Len())
    for i := range ws {
        ws[i] = s.rand.Float64()
    }
    for i := range s.y {
        s.y[i] = s.y[i] / (s.maxy + 1)
    }
    w := mat.NewVecDense(len(ws), ws)
    y := mat.NewVecDense(len(s.y), s.y)
    for n := 0; n < s.cfg.NTrains; n++ {
        for i, x := range s.X {
            t := mat.NewVecDense(x.Len(), nil)
            t.CopyVec(x)
            pred := softmax(t, w)
            perr := y.AtVec(i) - pred
            scale := s.cfg.Rate * perr * pred * (1 - pred)

            for j := 0; j < x.Len(); j++ {
                dx := mat.NewVecDense(x.Len(), nil)
                dx.CopyVec(x)
                dx.ScaleVec(scale, x)
                w.AddVec(w, dx)
            }
        }
    }

    fargs := make([]float64, w.Len())
    for i := 0; i < w.Len(); i++ {
        fargs[i] = w.AtVec(i)
    }
    var buf bytes.Buffer
    err := json.NewEncoder(&buf).Encode(&model{
        W: fargs,
        M: s.maxy,
    })
    if err != nil {
        return "", err
    }
    return buf.String(), nil
}

この例では推論した値 predict と、正解の値 class が SELECT されるので Go で値を取り出すと推論が正しいか判断できる。

    for rows.Next() {
        var predicted, class float64
        err = rows.Scan(&predicted, &class)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf(
            "predict: %d (%d)\n",
            int(math.RoundToEven(predicted)), int(class))
    }
predict: 1 (2)
predict: 1 (1)
predict: 0 (0)
predict: 2 (2)
predict: 0 (0)
predict: 1 (1)
predict: 1 (1)
predict: 0 (0)
predict: 0 (0)
predict: 1 (1)

正解率 90% なのでまずまずと言っていいのかな。

サンプルコードの位置づけだけど GitHub にコードを置いておきます。

GitHub - mattn/go-sqlite3-logistics-regression

Features → Code review Project management Integrations Actions Package registry Team management...

https://github.com/mattn/go-sqlite3-logistics-regression
Posted at by



2019/04/19


この文章は Rob Pike 氏が Google+ に投稿した内容の意訳です。現在は web archive からしか閲覧できません。面白かったので訳してみました。

ショートカットからのレッスン

ずっと昔の話。Unixファイルシステムで移動を簡単にする為に ... を追加する設計がほぼ完成していた頃。確かではないけれどバージョン2への書き換え中、つまりファイルシステムが階層的になった頃(初期の段階では構造が全く違っていた)に ... は入った。ls をタイプするとドット付きのファイルが現れたので、Ken か Dennis のどちらかが簡単なテストプログラムを追加しました。当時はアセンブラでしたが問題のコードは次のようなものと同等でした。

   if (name[0] == '.'continue;

この1文は、本来あるべき以下の1文よりも多少短かった。

   if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0continue;

ただこうだとしても簡単ではあったんだけれども。

2つの事が起きました。

まず、悪い前例を作ってしまった。他の多くの怠惰なプログラマも同じ単純化をした事によりバグが入ってしまった。ピリオドで始まる実際のファイルは、ファイル数をカウントする時にスキップされてしまう場合があります。

さらに悪い事に「hidden」または「dot」ファイルのアイデアが作られてしまった。結果として、もっと怠惰なプログラマが皆のホームディレクトリにこのファイルを置き始めました。私がこの文章をタイプするのに使っているマシンにはそれほど多くの物がインストールされていませんが、私のホームディレクトリにはおよそ100個もの良く分からない、また必要なのかどうかも分からないドットファイルがあります。私のホームディレクトリ上でのファイル名の評価はこの蓄積されたヘドロによりどんどん遅くなります。

隠しファイルの概念は意図しない事から生まれた結果だったと確信しています。それは確かな間違いだった。

約40年前のたった1つのショートカットから、バグや無駄なCPUサイクル、そして人間の欲求不満(デザインが悪い事は言うまでもない)の実例がいったい幾つ生まれてしまったんだろうか。

次回コードを書く際にコーナーケースをショートカットしたい場合は留意しておいて下さい。

(ドットファイルが目的を果たしているかについて異論を唱える人に関して私はそれに異議を唱えませんが、目的を果たすべきはファイル自身でありファイル名の為の習慣ではないと反論するだろう。これらは $HOME/cfg$HOME/lib に置くことが出来るし Plan9 では実際にそうなっていてドットファイルは1つもない。レッスンには学びがある)

Posted at by