カギカン終了でSwitchBotへ移行した話 〜スマートロックとしては優秀。でも代替サービスとして考えると話は別〜

これまで、小規模な共有スペース(利用者5名程度)の入退室管理にカギカンを利用していました。

目的はシンプルで、

  • 誰が部屋を利用したか把握したい
  • いつ鍵が開けられたか記録したい
  • 鍵の閉め忘れを防ぎたい

というものでした。

今回、カギカンのサービス終了に伴いSwitchBotへ移行したのですが、思っていた以上に苦労したため、その記録を残しておこうと思います。

結論から言うと、

スマートロックとしてのSwitchBotに不満はない。しかし、カギカンの代替として考えると話は別。

というのが率直な感想です。

カギカンを選んでいた理由

カギカンではQrioを利用していました。

当時は個人向けのQrio Lockも自宅で利用しており、トラブルもなく運用できていたため、安心して導入できました。

導入工事も不要で、サムターン錠の上から取り付けるだけという手軽さも魅力でした。

月額費用はおよそ5,000円でしたが、法人向けサービスとして考えれば妥当な金額だと思っていました。

特に便利だったのは以下の点です。

  • 誰が解錠したか記録できる
  • 利用履歴を確認できる
  • グループごとに権限管理ができる
  • 解錠可能な時間帯を制御できる

個人利用であれば高額に感じるかもしれませんが、入退室管理システムとして見ると非常に使いやすいサービスでした。

運用中に一時的な不具合が発生したこともありましたが、問い合わせ後の対応は迅速で、大きな不満はありませんでした。

なぜSwitchBotを選んだのか

移行先としてSwitchBotを選んだ理由は単純です。

自宅でさまざまなSwitchBot製品を利用しており、大きなトラブルを経験していなかったからです。

また、ちょうど自宅へのロックUltra導入も検討していたため、何か問題が起きても対応しやすいだろうと考えていました。

今振り返ると、他のサービスも比較検討すべきだったかもしれません。

しかし、既に利用実績があり、運用イメージが描けていたことからSwitchBotを選択しました。

今回の要件

移行にあたり必要だった機能は次の2つです。

オートロック

施錠忘れによる備品の盗難リスクを減らしたい。

監視カメラは24時間稼働していましたが、それでも施錠忘れは避けたいと考えていました。

入退室記録

理想は自動記録ですが、最悪の場合は紙でも構いません。

ただし、毎回手書きで記録する運用はできるだけ避けたいという考えでした。

ロックUltraだけでは解決できなかったこと

まずSwitchBot ロックUltraを導入しました。

スマートロックとしては非常に優秀です。

しかし、カギカンの代替という視点で見ると問題がありました。

利用者を識別できない

アプリで解錠した場合、

「アプリ解錠」

という記録は残るものの、

「誰が解錠したか」

までは判別できませんでした。

公式サポートへ確認したところ、

  • 指紋認証
  • 顔認証
  • 専用NFCカード

などを利用すれば個人識別は可能とのことでした。

ただし追加機器が必要になります。

また、今回は生体情報を運用に組み込みたくなかったため、顔認証や指紋認証の導入は見送りました。

解錠履歴をエクスポートできない

これは正直かなり驚きました。

SwitchBotには、

  • プラグミニの消費電力履歴
  • Hub 2の温湿度履歴

など長期間のデータを扱う機能があります。

そのため当然、解錠履歴もエクスポートできるものだと思っていました。

ところが実際には簡単には取り出せませんでした。

少なくともカギカンで行っていたような運用をそのまま移行することはできませんでした。

API連携を検討した

そこで次に考えたのがAPI連携です。

SwitchBotにはクラウドAPIが用意されています。

ただし実運用を考えると悩ましい部分がありました。

例えば、

  • サーバを用意する
  • APIを受ける仕組みを作る
  • 利用者管理を実装する
  • ログを保存する

といった作業が必要になります。

クラウドでも自宅サーバでも実現はできます。

しかし公開環境で運用する以上、

  • セキュリティアップデート
  • アクセス管理
  • 障害対応

などを継続しなければなりません。

小規模な共有スペースのために、そこまでの運用コストをかけるのは本末転倒だと感じました。

実はiPhone上では実装できた

実は途中まで実装しました。

iPhone上で動作するa-Shellを利用し、PythonコードからSwitchBot APIを呼び出して解錠できるところまで確認しています。

さらにa-Shellはショートカットと連携できるため、

  1. LINEへ記録を送信
  2. APIで解錠

をワンタップで実行することも可能でした。

しかし、

  • APIキーの管理
  • a-Shellの長期的な信頼性
  • 他利用者への展開

などを考えると、運用として採用するには少し不安が残りました。

そのため最終的には見送りました。

Siriショートカット作戦

もっと簡単な方法として、Siriショートカットを利用しようと考えました。

SwitchBotの解錠操作とLINE送信を組み合わせれば、

解錠と記録を同時に実現できるのではないかと考えたのです。

そのためにHub 2を追加購入しました。

ところが実際に試してみると、

Siriショートカットでは施錠しかできない

解錠操作が提供されていませんでした。

おそらく誤操作対策だと思われますが、今回の用途では致命的でした。

結果として、Hub 2を購入した当初の目的は達成できませんでした。

最終的な運用

現在は非常にシンプルです。

iPhoneのホーム画面に

  • SwitchBot解錠ウィジェット
  • LINE送信用ショートカット

を並べています。

利用者は、

  1. SwitchBotで解錠
  2. LINE送信ボタンを押す

という流れで運用しています。

2タップ必要になりますが、

「紙に記録を書く」

よりはかなり楽になりました。

もし今後さらに改善するなら

利用者から

「2タップは面倒」

という声が出た場合は、API方式を再検討すると思います。

LAN内に小型端末を設置し、

  • 利用者管理
  • 解錠処理
  • ログ保存

をまとめて行う仕組みであれば実現できそうです。

ただし、その場合は運用コストや保守コストとのトレードオフになります。

まとめ

今回の移行で改めて感じたのは、

「スマートロック」と「入退室管理システム」は似ているようで別物だということです。

SwitchBot ロックUltraは非常に優秀なスマートロックです。

オートロックや遠隔操作など、個人利用で求められる機能は十分備えています。

しかし、

  • 誰が利用したか記録したい
  • 利用履歴を管理したい
  • 権限管理をしたい

という用途になると、カギカンが提供していた価値の大きさを改めて実感しました。

これからカギカンの代替としてSwitchBotを検討している方は、

「鍵が開けばよい」

のか、

「利用履歴まで管理したい」

のかを先に整理しておくことをおすすめします。

私自身、もっと簡単に移行できると思っていました。

しかし実際には、想像以上に運用面で考えることが多い移行となりました。

おまけ:検証時に使用したSwitchBot API関連メモ

今回の検証では、SwitchBot Cloud APIを利用して解錠処理の自動化も試しました。

最終的には運用面の理由から採用しませんでしたが、同じようなことを検討している方の参考になればと思い、調査時のメモを残しておきます。

SwitchBot APIキーの取得方法

SwitchBotアプリから開発者向け設定を開くことで取得できます。

執筆時点では、

  1. SwitchBotアプリを開く
  2. プロフィール画面を開く
  3. アプリバージョンの表示部分を10回タップする

ことで、開発者向けメニューが表示されます。

そこから以下の情報を確認できます。

  • Token
  • Secret

API利用時に必要となるため、安全に管理してください。

デバイス一覧の取得

まずは対象デバイスのDevice IDを取得する必要があります。

以下のコードで取得できます。

import hashlib
import hmac
import base64
import time
import uuid
import requests
import json

# 設定項目
token = ''
secret = ''

# 署名生成に必要なパラメータ
t = str(int(round(time.time() * 1000)))
nonce = str(uuid.uuid4())
string_to_sign = f"{token}{t}{nonce}"

# HMAC-SHA256署名の計算
sign = base64.b64encode(hmac.new(secret.encode('utf-8'), msg=string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest())

# ヘッダー構築
headers = {
    "Authorization": token,
    "sign": sign.decode('utf-8'),
    "nonce": nonce,
    "t": t,
    "Content-Type": "application/json; charset=utf8"
}

# リクエスト送信
response = requests.get("https://api.switch-bot.com/v1.1/devices", headers=headers)

# 結果表示
print(json.dumps(response.json(), indent=2, ensure_ascii=False))

ロックUltraの解錠

Device IDが取得できたら、ロックUltraへ解錠コマンドを送れます。

以下は検証時に使用したコードです。

import hashlib
import hmac
import base64
import time
import uuid
import requests
import json

# ==========================================
# 【設定項目】ご自身の情報に書き換えてください
# ==========================================
token = ''
secret = ''
device_id = ''  # 先ほど取得したロックのID
# ==========================================

# 署名生成に必要なパラメータ
t = str(int(round(time.time() * 1000)))
nonce = str(uuid.uuid4())
string_to_sign = f"{token}{t}{nonce}"

# HMAC-SHA256署名の計算
sign = base64.b64encode(hmac.new(secret.encode('utf-8'), msg=string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest())

# ヘッダー構築
headers = {
    "Authorization": token,
    "sign": sign.decode('utf-8'),
    "nonce": nonce,
    "t": t,
    "Content-Type": "application/json; charset=utf8"
}

# 解錠コマンドのペイロード(JSONデータ)
body = {
    "command": "unlock",
    "parameter": "default",
    "commandType": "command"
}

# リクエスト送信(POSTで解錠命令を送る)
url = f"https://api.switch-bot.com/v1.1/devices/{device_id}/commands"
response = requests.post(url, headers=headers, json=body)

# 結果表示
print(json.dumps(response.json(), indent=2, ensure_ascii=False))

注意事項

今回紹介したコードは検証目的で利用したものです。

実運用する場合は以下の点に注意してください。

  • APIキーをソースコードへ直接埋め込まない
  • GitHubなどへ誤って公開しない
  • 利用履歴の管理方法を事前に検討する
  • API障害時の運用を決めておく
  • 物理鍵によるバックアップ手段を確保しておく

また、APIを利用した仕組みは便利な反面、保守対象が増えます。

小規模運用の場合は、運用コストと得られるメリットのバランスを考えながら導入を検討することをおすすめします。

RAW画像からJPGを取り出しそのHEICも作成

  1. RAW画像のプレビューからJPGを取り出します
  2. その後そのJPGをHEICに変換します
sourcedir=DCIM
targetdir_jpg=DCIM_PRV_JPG
targetdir_heic=DCIM_PRV_HEIC
formatOptions=10

getimgtype=JpgFromRaw
# getimgtype=PreviewImage
# getimgtype=ThumbnailImage

mkdir -p ${targetdir_jpg} ${targetdir_heic}
exiftool -b -${getimgtype} -w ${targetdir_jpg}/%-1:d%f.jpg -ext ARW -r ${sourcedir}/ &

while :; do
  cnt=0
  for org in $(find "${targetdir_jpg}" -type f -name "*.jpg"); do
      dst=$(echo "$org" | sed -e "s|${targetdir_jpg}|${targetdir_heic}|" -e "s|jpg$|heic|")
      mkdir -p "$(dirname "$dst")"
      [[ ! -e $dst ]] &&
        sips -s format heic -s formatOptions "${formatOptions}" "${org}" --out "${dst}" &&
        cnt=$((${cnt} + 1))
  done
  if [[ $cnt -eq 0 ]]; then break; fi
  sleep 5s
done
wait

torchを使わずに誤差逆伝播を理解してみる

# ─── データの準備 ───────────────────────────────────────────
# 入力 x = 2.0 のとき、正解 t = 10.0 を目指して学習する
x = 2.0
t = 10.0

# ─── パラメータの定義 ────────────────────────────────────────
w1 = 3.0
w2 = 2.0

# ─── 学習の設定 ─────────────────────────────────────────────
learning_rate = 0.01

# ─── 学習ループ ──────────────────────────────────────────────
print("--- 学習スタート ---")

for epoch in range(1, 11):
    # 1. 順伝播:入力から予測値を計算(2段階の線形変換)
    h = x * w1   # 中間値
    y = h * w2   # 最終的な予測値

    # 2. 損失計算:1/2 * (予測 - 正解)²
    # 1/2 をかけるのは、微分したときに係数がきれいに消えるため
    loss = 0.5 * (y - t) ** 2

    # 3. 逆伝播:損失をw1・w2で微分して勾配を手計算する
    #
    # 連鎖律(chain rule)を使って順番に微分する:
    #
    #   ∂loss/∂y  = (y - t)          ← 損失を予測yで微分
    #   ∂y/∂h     = w2               ← yを中間値hで微分
    #   ∂h/∂w1    = x                ← hをw1で微分
    #   ∂y/∂w2    = h                ← yをw2で微分
    #
    #   ∂loss/∂w1 = (y - t) * w2 * x ← 連鎖律でつなげる
    #   ∂loss/∂w2 = (y - t) * h      ← 連鎖律でつなげる

    dL_dy  = y - t          # ∂loss/∂y
    dL_dw2 = dL_dy * h      # ∂loss/∂w2
    dL_dw1 = dL_dy * w2 * x # ∂loss/∂w1

    # 4. パラメータ更新:勾配の方向と逆向きに少し動かす(勾配降下法)
    w1 = w1 - learning_rate * dL_dw1
    w2 = w2 - learning_rate * dL_dw2

    # 進捗表示
    print(f"{epoch:2d}回目: 予測={y:.4f} | 損失={loss:.4f} | w1={w1:.4f}, w2={w2:.4f}")

# ─── 結果の確認 ──────────────────────────────────────────────
print("\n--- 学習完了 ---")
print(f"最終予測値: {y:.4f}  (目標: {t:.1f})")
print(f"学習済みパラメータ: w1={w1:.4f}, w2={w2:.4f}")
--- 学習スタート ---
 1回目: 予測=12.0000 | 損失=2.0000 | w1=2.9200, w2=1.8800
 2回目: 予測=10.9792 | 損失=0.4794 | w1=2.8832, w2=1.8228
 3回目: 予測=10.5110 | 損失=0.1306 | w1=2.8646, w2=1.7933
 4回目: 予測=10.2743 | 損失=0.0376 | w1=2.8547, w2=1.7776
 5回目: 予測=10.1493 | 損失=0.0111 | w1=2.8494, w2=1.7691
 6回目: 予測=10.0818 | 損失=0.0033 | w1=2.8465, w2=1.7644
 7回目: 予測=10.0450 | 損失=0.0010 | w1=2.8449, w2=1.7619
 8回目: 予測=10.0248 | 損失=0.0003 | w1=2.8440, w2=1.7605
 9回目: 予測=10.0137 | 損失=0.0001 | w1=2.8436, w2=1.7597
10回目: 予測=10.0076 | 損失=0.0000 | w1=2.8433, w2=1.7593

--- 学習完了 ---
最終予測値: 10.0076  (目標: 10.0)
学習済みパラメータ: w1=2.8433, w2=1.7593

誤差逆伝播を使って、y=x*w1*w2を計算してみる

import torch

# ─── データの準備 ───────────────────────────────────────────
# 入力 x = 2.0 のとき、正解 t = 10.0 を目指して学習する
x = torch.tensor([2.0])
t = torch.tensor([10.0])

# ─── パラメータの定義 ────────────────────────────────────────
# requires_grad=True:このテンソルの勾配を追跡するようPyTorchに伝える
# 逆伝播で「どの方向に・どれだけ修正するか」を計算できるようになる
w1 = torch.tensor([3.0], requires_grad=True)
w2 = torch.tensor([2.0], requires_grad=True)

# ─── 学習の設定 ─────────────────────────────────────────────
# SGD:確率的勾配降下法でw1・w2を少しずつ正解に近づける
learning_rate = 0.01
optimizer = torch.optim.SGD([w1, w2], lr=learning_rate)

# ─── 学習ループ ──────────────────────────────────────────────
print("--- 学習スタート ---")

for epoch in range(1, 11):
    # 1. 順伝播:入力から予測値を計算(2段階の線形変換)
    h = x * w1   # 中間値
    y = h * w2   # 最終的な予測値

    # 2. 損失計算:1/2 * (予測 - 正解)² で誤差を数値化
    # 1/2 をかけるのは、微分したときに係数がきれいに消えるため
    loss = 0.5 * (y - t) ** 2

    # 3. 勾配リセット:前ステップの勾配が蓄積しないようにクリア
    optimizer.zero_grad()

    # 4. 逆伝播:損失からw1・w2それぞれの勾配を自動計算
    loss.backward()

    # 5. パラメータ更新:勾配に従ってw1・w2を少し修正する
    optimizer.step()

    # 進捗表示
    print(f"{epoch:2d}回目: 予測={y.item():.4f} | 損失={loss.item():.4f} | w1={w1.item():.4f}, w2={w2.item():.4f}")

# ─── 結果の確認 ──────────────────────────────────────────────
print("\n--- 学習完了 ---")
print(f"最終予測値: {y.item():.4f}  (目標: {t.item():.1f})")
print(f"学習済みパラメータ: w1={w1.item():.4f}, w2={w2.item():.4f}")
--- 学習スタート ---
 1回目: 予測=12.0000 | 損失=2.0000 | w1=2.9200, w2=1.8800
 2回目: 予測=10.9792 | 損失=0.4794 | w1=2.8832, w2=1.8228
 3回目: 予測=10.5110 | 損失=0.1306 | w1=2.8646, w2=1.7933
 4回目: 予測=10.2743 | 損失=0.0376 | w1=2.8547, w2=1.7776
 5回目: 予測=10.1493 | 損失=0.0111 | w1=2.8494, w2=1.7691
 6回目: 予測=10.0818 | 損失=0.0033 | w1=2.8465, w2=1.7644
 7回目: 予測=10.0450 | 損失=0.0010 | w1=2.8449, w2=1.7619
 8回目: 予測=10.0248 | 損失=0.0003 | w1=2.8440, w2=1.7605
 9回目: 予測=10.0137 | 損失=0.0001 | w1=2.8436, w2=1.7597
10回目: 予測=10.0076 | 損失=0.0000 | w1=2.8433, w2=1.7593

--- 学習完了 ---
最終予測値: 10.0076  (目標: 10.0)
学習済みパラメータ: w1=2.8433, w2=1.7593

最小のニューラルネットワークでy=wx+bを解いてみる

import torch
import torch.nn as nn
import torch.optim as optim

# ─── モデルの定義 ───────────────────────────────────────────
# 入力1次元 → 出力1次元の線形変換(y = wx + b)
# これが「ニューラルネットワーク」の最もシンプルな形
model = nn.Sequential(
    nn.Linear(1, 1)
)

# ─── データの準備 ───────────────────────────────────────────
# 入力 x = 2.0 のとき、正解 t = 10.0 を目指して学習する
x = torch.tensor([[2.0]])   # 入力値
t = torch.tensor([[10.0]])  # 正解ラベル(目標値)

# ─── 学習の設定 ─────────────────────────────────────────────
# SGD:確率的勾配降下法(パラメータを少しずつ正解に近づける手法)
# lr(学習率):一度にどれだけ動かすかの幅。大きすぎると発散する
optimizer = optim.SGD(model.parameters(), lr=0.01)

# MSELoss:平均二乗誤差(予測と正解のズレを数値化する関数)
criterion = nn.MSELoss()

# ─── 学習ループ ──────────────────────────────────────────────
print("--- 学習スタート ---")

for epoch in range(1, 101):
    # 1. 順伝播:現在のモデルで予測を計算
    y = model(x)

    # 2. 損失計算:予測と正解のズレを求める
    loss = criterion(y, t)

    # 3. 勾配リセット:前のステップの勾配が残らないようにクリア
    optimizer.zero_grad()

    # 4. 逆伝播:損失をもとに各パラメータの勾配を計算
    loss.backward()

    # 5. パラメータ更新:勾配に従って重みを少し修正する
    optimizer.step()

    # 1回目と10回ごとに進捗を表示
    if epoch == 1 or epoch % 10 == 0:
        print(f"{epoch:3d}回目: 予測={y.item():.4f} | 損失={loss.item():.4f}")

# ─── 結果の確認 ──────────────────────────────────────────────
print("\n--- 学習完了 ---")
print(f"最終予測値: {model(x).item():.4f}  (目標: {t.item():.1f})")
print("学習済みパラメータ(重みとバイアス):")
print(model.state_dict())
--- 学習スタート ---
  1回目: 予測=0.6449 | 損失=87.5183
 10回目: 予測=6.3756 | 損失=13.1360
 20回目: 予測=8.7363 | 損失=1.5970
 30回目: 予測=9.5594 | 損失=0.1942
 40回目: 予測=9.8464 | 損失=0.0236
 50回目: 予測=9.9464 | 損失=0.0029
 60回目: 予測=9.9813 | 損失=0.0003
 70回目: 予測=9.9935 | 損失=0.0000
 80回目: 予測=9.9977 | 損失=0.0000
 90回目: 予測=9.9992 | 損失=0.0000
100回目: 予測=9.9997 | 損失=0.0000

--- 学習完了 ---
最終予測値: 9.9998  (目標: 10.0)
学習済みパラメータ(重みとバイアス):
OrderedDict([('0.weight', tensor([[4.3775]])), ('0.bias', tensor([1.2448]))])

つまづいていたところ

  • optimizerはmodelにつながっていて、
  • yはmodelにつながっていて、
  • lossはyにつながっている

Transformerのサンプルコード

import torch                        # PyTorch本体。テンソル計算や自動微分の基盤
import torch.nn as nn               # ニューラルネットワークの部品(層など)を提供するモジュール
import torch.nn.functional as F     # softmaxなど、よく使う関数を関数形式で呼び出せるモジュール
import torch.optim as optim         # パラメータを自動更新する最適化アルゴリズム集
import math                         # sqrt(平方根)など数学関数を使うための標準ライブラリ

# ==========================================
# 準備:コーパスと語彙の作成
# ==========================================

# 学習に使う文章の集まり(コーパス)。単語はスペース区切りで書いている
corpus = [
    "私 は 毎日 りんご を 食べる",
    "ウィキペディア は インターネット 百科事典 です",
    "りんご は とても 美味しい 果物 です",
    "私 は ウィキペディア で りんご を 調べる"
]

# コーパス全文から単語を1つずつ取り出し、重複なしの集合(set)にまとめる
all_words = set(word for sentence in corpus for word in sentence.split())

# 各単語に番号(ID)を割り当てた辞書を作る。例:{"私": 0, "は": 1, ...}
vocab = {word: i for i, word in enumerate(all_words)}

# 上の逆引き用辞書。番号→単語に変換できる。例:{0: "私", 1: "は", ...}
id_to_word = {i: word for word, i in vocab.items()}

# 語彙の総数(単語が全部で何種類あるか)
vocab_size = len(vocab)

# ---- 学習データの準備 ----

# 入力となる単語列(「私 は りんご を」の4単語)
input_words = "私 は りんご を".split()

# 正解として覚えさせたい次の単語
target_word = "食べる"

# 入力単語をIDの数値リストに変換し、PyTorchが扱えるテンソル(多次元配列)にする
input_ids = torch.tensor([vocab[w] for w in input_words])

# 正解単語もIDに変換してテンソルにする(損失計算で使う)
target_id = torch.tensor([vocab[target_word]])

# ---- ハイパーパラメータの設定 ----

# 各単語を何次元のベクトルで表現するか(埋め込み次元数)
embed_dim = 16

# Attentionの計算で使うQ/K/Vベクトルの次元数
head_dim = 8

# 乱数シードを固定することで、毎回同じ初期値でモデルが始まる(再現性のため)
torch.manual_seed(42)

# ==========================================
# モデルのパーツ定義
# ==========================================

# 単語IDを embed_dim 次元のベクトルに変換する埋め込み層
# 例:ID=3 → [0.12, -0.45, 0.87, ...] のような16次元ベクトル
embedding = nn.Embedding(vocab_size, embed_dim)

# QueryベクトルへのLinear変換層。「何を探したいか」を表す方向を学習する
linear_Q = nn.Linear(embed_dim, head_dim, bias=False)

# KeyベクトルへのLinear変換層。「自分がどんな情報を持っているか」を表す
linear_K = nn.Linear(embed_dim, head_dim, bias=False)

# ValueベクトルへのLinear変換層。「実際に渡す情報の中身」を表す
linear_V = nn.Linear(embed_dim, head_dim, bias=False)

# 最終的なベクトルを語彙サイズのスコア(logits)に変換する出力層(言語モデルヘッド)
lm_head = nn.Linear(head_dim, vocab_size, bias=False)

# ------------------------------------------
# 損失関数と最適化アルゴリズムの設定
# ------------------------------------------

# 損失関数:AIの予測スコアと正解のズレを数値化する「採点係」
# CrossEntropyLoss は分類問題(次の単語はどれ?)に最もよく使われる損失関数
criterion = nn.CrossEntropyLoss()

# 最適化アルゴリズム(Adam):損失を小さくするためにパラメータを自動更新する仕組み
# 第1引数に「更新してほしいパラメータの一覧」をまとめて渡す
optimizer = optim.Adam(
    list(embedding.parameters()) +   # 埋め込み行列のパラメータ
    list(linear_Q.parameters()) +    # Q変換行列のパラメータ
    list(linear_K.parameters()) +    # K変換行列のパラメータ
    list(linear_V.parameters()) +    # V変換行列のパラメータ
    list(lm_head.parameters()),       # 出力層のパラメータ
    lr=0.05  # 学習率:1回の更新でパラメータをどれだけ動かすかの大きさ(大きすぎると発散する)
)

# 学習の目的を表示する
print(f"【学習ミッション】「{' '.join(input_words)}」の次は「{target_word}」であると覚えさせる\n")

# ==========================================
# 学習ループ(誤差逆伝播法)
# ==========================================

# 同じデータで何周学習させるかの回数(エポック数)
epochs = 50

for epoch in range(epochs):  # 0〜49 まで50回繰り返す

    # ① 前のエポックで蓄積した勾配(パラメータへの修正量)をゼロにリセット
    #    これをしないと前回の修正量が足し合わさって正しく学習できない
    optimizer.zero_grad()

    # ② 順伝播(Forward Pass):入力から予測を計算する

    # input_ids(単語IDの列)を embed_dim 次元のベクトル行列 X に変換
    # X の形状:(入力単語数, embed_dim) = (4, 16)
    X = embedding(input_ids)

    # X に線形変換をかけて Q・K・V の各行列を作る
    # それぞれ形状:(4, head_dim) = (4, 8)
    Q = linear_Q(X)  # クエリ行列:「何を探したいか」
    K = linear_K(X)  # キー行列:「どんな情報を持っているか」
    V = linear_V(X)  # バリュー行列:「実際に渡す情報」

    # Q と K の内積でAttentionスコアを計算する
    # K.transpose(-2, -1) で K を転置(行と列を入れ替え)して内積が取れるようにする
    # head_dim の平方根で割るのは、次元数が大きくなるとスコアが大きくなりすぎるのを防ぐため(スケーリング)
    # scores の形状:(4, 4) 各単語が他の単語にどれだけ注目するかの生スコア
    scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(head_dim)

    # softmax で各行のスコアを「合計が1になる確率(注目度)」に変換する
    # dim=-1 は行方向(各単語から見て他の単語への注目度の和が1になるよう)に適用する意味
    attention_weights = F.softmax(scores, dim=-1)

    # 注目度の重みで V を加重平均し、文脈を反映したベクトルに変換する
    # 形状:(4, head_dim) = (4, 8)
    attention_output = torch.matmul(attention_weights, V)

    # 最後の単語(「を」)の位置のベクトルだけ取り出す
    # 「を」の後に来る単語を予測したいので、最終位置の出力を使う
    last_word_vector = attention_output[-1]

    # 出力層(lm_head)を通して「全語彙に対する生のスコア(logits)」に変換する
    # logits の形状:(vocab_size,) 各単語が次に来る可能性を表す生の数値
    logits = lm_head(last_word_vector)

    # ③ 損失(Loss)の計算
    # logits.unsqueeze(0) でバッチ次元を追加し、形状を (1, vocab_size) にする(CrossEntropyLossの入力形式に合わせる)
    # target_id は正解単語のID。予測と正解のズレ(誤差)を数値として計算する
    loss = criterion(logits.unsqueeze(0), target_id)

    # ④ 誤差逆伝播(Backward Pass)
    # 「この損失を小さくするには、各パラメータをどう動かすべきか」を
    # 自動微分によってすべての層に対して一括計算し、各パラメータの .grad に勾配を保存する
    loss.backward()

    # ⑤ パラメータの更新(Optimizer Step)
    # ④で計算された勾配(修正方向と量)に従って、実際にすべてのパラメータの値を書き換える
    optimizer.step()

    # --- 毎エポックの進捗を表示 ---

    # logits に softmax をかけて、各単語の予測確率(0〜1)に変換する
    probs = F.softmax(logits, dim=-1)

    # 正解単語「食べる」の予測確率を取り出してパーセント表示用に100倍する
    eat_prob = probs[target_id[0]].item() * 100

    # 現時点でAIが最も高確率だと思っている単語を取り出す
    top_pred_word = id_to_word[torch.argmax(probs).item()]

    # エポック番号・損失・「食べる」の確率・現在の最有力予測単語を表示
    print(f"エポック {epoch+1:2d} | 誤差(Loss): {loss.item():.4f} | "
          f"「食べる」の確率: {eat_prob:5.1f}% | AIの今の回答: {top_pred_word}")

# 学習完了メッセージ
print("\n学習完了!デタラメだったQ,K,V行列が、「食べる」を導き出せるように更新されました。")
【学習ミッション】「私 は りんご を」の次は「食べる」であると覚えさせる
エポック  1 | 誤差(Loss): 2.5828 | 「食べる」の確率:   7.6% | AIの今の回答: とても
エポック  2 | 誤差(Loss): 1.0312 | 「食べる」の確率:  35.7% | AIの今の回答: 食べる
エポック  3 | 誤差(Loss): 0.1422 | 「食べる」の確率:  86.7% | AIの今の回答: 食べる
エポック  4 | 誤差(Loss): 0.0081 | 「食べる」の確率:  99.2% | AIの今の回答: 食べる
エポック  5 | 誤差(Loss): 0.0003 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック  6 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック  7 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック  8 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック  9 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 10 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 11 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 12 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 13 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 14 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 15 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 16 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 17 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 18 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 19 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 20 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 21 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 22 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 23 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 24 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 25 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 26 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 27 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 28 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 29 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 30 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 31 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 32 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 33 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 34 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 35 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 36 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 37 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 38 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 39 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 40 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 41 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 42 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 43 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 44 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 45 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 46 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 47 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 48 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 49 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
エポック 50 | 誤差(Loss): 0.0000 | 「食べる」の確率: 100.0% | AIの今の回答: 食べる
学習完了!デタラメだったQ,K,V行列が、「食べる」を導き出せるように更新されました。