見出し画像

盆栽データで ML 実践してみた (+ 5/9追記)

jun@bitnengineers です。

4月になり新芽が沢山伸びました。盆栽始めて3年目の春で、成長が見えて楽しいですね。

中には新芽に紛れて、花粉のうもありました。この中に花粉が詰まっています。松の花粉もアレルゲンらしく、吸いすぎると花粉症にもなりうる危険なものです。触ってみたら花粉が目視できるレベルで舞ってました。

黒松の花粉のう

先月蒔いた種も発芽して春の変化を満喫しております。

黒松

さて、本題へ。

前回 AWS 上での ML を扱いました。SageMaker 上に ML 環境を構築する内容でしたが、今回は実際に取得しているデータを使ってML処理で推論を行ってみます。
勉強しながらのものなので未熟な点は多いかと思います。ほんの一例として見ていただけると丁度良いかと思います。

Dataset 

これまで取得したデータの column は主にメタデータ、稼働状態の確認・管理データ、水やり管理用の秤設定・重さ、環境データなどです。

メタデータ

  • timestamp  データ取得時刻

  • clientId       鉢毎の対象識別子

稼働管理

  • voltage       バッテリー電圧。残量の算出に使用。

  • charge_current 充電電流。充電状態確認に使用。

  • current  消費電流。voltage と合わせて残り稼働時間の推測等に使用。firmware 実装の改善にも。

重さデータ

  • scale_zerooffset  秤の0[g] 時のセンサー値。オフセット。

  • scale_lsb     センサーの1bit が何g かに用いる

  • scale_gain     センサーの設定。 27bit 出力モード。ずっと固定の予定。

  • weight_value   秤で量った値。 scale_lsb * weight_value = 実際の重さ[g]

環境データ

  • env_temperature  環境温度(試験的に使用)

  • env_humidity    環境湿度(試験的に使用)

  • env_light     環境光(試験的に使用)

env_temperature, env_humidity, env_light は試験的に一部の鉢でのみ収集してます。

取得頻度は5~10分ほどです。運用上、常時稼働出来ていなかったこともあるので途中途中欠けています。

clientId は鉢毎に違うものを使っています。それぞれ鉢の大きさが違うので水やり管理はclientId 毎に行うことになります。

PreProcess

Dataset は上に書いた通りなのですが、さて、何をどう推測できたら良いか、と考えてみたところ、

温度T, 湿度H 環境においてねある時間T辺りの水分量の減り具合X

が推測できたら面白いのではないか?と。

おそらく鉢の置かれる環境によっては風が強く吹いていたり、また鉢の大きさによっても露出している土の表面積が違う、などで減る水分量は左右されそうですが、取得したデータに基づいてそれっぽい値になるのではないか、と期待します。

ということで進めていきます。

まず、データを前処理していきます。元データは以下の感じです。

>>> bonsai_df.describe()
        water_level  soil_temperature  soil_humidity  env_temperature  \
count  56133.000000      53810.000000   53810.000000     53780.000000   
mean     197.204710         15.411294      12.711647         7.911630   
std      710.797958        821.563250     538.890179        11.128406   
min        0.000000        -45.000000       0.000000       -45.000000   
25%        0.000000          0.000000       0.000000         0.000000   
50%        0.000000          0.000000       0.000000         0.000000   
75%        0.000000          0.000000       0.000000        20.704106   
max     4095.000000      64784.000000   61236.000000        89.154648   

       env_humidity     env_light       current  charge_current       voltage  \
count  53780.000000  53744.000000  58683.000000    58993.000000  59224.000000   
mean      16.916929    193.379447     28.673737        2.261431    328.906775   
std       23.424874    297.789435    136.418199       49.916886   1023.435489   
min        0.000000      0.000000      0.000000        0.000000      0.000000   
25%        0.000000      0.000000      0.000000        0.000000      4.100801   
50%        0.000000      0.000000      0.000000        0.000000      4.117301   
75%       39.205006    404.000000      0.000000        0.000000      4.131600   
max       97.486839   1015.000000   1154.000000     1427.000000   3962.000000   

       weight_value  scale_zero_offset  scale_gain     scale_lsb     timestamp  
count  5.070300e+04       5.070000e+04     50703.0  50703.000000  5.615600e+04  
mean   7.183342e+06       4.496463e+06        27.0      0.001474  1.645493e+09  
std    6.806322e+07       7.302036e+06         0.0      0.004459  4.485871e+06  
min   -1.677720e+07       0.000000e+00        27.0      0.000000  1.632751e+09  
25%    4.096000e+03       7.562100e+04        27.0      0.000940  1.643365e+09  
50%    1.371520e+05       1.467360e+05        27.0      0.001740  1.646522e+09  
75%    1.725830e+05       1.672694e+07        27.0      0.001910  1.648894e

どの行も全体では50000データ以上あります。ここから不要な column を消します。(以前土壌センサーを使って計測していた column ですが、今は使っていません)

bonsai_df = bonsai_df.drop(columns=[""water_level", "soil_temperature", "soil_humidity"])

温度、湿度などの 環境データ env_* は1鉢にしかないので、clientId で filter します。

blackpine_bunzan = bonsai_df.query("clientId == 'blackpine_bunzan'")

timestamp (epoch time) では直感的に分からないので人に読みやすい datetime に変更します。その際にデータの順序を過去から未来の並びにもします。

bonsai_df = bonsai_df.sort_values(by=["timestamp"], ascending=True)
blackpine_bunzan["datetime"] = blackpine_bunzan.apply(lambda x: pd.to_datetime(x.timestamp, unit="s"), axis=1)

weight_value はロードセルのセンサー値をオフセットしたものをそのまま保存しているので、分かりやすいように Gram に変換します。

blackpine_bunzan["weight_gram"] = blackpine_bunzan.apply(lambda x: x.weight_value * x.scale_lsb, axis=1)
blackpine_bunzan.weight_gram.hist()

ヒストグラムを見てみると大きく外れた値があるので除去します。

wg_min = blackpine_bunzan.weight_gram.quantile(0.001)
wg_max = blackpine_bunzan.weight_gram.quantile(0.999)
print(f"wg_min = {wg_min}, wg_max = {wg_max}")
# wg_min = 243.64554302000002, wg_max = 308.23633906000026
blackpine_bunzan = blackpine_bunzan.query("weight_gram > @wg_min & weight_gram < @wg_max")

この調整でヒストグラムは綺麗になりました。

次に水やりを見ます。weight_gram 時間軸が進むに従い下がっている場合が水やり後、反対に上がった時点が水やりした時、と判定できそうです。

weight_gram の微分を見てみます。

blackpine_bunzan["weight_gram_diff"] = blackpine_bunzan.weight_gram.diff().replace(np.nan, 0)
print(blackpine_bunzan.weight_gram_diff.describe())
count    14615.000000
mean         0.000369
std          1.850227
min        -47.313950
25%         -0.142820
50%          0.029140
75%          0.284350
max         35.222500
Name: weight_gram_diff, dtype: float64

グラフかしたものが以下です。

datetime, weight_gram_diff

外れ値を除外します。

wgd_min = blackpine_bunzan.weight_gram_diff.quantile(0.05)
wgd_max = blackpine_bunzan.weight_gram_diff.quantile(0.93)
print(f"weight_gram_diff min = {wgd_min}, weight_gram_diff max = {wgd_max}")
# weight_gram_diff min = -1.4412274999999966, weight_gram_diff max = 0.9346025000000111
blackpine_bunzan = blackpine_bunzan.query("weight_gram_diff > @wgd_min & weight_gram_diff < @wgd_max")

0の少し下を中央値としてバラついてます。ロードセルで量った値もホワイトノイズはどうしても乗るので多少の増加(0以上の値)を許容しています。

次に、このデータを
水やりした後、T時間後に重量xが減っていくデータ x Row
という形にします。

水やり後のデータを切り出します。

downs = []
weight_gram_quantiles_q = [0.1, 0.2, 0.4, 0.6, 0.8, 0.9]
weight_gram_quantiles = [blackpine_bunzan.weight_gram.quantile(x) for x in weight_gram_quantiles_q]

def quantile_rank(wg):
    for x in weight_gram_quantiles_q:
        q = blackpine_bunzan.weight_gram.quantile(x)
        if q > wg:
            return x
        
    return weight_gram_quantiles[-1]
    
for i, r in blackpine_bunzan.iterrows():
        w = r.weight_gram
        q = quantile_rank(r.weight_gram)
        r["quantile"] = q
        if len(downs) == 0:
            downs.append([r])
        else:
            if downs[-1][-1]["quantile"] >= r["quantile"]:
                downs[-1].append(r)
            else:
                downs.append([r])

どうやるか色々と迷ったのですが、 weight_gram の値を6個ランクに分類して比較してます。

多少の weight_gram の上昇には左右されずに下り傾向を切り出せました。
データを処理していて思うところは多々ありますが、一旦このデータを使って進めてみます。

4/30 現在、上記の処理で136本の下り傾向のデータがあるので、100 を訓練用の入力値、残り 36 をテストデータとして csv に書き込み、前処理が完了です。

def to_series(_df):
    timestamp_diff = _df.timestamp.max() - _df.timestamp.min()
    weight_gram_diff = _df.weight_gram.max() - _df.weight_gram.min()
    temp_mean = _df.env_temperature.mean()
    hum_mean = _df.env_humidity.mean()
    light_mean = _df.env_light.mean()
    # print(f"time diff = {timestamp_diff}, weight_gram_diff = {weight_gram_diff} temp = {temp_mean}, humidity = {hum_mean}, light={light_mean}")
    return pd.DataFrame({"timestamp_diff": [timestamp_diff],
                         "weight_gram": [weight_gram_diff],
                         "env_temperature": [temp_mean],
                         "env_humidity": [hum_mean],
                         "env_light": [light_mean]})
    
dd = [to_series(d) for d in dataframes]

train_data = pd.concat(dd)
train_data.to_csv("train/bonsai/train.csv", index=False)
test_data = train_data.sample(36)
test_data.to_csv("train/bonsai/test.csv", index=False)

Model 作成

ようやく Model にたどり着きましたが、データの前処理に苦戦してしまったので Model については深堀り出来ていません。

よくある例で試してみます。

INPUT_N = 4
HIDDEN_N = 8
OUTPUT_N = 1

class BonsaiNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.l1 = nn.Linear(INPUT_N, HIDDEN_N)
        self.l2 = nn.Linear(HIDDEN_N, OUTPUT_N)

    def forward(self, x):
        x = self.l1(x)
        x = torch.sigmoid(x)
        x = self.l2(x)
        return x

入力層で 4 つのデータ(温度、湿度、経過時間、環境光)を入力し、隠れ層に 8 を指定し1つの出力(重量の差)を得る Model にしてます。

作成した訓練用データを読み込み, Tensor 型にして

train_df = pd.read_csv("train/bonsai/train.csv")
test_df = pd.read_csv("train/bonsai/test.csv")

train_y = train_df["weight_gram"]
train_x = train_df.drop(columns=["weight_gram"], axis=1, inplace=False)

train_x_data = torch.tensor(train_x.values.astype(np.float32))
train_y_data = torch.tensor(train_y.values.astype(np.float32))
test_data = torch.tensor(test_df.values.astype(np.float32))

train 関数を作成し、訓練開始。

def train():
    model = BonsaiNN()
    criterion = nn.MSELoss()
    optimizer = torch.optim.SGD(model.parameters(), lr=0.01) # , momentum = 0.9)
    epoch = 100
    
    for epoch in range(0, epoch):
        running_loss = 0.0
        for i, x in enumerate(train_x_data):
            optimizer.zero_grad()
            # print(x)
            outputs = model(x)
            # print(train_y_data[i])
            loss = criterion(outputs, train_y_data[i])
            loss.backward()
            optimizer.step()
        running_loss += loss.item()
        logger.info(f"[{epoch+1}] loss: {running_loss}: {loss.item()}")
    logger.info("Finished Training")
    return model

m = train()
print(f"weight = {m.l1.weight}, bias = {m.l1.bias}")

訓練がおわり、出来た Model を評価する。という段階でよくわからない状況に直面し未解決です。

def test_model(elapsedtime, temp, humidity, light):
    inputs = torch.tensor(pd.Series([r.timestamp_diff, r.env_temperature, r.env_humidity, r.env_light]).values.astype(np.float32))
    outputs = m.forward(inputs.data.flatten())
    return outputs.data.flatten()[0]
​
for col, r in test_df.iterrows():
    print(f"inference value = {test_model(r.timestamp_diff, r.env_temperature, r.env_humidity, r.env_light)}, expected={r.weight_gram}")


# 以下出力
inference value = 14.700004577636719, expected=13.76254
inference value = 14.700004577636719, expected=10.302340000000015
inference value = 14.700004577636719, expected=29.660239999999988
inference value = 14.700004577636719, expected=3.322900000000004
inference value = 14.700004577636719, expected=18.04164000000003
inference value = 14.700004577636719, expected=14.20622000000003
inference value = 14.700004577636719, expected=15.288159999999976
inference value = 14.700004577636719, expected=16.399209999999982
inference value = 14.700004577636719, expected=5.043090000000007
...

推論値が出力されましたが、以降 Model への入力値を変えてもずっと同じ出力、という状態です。
最初の呼び出しはそこそこ期待させる値が出るんですけどね、、、無念。

今回はここまでです。

おわりに

毎度同じく今回も、うまく行かないと投稿が遅れてしまう、ということになりました。 

"PreProcess" が思った以上に大変でした。気づきも多々あり、記述できてないですが、温度によってロードセルの値が変わっていそうなんですよね。水やりしていないのに weight_gram が上がってたりするので、精度には影響ありそうです。途中で気づいてしまいましたが今回は目をつぶりましたw

Dataset が用意されているチュートリアルなどはやったことがありますが、自作データから ML へというのは初めてだったので勉強になりました。

次回リベンジします。

5/9 追記

未解決とは

上記のうまく行かなかった Model 作成ですが、多少改善しました。
まず、未解決としていた理由は以下の二つの事象が見られたからです。

  • BonsaiNN の self.l1, self.l2 の重み付け weight  と バイアス bias の訓練前後で値が変化していなかった or 変化がほぼ無かった。(訓練効果がない?)

  • 訓練済み Model が出力した推論値がどんな入力値でも一定。

この二つの事象より、訓練効果がない?訓練できないとこういう結果?と状況を把握できず、未解決としていました。

原因を知る

その後の取り組みで色々調べ、以下の失敗に気づきました。

  • 訓練データのサンプル数が少なかったため、訓練効果がない可能性がある

  • 訓練データの centering がされていないので訓練データとして適していなかった

さらには

  • 訓練済み Model のテストコードでの誤り

という凡ミスも重なっていた。(<= これは恥ずかしい… orz)

Chainer のこの記事を参考としました。Chainer の Tutorial は日本語でも質が良いですね。とても分かりやすいです。(まだ全ての内容を理解するには至っていないですが)

改善した

サンプル数は 136本あった weight_gram の下り傾向のデータを分解し切り出し、サンプル数を増やすことに成功。136 -> 8994
(これでも少ないですが。。。)

次に、centering するために、train_data 各種値から列毎の平均値を引いた。
ただし timestamp_diff としていたデータ取得間の秒数は centering とは相性が悪いようなので、重さの変化を1秒間での変化とした。

わかりづらいので改めると以下です。

気温T, 湿度H, 環境光 L 下における、1 秒経過する毎の weight_gram の変化量  => Centering

となります。以下データ。

Centering 前


len = 8994
data =  weight_gram  env_temperature  env_humidity  env_light
0        0.001795        24.387161     34.203862      270.5
1        0.003533        24.401848     34.017700      274.0
2       -0.003561        24.584763     35.152208      276.5
3        0.007910        24.726288     35.676355      276.0
4        0.002418        24.451248     36.952772      277.5
...           ...              ...           ...        ...
8989    -0.001587        24.329746     46.346988        0.0
8990     0.003056        23.739601     50.853741        0.0
8991     0.001406        23.704887     52.680247        0.0
8992     0.001130        23.499275     51.452660        0.0
8993    -0.001169        23.288322     50.849165        0.0

[8994 rows x 4 columns]

Centering 後

train_df_tmp: weight_gram  env_temperature  env_humidity   env_light
0        0.001476         2.495317    -10.111614 -291.206916
1        0.003215         2.510004    -10.297776 -287.706916
2       -0.003879         2.692919     -9.163268 -285.206916
3        0.007591         2.834444     -8.639120 -285.706916
4        0.002100         2.559405     -7.362703 -284.206916
...           ...              ...           ...         ...
8989    -0.001905         2.437903      2.031513 -561.706916
8990     0.002738         1.847758      6.538265 -561.706916
8991     0.001087         1.813044      8.364771 -561.706916
8992     0.000812         1.607431      7.137184 -561.706916
8993    -0.001487         1.396479      6.533689 -561.706916

weight_gram は1秒間に 0.001476[g] 減る、という内容になってます。

このデータを使って訓練するBonsaiNN Model は入力は 4 (timestamp_diff, temperature, humidity, light) としていましたが  3 (temperature, humidity, light) と変更になりました。

(Chainer の記事にもあるような bias の重みとして 1 を入力に追加する、という手法を取った方が良いのか、判断付かず。一旦 入力データは 3 としてます。)

ここまでで epoch や lr などを調整しつつ、訓練し、出力されるデータはどうなったのか? 正解と推論の誤差で色をつけています。

正解

正解値

に対して推論値は

推論値


全然ですね。。精度を出すのは難しい。

一歩進めたので良しとします。

次回は

どう進めるか?

候補(仮)としては

  • 今回の Dataset や Model を SageMaker 上へ移行させる

  • PreProcess をどこで行う? Processing Job?  IoT Analytics は?

  • SageMaker での運用に乗せてみたい

  • 試験的な使用の 温度湿度センサー、環境光センサーの実用化

など、次に進めたいと思います。


この記事が気に入ったらサポートをしてみませんか?