前回はオームの法則による掛け算とキルヒホッフの電流則による足し算を組み合わせれば、ニューラルネットワークの積和演算を回路上で表現できそうだという話をしました。
今回は最終的に物理回路を作る前提で、まずは最低限の回路構成を考えます。
今回のシミュレータでは抵抗値を固定して、マイコンから出力する電圧を動的に変更する方式にします。
この構成の場合、重みの更新や掛け算をマイコン側で行い、アナログ回路側では電圧の加算と反転を処理します。機械学習の全部をアナログ回路でやるわけではありませんが、まず式と回路の対応を確認するにはこのくらいがよさそうです。
ただ実際に回路で学習させるとなると重みをどう変更するかという課題が出てきます。 可変抵抗を使えば重みを表現できそうですが学習のたびに人間が手でグリグリ回すわけにはいきません。 重みを自動で更新できる仕組みが必要です。
実機では入力電圧をDACで制御し重みを電子的に変更できる重み素子へ設定し回路の出力をADCで読み取る構成になりそうです。 入力電圧を制御するのはDACでADCは出力電圧を読むために使います。
ただ物理回路をいきなり作るのは大変です。 配線を間違えたり測定した電圧が理論値からずれていたりすると、回路の問題なのか計算の問題なのか分からなくなりそうです。
そこで今回は、いきなり実機を作るのではなく回路の式をそのままコードにしたシミュレータを作って試してみます。 まずは理想的な回路として計算が合うかを確認し物理回路はその後です。
実機で想定している回路の全体構成
最終的に実機へ持っていく回路は、まず1層分を処理する構成から始める想定です。
flowchart LR
MCU["マイコン<br/>学習・重み更新"]
DAC["DAC<br/>1ch使用"]
MCU -->|"I²C"| DAC
DAC -->|"V₁"| R1["R₁"]
R1 --> SUM(("反転入力"))
SUM -->|"-入力"| OP["反転アンプ<br/>オペアンプ"]
VREF["Vref = Vcc / 2<br/>分圧+バッファ"] -->|"+入力"| OP
OP --> OUT["Vout"]
OUT --> ADC["ADC"]
ADC -.-> MCU
OUT --> RF["Rf"]
RF --> SUM
実機で使う主な構成要素は次のとおりです。
| 部品 | 役割 |
|---|---|
| マイコン | 学習、重みの更新、DACとADCの制御 |
| DAC | 入力電圧を生成(今回は1チャンネルだけ使用) |
| 基準電圧バッファ | Vref を安定させる |
| オペアンプ | 1入力の反転アンプ |
| R1 | 入力を反転入力へ接続する固定抵抗 |
| Rf | オペアンプの帰還抵抗 |
| ADC | 回路の出力電圧を読み取る |
実機の回路は単電源で動作させます。電源電圧を Vcc とします。
実装する回路はまず1層分
ニューラルネットワーク全体を最初から回路にするのではなく、まずは1層分の積和演算を処理する回路を実装することにします。
この場合入力電圧 V_i はDACから与え、重み w_i は電子的に変更できる重み素子へ設定します。
重みを表す抵抗やコンダクタンスをマイコンから変更できれば学習のたびに人間が可変抵抗を回す必要はありません。
想定している1層分の流れは次のようになります。
flowchart LR
MCU["マイコン<br/>学習・重み更新<br/>層の切り替え"]
DAC["DAC<br/>入力電圧 Vᵢ"]
WEIGHT["電子的に制御する<br/>重み素子"]
SUM(("加算点"))
OP["反転アンプ<br/>1層分の積和"]
ADC["ADC<br/>層の出力"]
MCU -->|"重み設定"| WEIGHT
MCU -->|"入力電圧設定"| DAC
DAC -->|"Vᵢ"| WEIGHT
WEIGHT --> SUM
SUM --> OP
OP --> ADC
ADC -->|"出力読み取り"| MCU
1層分の計算が終わったら出力をADCで読み取ります。 マイコンは必要に応じて活性化関数を適用し、その結果を次の層の入力電圧としてDACから出力します。 これを層ごとに繰り返す構成です。
全層を同時に動かす回路を作れば高速化できそうですが層の数だけ回路が必要になります。 最初は1層分の回路を使い回しADCとDACを介して1層ずつ処理するほうが、実装と確認は進めやすそうです。
マイコンから1個の電圧を出力する
実機ではマイコンからDACを制御し複数チャンネルのうち1チャンネルだけを使って電圧を出力します。
DACの出力を、
$$ V_1 $$
とします。
DAC出力は固定抵抗 R_1 を通り、オペアンプの反転入力へ接続します。
マイコン自身にDACが搭載されている場合は外付けDACを省略してもよさそうです。 後で入力を増やせるように複数チャンネルのDACを想定していますが最初の実機では1チャンネルしか使いません。
Vcc / 2を計算上のゼロにする
今回の回路は Vcc の単電源で動作させます。
そのため回路上では負の電圧を直接扱えません。
そこで電源電圧の半分である Vcc / 2 を基準電圧にします。
$$ V_{\mathrm{ref}}=\frac{V_{\mathrm{cc}}}{2} $$
この Vref を計算上の0として扱います。
例えば
Vref:0Vrefより高い電圧:正の値Vrefより低い電圧:負の値
という感じです。
基準電圧はVcc を同じ値の抵抗2本で分圧すれば作れます。
ただし分圧しただけの電圧をオペアンプへ直接接続すると回路から流れ込む電流によって電圧が変動する可能性があります。
そこで、別のバッファ回路を使って Vref を安定させます。
バッファした基準電圧を反転アンプの非反転入力へ接続します。
まずは1入力の反転アンプ
いきなり複数の入力を加算するのではなく、まずは入力を1個だけにします。
オペアンプの反転入力に R_1、出力から反転入力に R_f を接続します。
非反転入力には先ほど作った Vref を接続します。
反転入力は負帰還によってほぼ Vref と同じ電圧になります。
このとき回路の出力は次の式で表せます。
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-\frac{R_f}{R_1}(V_1-V_{\mathrm{ref}}) $$
今回は入力抵抗と帰還抵抗を同じ値にします。
$$ R_1=R_f $$
抵抗値の比が1になるので出力は次のようになります。
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-(V_1-V_{\mathrm{ref}}) $$
DAC出力 V_1 が Vref なら、
$$ V_1-V_{\mathrm{ref}}=0 $$
なので出力も Vref になります。
$$ V_{\mathrm{out}}=V_{\mathrm{ref}} $$
V_1 を Vref より高くすると反転アンプなので出力電圧は Vref より低くなります。
反対に V_1 を Vref より低くすると出力電圧は Vref より高くなります。
まずはこの「入力電圧を基準電圧からどれだけ反転させるか」という動作を確認します。
重みと入力を1個の電圧に変換する
今回の回路では抵抗値を学習中に変更しません。
マイコン側で入力 x と重み w を掛け、その結果をDACの出力電圧へ変換します。
$$ V_1=V_{\mathrm{ref}}+k w x $$
k は計算値を回路で扱える電圧範囲に収めるための係数です。
この式を1入力の反転アンプの式へ代入すると、
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-\frac{R_f}{R_1}k w x $$
となります。
抵抗値を同じにした場合は、
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-k w x $$
です。
出力は反転していますがマイコン側で
$$ z=\frac{V_{\mathrm{ref}}-V_{\mathrm{out}}}{k} $$
と計算すれば1入力分の積 wx に相当する値を得られます。
この段階ではまだ足し算をしていません。 まずはシミュレータで1個の入力と重みから作った値が電圧の変化として回路を通り元の値に戻せるかを見ます。
2入力へ拡張する
1入力の計算をシミュレータで確認できたら次は同じ加算点に2本目の抵抗 R_2 を追加します。
実機の回路としては2つ目のDAC出力 V_2 を R_2 経由で反転入力へ接続するだけです。
flowchart LR
D1["DAC<br/>V₁"] --> R1["R₁"]
D2["DAC<br/>V₂"] --> R2["R₂"]
R1 --> SUM(("加算点"))
R2 --> SUM
SUM -->|"-入力"| OP["反転アンプ<br/>オペアンプ"]
VREF["Vref = Vcc / 2<br/>基準電圧バッファ"] -->|"+入力"| OP
OP --> OUT["Vout"]
OUT --> RF["Rf"]
RF --> SUM
このときの出力は、次の式になります。
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-\frac{R_f}{R_1}(V_1-V_{\mathrm{ref}})-\frac{R_f}{R_2}(V_2-V_{\mathrm{ref}}) $$
入力抵抗と帰還抵抗を同じ値にすると
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-(V_1-V_{\mathrm{ref}})-(V_2-V_{\mathrm{ref}}) $$
となります。
1chのときは入力電圧を1個処理するだけでしたが2chにすると2つの電圧を同じ加算点へ流し込めます。 ここで初めて抵抗を流れる電流を回路上で足し合わせる形になります。
入力と重みをそれぞれ電圧へ変換して
$$ V_1=V_{\mathrm{ref}}+k w_1 x_1 $$
$$ V_2=V_{\mathrm{ref}}+k w_2 x_2 $$
とすると抵抗値が同じ場合の出力は、
$$ V_{\mathrm{out}}=V_{\mathrm{ref}}-k(w_1x_1+w_2x_2) $$
です。
マイコン側で
$$ z=\frac{V_{\mathrm{ref}}-V_{\mathrm{out}}}{k} $$
と計算すれば、2入力分の積和演算
$$ z=w_1x_1+w_2x_2 $$
に相当する値を得られます。
まだ掛け算はマイコン側で行っていますが加算だけは回路に任せられています。 まず1chの式をシミュレータで確認し、その後2chにして加算が増えたときにも理論どおり動くかを見る、という順番にします。
まずはシミュレータで確認する
ここまでの回路を、いきなりブレッドボードへ組むのはやはり大変です。 まずは反転アンプの式をコードにして入力電圧を与えたときに計算どおりの出力が得られるかを確認します。
今回作るのはSPICEのようにトランジスタの特性や時間変化まで再現するものではありません。 オームの法則とキルヒホッフの電流則から導いた式を計算する、かなり理想化したシミュレータです。
まずは反転加算回路を次のような関数にします。
function simulateInvertingSummer(
vInputs: number[],
rInputs: number[],
rFeedback: number,
vRef: number,
): number {
const currents = vInputs.map(
(voltage, index) => (voltage - vRef) / rInputs[index],
);
const totalCurrent = currents.reduce(
(sum, current) => sum + current,
0,
);
return vRef - rFeedback * totalCurrent;
}
vInputs に各入力電圧rInputs に入力抵抗rFeedback に帰還抵抗を渡します。
入力電圧と基準電圧の差から電流を求め、それを合計して出力電圧へ変換しています。
2chの入力を与えると、次のようになります。
const vcc = 3.3;
const vRef = vcc / 2;
const rFeedback = 10_000;
const rInputs = [10_000, 10_000];
const k = 0.2;
const x = [2.0, 3.0];
const w = [0.5, 0.2];
const vInputs = w.map(
(weight, index) => vRef + k * weight * x[index],
);
const vOut = simulateInvertingSummer(
vInputs,
rInputs,
rFeedback,
vRef,
);
const z = (vRef - vOut) / k;
console.log(vInputs); // [1.85, 1.77]
console.log(vOut); // 1.33
console.log(z); // 1.6
w_1 x_1 + w_2 x_2 は、0.5 * 2.0 + 0.2 * 3.0 = 1.6 です。
シミュレータでも z = 1.6 になったので今回考えた2chの回路式をコードへ移せています。
同じ計算をページ上でも入力値を変えながら試せるようにしました。
SIMULATOR
2ch 反転加算回路
| 経路 | 入力電圧 | 電流 (mA) |
|---|---|---|
| 1 | ||
| 2 |
XORを学習させてみる
ここまでの回路は入力と重みの積和を計算するだけです。 せっかくなのでこのシミュレータでXORも試してみます。
XORの出力は次のようになります。
| x₁ | x₂ | XOR |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
ただし1個のニューロンに積和演算としきい値だけを追加してもXORは表現できません。 1個の積和演算で作れる境界は直線なのでXORの4点を正しく分けられないためです。
これは回路の計算が間違っているというより、そもそもモデルの表現力が足りないという話です。 XORを扱うには積和演算の間に非線形な活性化関数を入れ層を増やす必要があります。
今回は、入力2個、隠れ層2個、出力1個のネットワークを使います。 各ニューロンの積和は、これまで考えてきた回路で計算する部分です。 その後のシグモイドをソフトウェアで計算し誤差逆伝播法で重みを更新します。
隠れ層の出力を h_j、最終出力を y とすると、計算は次のようになります。
$$ h_j=\sigma(b_j+w_{j1}x_1+w_{j2}x_2) $$
$$ y=\sigma(b_o+v_1h_1+v_2h_2) $$
今回のシミュレータでは初期重みを固定しているので同じ条件なら毎回同じ学習結果になります。 学習前の出力を確認してから学習を実行してみます。
LEARNING SIMULATOR
2 → 2 → 1 XORネットワーク
| x₁ | x₂ | 正解 | 出力 | 判定 |
|---|---|---|---|---|
| 0 | 0 | 0 | ||
| 0 | 1 | 1 | ||
| 1 | 0 | 1 | ||
| 1 | 1 | 0 |
今回のXORシミュレータはネットワーク全体をTypeScriptで計算しています。 実機にする場合は隠れ層の1層分をアナログ回路で計算してADCで読み取り、マイコンから次の層へDACで渡すことになります。 つまりXORのような複数層の処理も、まずは1層分の回路が動くことを確認してから組み合わせていくことになります。
処理の流れをまとめると次のようになります。
- マイコンが入力と重みを掛ける
- 計算結果を各入力に対応する電圧へ変換する
- シミュレータが抵抗を流れる電流を計算する
- シミュレータが電流を加算し、反転アンプの出力電圧を求める
- 出力電圧から積和演算の結果を求める
今回は乗算をマイコン側の計算として扱い電圧の加算と反転をシミュレータで再現する構成です。
積和演算のすべてをアナログ回路で実行しているわけではありませんが実機を作る前の動作確認としてはこれで十分だと思います。
電圧範囲に注意する
実機ではオペアンプとADCを同じ単電源で動かすため、出力電圧は基本的に0~Vcc の範囲へ収める必要があります。
シミュレータでも、出力がこの範囲を超えた場合は実機で飽和する可能性がある値として扱う必要があります。
理論上は基準電圧の Vcc / 2 を中心として、入力と重みの積和が次の範囲に収まる必要があります。
入力抵抗と帰還抵抗が同じ場合は
$$ -\frac{V_{\mathrm{cc}}}{2}\leq-k\sum_i w_i x_i\leq\frac{V_{\mathrm{cc}}}{2} $$
となります。
2chの場合は、w_1 x_1 + w_2 x_2 がこの積和に相当します。
ただし実際のオペアンプ出力は電源電圧ぎりぎりまで正確に出力できるとは限りません。
最初は出力が電源電圧の端まで振れないように k を設定し余裕を持たせたほうがよさそうです。
実機へ進むときは各回路の電源端子付近にデカップリングコンデンサーを配置します。
今回シミュレータで確認すること
今回は物理回路を組まず、まず次の点をシミュレータで確認します。
- 1入力の反転アンプの式を正しく計算できるか
- 2入力にしたとき電流の合計を正しく求められるか
- 電圧から積和演算の結果を元に戻せるか
- 出力が電源電圧の範囲に収まっているか
- 理論式とシミュレータの計算結果が一致するか
- 隠れ層を追加したネットワークでXORを学習できるか
- 1層分の出力を次の層へ渡せるか
ここまで確認できればマイコンとアナログ回路を組み合わせた積和演算をコード上で試し、さらに非線形な層を追加するとXORのような問題も扱えることを確認できます。