Subscribed unsubscribe Subscribe Subscribe

温度センサーデータをSORACOM Harvestで可視化する

 引き続きIoTエンジニア養成読本のハンズオンの内容を実践中です。今度は温度センサーのデータを読み取って、そのデータをSORACOM Harvestへ送って可視化する処理をRubyで実装してみます。

gihyo.jp

温度センサーの接続

 まずは下記のように温度センサー(DS18B20)を接続します。温度センサーの3本の端子はそれぞれ用途が決まってるので、向きを間違えないように注意です。抵抗は4.7kΩのものを使っています。

f:id:akanuma-hiroaki:20170518013514p:plain:w300

Raspbianの設定

 次に温度センサーの情報を読み取れるようにRaspbianを設定します。まずは /boot/config.txt に下記差分の内容を追記します。gpioponは温度センサーをどのGPIOに接続するかの指定です。デフォルトが4とのことです。ちなみにここの数字を変えて該当するGPIOに接続を変更してみましたがうまくいかなかったので、変更する場合は他にも変更が必要なのかもしれません。

pi@raspberrypi:~ $ diff /boot/config.txt.20170513 /boot/config.txt
56a57,59
> 
> # Enable thermo sensor (DS18B20+)
> dtoverlay=w1-gpio-pullup,gpiopin=4

 そして /etc/modules にも起動時にモジュールが有効になるように下記差分の内容を追記します。

pi@raspberrypi:~ $ diff /etc/modules.20170513 /etc/modules
5c5,6
< 
---
> w1-gpio
> w1-therm

 設定を有効にするために再起動します。

pi@raspberrypi:~ $ sudo shutdown -r now

温度センサーの計測値を読み出してみる

 Rubyでの処理を実装する前に、まずは直接温度センサーの計測値を表示してみます。ここまでの設定がうまくいっていれば、温度センサーは /sys/bus/w1/devices/28-XXXXXXXXXXXX というディレクトリができ、温度センサーにアクセスできます。XXXXXXXXXXXX の部分は個体によって変わります。

pi@raspberrypi:~ $ ls -l /sys/bus/w1/devices/
total 0
lrwxrwxrwx 1 root root 0 May 13 04:33 28-01162e298eee -> ../../../devices/w1_bus_master1/28-01162e298eee
lrwxrwxrwx 1 root root 0 May 13 04:33 w1_bus_master1 -> ../../../devices/w1_bus_master1
pi@raspberrypi:~ $ 

 計測値を表示するにはデバイスのファイルをcatで開きます。

pi@raspberrypi:~ $ cat /sys/bus/w1/devices/28-*/w1_slave
7e 01 4b 46 7f ff 0c 10 f9 : crc=f9 YES
7e 01 4b 46 7f ff 0c 10 f9 t=23875
pi@raspberrypi:~ $ 

 t=XXXXX の部分が温度データで、摂氏を1,000倍したものが表示されています。

定期的な温度測定&Harvestへの送信処理

 それではこの温度センサーのデータを定期的に読み出して、SORACOM Harvestへデータを送信する処理を書いてみます。

require 'bundler/setup'
require 'httpclient'
require 'logger'

SENSOR_FILE_PATH = "/sys/bus/w1/devices/28-*/w1_slave"
HARVEST_URL = 'http://harvest.soracom.io/'

logger = Logger.new('temperature.log')

interval = 60.0
unless ARGV.empty?
  interval = ARGV.first.to_f
end

device_file_name = Dir.glob(SENSOR_FILE_PATH).first
http_client = HTTPClient.new
loop do
  sensor_data = File.read(device_file_name)
  temperature = sensor_data.match(/t=(.*$)/)[1].to_f / 1000
  payload = '{"temperature":"%.3f"}' % temperature
  res = http_client.post(HARVEST_URL, payload, 'Content-Type' => 'application/json')
  logger.info("PAYLOAD: #{payload} / HARVEST Response: #{res.status}")

  sleep(interval)
end

 Harvestへのデータの送信は、SORACOM Air SIMでネットワークに接続した上で、HarvestのエントリポイントへHTTP、TCPもしくはUDPでデータを送信するだけです。今回はHTTPでJSONデータをPOSTしていますので、Content-Typeには application/json を指定します。1分おきにセンサーデータを読み出し、温度データを1,000で割って摂氏の温度に変換し、Harvestに送信しています。データのPOSTにはhttpclientを使っていますので、gem install等でインストールしておきます。

SORACOM Harvest 使用設定

 Harvestでのデータ集計を利用するにはユーザコンソールで設定をしておく必要があります。SORACOMユーザコンソールの左上のメニューから「グループ」を選択し、該当のSimが所属するグループを開いたら、SORACOM Harvest設定を開き、スライドスイッチをONに切り替えます。

f:id:akanuma-hiroaki:20170518015451p:plain

 なお、Harvestを有効にすると、書き込み回数に応じて通信料とは別に料金が発生しますのでご注意ください。

soracom.jp

処理の実行

 それでは処理を実行します。SORACOM Harvestを利用するにはSoracom AIR Simからネットワークに接続している必要がありますので、接続した上で下記のように処理を実行します。

pi@raspberrypi:~ $ ruby temperature.rb &
[2] 2939

 ログファイルには下記のように出力されます。

pi@raspberrypi:~ $ tail -f temperature.log 
I, [2017-05-13T06:06:37.734748 #3352]  INFO -- : PAYLOAD: {"temperature":"24.062"} / HARVEST Response: 201
I, [2017-05-13T06:08:12.354801 #3419]  INFO -- : PAYLOAD: {"temperature":"23.875"} / HARVEST Response: 201
I, [2017-05-13T10:50:18.103700 #2915]  INFO -- : PAYLOAD: {"temperature":"26.500"} / HARVEST Response: 201
I, [2017-05-13T10:50:25.385651 #2939]  INFO -- : PAYLOAD: {"temperature":"26.562"} / HARVEST Response: 201
I, [2017-05-13T10:51:27.733886 #2939]  INFO -- : PAYLOAD: {"temperature":"26.375"} / HARVEST Response: 201
I, [2017-05-13T10:52:29.934142 #2939]  INFO -- : PAYLOAD: {"temperature":"26.437"} / HARVEST Response: 201
I, [2017-05-13T10:53:32.343927 #2939]  INFO -- : PAYLOAD: {"temperature":"26.375"} / HARVEST Response: 201
I, [2017-05-13T10:54:34.634204 #2939]  INFO -- : PAYLOAD: {"temperature":"26.625"} / HARVEST Response: 201
I, [2017-05-13T10:55:36.983980 #2939]  INFO -- : PAYLOAD: {"temperature":"26.625"} / HARVEST Response: 201
I, [2017-05-13T10:56:39.254179 #2939]  INFO -- : PAYLOAD: {"temperature":"26.687"} / HARVEST Response: 201

集計データの確認

 SORACOM Harvestで集計されたデータを見るには、ユーザコンソールのSIM Management画面から該当のSIMを選択し、ActionsからHarvest dataを選択します。

f:id:akanuma-hiroaki:20170518073208p:plain

 すると下記のように集計データがグラフで表示されます。

f:id:akanuma-hiroaki:20170518073321p:plain

 アップロードされたデータの保存期間は40日間ということなので、継続的にデータを保存する必要があるサービスを構築する場合には自前で環境を用意する必要がありますが、センサーで集めたデータをとりあえず集約してみてみたいというような場合には手軽に使えてとても便利だと思います。

SORACOM Air のメタデータとLEDを連動させる

 引き続きIoTエンジニア養成読本のハンズオンの内容を実践中なわけですが、今度はSORACOM AirのメタデータとLEDの点灯を連動させてる処理をRubyで実装してみます。

gihyo.jp

ユーザーコンソールからの設定

 メタデータサービスを使うにはまずユーザコンソールから、グループ設定とメタデータサービスの使用設定をしておく必要があります。

 ユーザコンソールのメニューからグループを作成した後、そのグループのSORACOM Airの設定で、メタデータサービスの使用設定をONにし、Readonlyのチェックを外して設定を保存します。

f:id:akanuma-hiroaki:20170504175133p:plain

 次にSORACOM AirのSim管理画面から、該当のSimのグループを先ほど作成したグループに変更して設定を保存します。

f:id:akanuma-hiroaki:20170504175303p:plain

f:id:akanuma-hiroaki:20170507161630p:plain:w500

SORACOM Air のメタデータサービスに接続

 まずはSORACOM Airで3Gネットワークに接続します。

pi@raspberrypi:~ $ sudo /usr/local/sbin/connect_air.sh &
[1] 30291
pi@raspberrypi:~ $ Found AK-020
waiting for modem device
--> WvDial: Internet dialer version 1.61
--> Cannot get information for serial port.
--> Initializing modem.
--> Sending: ATZ
ATZ
OK
--> Sending: ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0
ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0
OK
--> Sending: AT+CGDCONT=1,"IP","soracom.io"
AT+CGDCONT=1,"IP","soracom.io"
OK
--> Modem initialized.
--> Sending: ATD*99***1#
--> Waiting for carrier.
ATD*99***1#
OK
CONNECT 21000000
--> Carrier detected.  Starting PPP immediately.
--> Starting pppd at Thu May  4 05:36:43 2017
--> Pid of pppd: 30314
--> Using interface ppp0
--> local  IP address 10.247.81.162
--> remote IP address 10.64.64.64
--> primary   DNS address 100.127.0.53
--> secondary DNS address 100.127.1.53

 続いてcurlで直接メタデータサービスに接続してみます。(レスポンスの内容は省略しています)

pi@raspberrypi:~ $ curl http://metadata.soracom.io/v1/subscriber
{...}

 SORACOM Airでネットワークに接続していないとメタデータサービスにはアクセスできません。

 レスポンスのjsonを整形してみると下記のようになります。(一部マスクしています)

{
    "apn": "soracom.io", 
    "createdAt": 1493055578551, 
    "createdTime": 1493055578551, 
    "expiredAt": null, 
    "expiryAction": null, 
    "expiryTime": null, 
    "groupId": "d79b3210-8d23-4a41-8003-6b6acdec5e55", 
    "iccid": "XXXXXXXXXXXXXXXXXX", 
    "imeiLock": null, 
    "imsi": "XXXXXXXXXXXXX", 
    "ipAddress": "10.XXX.XX.XXX", 
    "lastModifiedAt": 1493876507132, 
    "lastModifiedTime": 1493876507132, 
    "moduleType": "nano", 
    "msisdn": "XXXXXXXXXXX", 
    "operatorId": "OPXXXXXXXXX", 
    "plan": 0, 
    "serialNumber": "AXXXXXXXXXXXXX", 
    "sessionStatus": {
        "dnsServers": [
            "100.127.0.53", 
            "100.127.1.53"
        ], 
        "gatewayPublicIpAddress": "54.XXX.XXX.XX", 
        "imei": "XXXXXXXXXXXXX", 
        "lastUpdatedAt": 1493876215192, 
        "location": null, 
        "online": true, 
        "ueIpAddress": "10.XXX.XX.XXX"
    }, 
    "speedClass": "s1.slow", 
    "status": "active", 
    "tags": {}, 
    "terminationEnabled": false, 
    "type": "s1.slow"
}

 取得できるデータの特定の項目だけ取得したい場合は下記のようにURLの末尾に項目名を追加します。

pi@raspberrypi:~ $ curl http://metadata.soracom.io/v1/subscriber.type
s1.slow

メタデータの更新

 メタデータのタグはAPIで取得だけでなく更新も可能です。curlでJSON形式でPUTリクエストを送信してみます。

pi@raspberrypi:~ $ curl http://metadata.soracom.io/v1/subscriber.tags
{}
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ curl -X PUT -H content-type:application/json -d '[{"tagName":"led","tagValue" :"off"}]' http://metadata.soracom.io/v1/subscriber/tags
{...}
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ curl http://metadata.soracom.io/v1/subscriber.tags
{"led":"off"}
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ curl http://metadata.soracom.io/v1/subscriber.tags.led
off
pi@raspberrypi:~ $ 

メタデータのタグ情報によってLEDの状態を変更する

 それではRubyスクリプトからメタデータサービスにアクセスして、タグの内容によってLEDを点灯/消灯してみます。

require 'bundler/setup'
require 'pi_piper'
require 'open-uri'

interval = 60.0
unless ARGV.empty?
  interval = ARGV[0].to_f
end

led_pin = PiPiper::Pin.new(pin: 22, direction: :out)

loop do
  print 'Connecting to Meta-data service... '
  begin
    res = OpenURI.open_uri('http://metadata.soracom.io/v1/subscriber.tags.led', read_timeout: 5)
    code = res.status.first.to_i

    if code != 200
      puts "ERROR: Invalid response code: #{code} message: #{res.status[1]}"
      break
    end

    led_tag = res.read.rstrip
    if led_tag.downcase == 'off'
      puts 'LED tag is OFF. Turn off the LED.'
      led_pin.off
    elsif led_tag.downcase == 'on'
      puts 'LED tag is ON. Turn on the LED.'
      led_pin.on
    end
  rescue => e
    puts e.message
    puts e.backtrace.join("\n")
    break
  end

  if interval > 0
    sleep(interval)
    next
  end

  break
end

 実行結果は下記のようになります。最初はonだったタグ情報をユーザコンソールからoffに変更すると、LEDが消灯されます。またonに変更すると、LEDが点灯されます。

pi@raspberrypi:~ $ sudo bundle exec ruby degital_twin1.rb 10
Connecting to Meta-data service... LED tag is ON. Turn on the LED.
Connecting to Meta-data service... LED tag is ON. Turn on the LED.
Connecting to Meta-data service... LED tag is ON. Turn on the LED.
Connecting to Meta-data service... LED tag is ON. Turn on the LED.
Connecting to Meta-data service... LED tag is OFF. Turn off the LED. ← この直前にユーザコンソールからタグの値をoffに変更
Connecting to Meta-data service... LED tag is OFF. Turn off the LED.
Connecting to Meta-data service... LED tag is ON. Turn on the LED. ← この直前にユーザコンソールからタグの値をonに変更
Connecting to Meta-data service... LED tag is ON. Turn on the LED.

LEDの状態をメタデータに反映する

 次は、スイッチが押されたらLEDのON/OFFを切り替え、その情報をメタデータの方にも反映します。

require 'bundler/setup'
require 'pi_piper'
require 'net/http'
require 'open-uri'

interval = 60.0
unless ARGV.empty?
  interval = ARGV[0].to_f
end

led_pin = PiPiper::Pin.new(pin: 22, direction: :out)
led_pin.off

switch_pin = PiPiper::Pin.new(pin: 23, direction: :in, pull: :up)

start_time = nil

loop do
  start_time = Time.now.to_i

  print '- Connecting to Meta-data service... '
  begin
    res = OpenURI.open_uri('http://metadata.soracom.io/v1/subscriber.tags.led', read_timeout: 5)
    code = res.status.first.to_i

    if code != 200
      puts "ERROR: Invalid response code: #{code} message: #{res.status[1]}"
      break
    end

    led_tag = res.read.rstrip
    if led_tag.downcase == 'off'
      puts 'LED tag is OFF. Turn off the LED.'
      led_pin.off
    elsif led_tag.downcase == 'on'
      puts 'LED tag is ON. Turn on the LED.'
      led_pin.on
    end
  rescue => e
    puts e.message
    puts e.backtrace.join("\n")
    break
  end

  puts "- Waiting input via the switch (%.1f sec)" % (start_time + interval - Time.now.to_i)
  loop do
    switch_pin.read
    led_pin.read
    if switch_pin.value == 0
      puts "The switch has been pushed. Turn %s the LED" % (led_pin.off? ? 'ON' : 'OFF')
      led_pin.off? ? led_pin.on : led_pin.off
      led_pin.read

      print 'Updating Meta-data... '
      payload = '[{"tagName":"led","tagValue":"%s"}]' % (led_pin.on? ? 'on' : 'off')
      uri = URI.parse('http://metadata.soracom.io/v1/subscriber/tags')
      http = Net::HTTP.new(uri.host, uri.port)
      req = Net::HTTP::Put.new(uri.path, initheader = { 'Content-Type' => 'application/json'})
      req.body = payload
      res = http.start {|http| http.request(req) }
      puts "response_code: #{res.code}"
    end

    if Time.now.to_i > start_time + interval
      break
    end

    sleep(0.1)
  end
end

 実行結果は下記の通りです。30秒ごとにメタデータサービスからタグ情報を取得していますが、その間にスイッチが押された場合はその情報をメタデータサービスに反映しています。

pi@raspberrypi:~ $ sudo bundle exec ruby degital_twin2.rb 30
- Connecting to Meta-data service... LED tag is ON. Turn on the LED.
- Waiting input via the switch (29.0 sec)
- Connecting to Meta-data service... LED tag is ON. Turn on the LED.
- Waiting input via the switch (29.0 sec)
The switch has been pushed. Turn OFF the LED ← スイッチを押下
Updating Meta-data... response_code: 200
- Connecting to Meta-data service... LED tag is OFF. Turn off the LED.
- Waiting input via the switch (29.0 sec)
The switch has been pushed. Turn ON the LED ← スイッチを押下
Updating Meta-data... response_code: 200
- Connecting to Meta-data service... LED tag is ON. Turn on the LED.
- Waiting input via the switch (29.0 sec)

Raspberry Pi + RubyでLチカ

 前回でRaspberry Piの初期設定がだいたい終わったので、引き続きIoTエンジニア養成読本のハンズオンの内容をベースにLチカ(LED点滅)をやってみました。

gihyo.jp

 書籍の例ではPythonが使われていますが、そのままやっても面白くないのでRubyで挑戦しました。mrubyでやりたかったのですが、mrubyでGPIOを操作するためのライブラリのmruby-WiringPiとmruby-raspberryを試してみたもののビルドがうまくいかなかったので、ひとまずCRubyでやってみます。

まずは常時点灯

 まずは特に制御はせずに、常時LEDが点灯するように接続してみます。下記の図のように接続するとLEDが点灯します。書籍で使われている抵抗は330Ωですが、購入した抵抗セットの中には含まれてなかったので、1kΩのものを使っています。また、回路図は Fritzing を使って作成しました。

Fritzing
http://fritzing.org/home/

f:id:akanuma-hiroaki:20170504133715p:plain:w300:left

f:id:akanuma-hiroaki:20170504135253j:plain:w300

赤:1番ピン
黒:6番ピン(GND)

コマンドラインからLED点灯

 次にコマンドラインから直接GPIIOを操作してみます。GPIOへの入出力はOSの擬似ファイルとして提供されているようなので、それを操作します。

 まず使用するGPIOのピンをexportファイルに対して設定します。今回はGPIO22のピンを使います。

f:id:akanuma-hiroaki:20170504134511p:plain:w300:left

黒:15番ピン(GPIO22)
赤:6番ピン(GND)


pi@raspberrypi:~ $ ls -l /sys/class/gpio/export 
-rwxrwx--- 1 root gpio 4096 May  1 05:36 /sys/class/gpio/export
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ file /sys/class/gpio/export                                                                                                                                                                                                
/sys/class/gpio/export: ERROR: cannot read `/sys/class/gpio/export' (Input/output error)
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ cat /sys/class/gpio/export                                                                                                                                                                                                 
cat: /sys/class/gpio/export: Input/output error
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ echo 22 > /sys/class/gpio/export                                                                                                                                                                                           
pi@raspberrypi:~ $ 

 するとそのGPIOピンを操作するためのディレクトリが作られるので、まずは入出力の向きを指定します。今回はLEDの点灯のための出力なので、outを設定します。

pi@raspberrypi:~ $ ls -l /sys/class/gpio/gpio22/direction 
-rwxrwx--- 1 root gpio 4096 May  1 06:16 /sys/class/gpio/gpio22/direction
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ file /sys/class/gpio/gpio22/direction                                                                                                                                                                                      
/sys/class/gpio/gpio22/direction: ASCII text
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ cat /sys/class/gpio/gpio22/direction                                                                                                                                                                                       
in
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ echo out > /sys/class/gpio/gpio22/direction                                                                                                                                                                                
pi@raspberrypi:~ $ 

 そしてそのピンの値を1にすると点灯状態になり、0にすると消灯状態に戻ります。

pi@raspberrypi:~ $ ls -l /sys/class/gpio/gpio22/value 
-rwxrwx--- 1 root gpio 4096 May  1 06:16 /sys/class/gpio/gpio22/value
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ file /sys/class/gpio/gpio22/value 
/sys/class/gpio/gpio22/value: ASCII text
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ cat /sys/class/gpio/gpio22/value 
0
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ echo 1 > /sys/class/gpio/gpio22/value 
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ echo 0 > /sys/class/gpio/gpio22/value 
pi@raspberrypi:~ $ 

RubyスクリプトでLチカ

 ではRubyスクリプトでLチカをやってみたいと思います。まずはRaspberry Pi上にRuby環境を作るために、gitとrbenvと、その他必要なライブラリをインストールします。

pi@raspberrypi:~ $ sudo apt-get install git
pi@raspberrypi:~ $ sudo apt-get install rbenv
pi@raspberrypi:~ $ echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> .bashrc
pi@raspberrypi:~ $ echo 'eval "$(rbenv init -)"' >> .bashrc
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ tail .bashrc
# sources /etc/bash.bashrc).
if ! shopt -oq posix; then
  if [ -f /usr/share/bash-completion/bash_completion ]; then
    . /usr/share/bash-completion/bash_completion
  elif [ -f /etc/bash_completion ]; then
    . /etc/bash_completion
  fi
fi
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
Cloning into '/home/pi/.rbenv/plugins/ruby-build'...
remote: Counting objects: 7534, done.
remote: Compressing objects: 100% (48/48), done.
remote: Total 7534 (delta 36), reused 0 (delta 0), pack-reused 7483
Receiving objects: 100% (7534/7534), 1.54 MiB | 850.00 KiB/s, done.
Resolving deltas: 100% (4567/4567), done.
Checking connectivity... done.
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ sudo apt-get install -y libssl-dev libreadline-dev

 そしてruby2.4.1をインストール。

pi@raspberrypi:~ $ rbenv install 2.4.1                                                                                                                                                                                                        
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
        LANGUAGE = (unset),
        LC_ALL = (unset),
        LC_CTYPE = "UTF-8",
        LANG = "en_GB.UTF-8"
    are supported and installed on your system.
perl: warning: Falling back to a fallback locale ("en_GB.UTF-8").
Downloading ruby-2.4.1.tar.bz2...
-> https://cache.ruby-lang.org/pub/ruby/2.4/ruby-2.4.1.tar.bz2
Installing ruby-2.4.1...
Installed ruby-2.4.1 to /home/pi/.rbenv/versions/2.4.1
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ rbenv global 2.4.1
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ rbenv version
2.4.1 (set by /home/pi/.rbenv/version)
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ ruby -v
ruby 2.4.1p111 (2017-03-22 revision 58053) [armv7l-linux-eabihf]

 gemの管理にはbundlerを使います。

pi@raspberrypi:~ $ gem install bundler
Fetching: bundler-1.14.6.gem (100%)
Successfully installed bundler-1.14.6
Parsing documentation for bundler-1.14.6
Installing ri documentation for bundler-1.14.6
Done installing documentation for bundler after 27 seconds
1 gem installed
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ bundle init
Writing new Gemfile to /home/pi/Gemfile
pi@raspberrypi:~ $ 

 GPIOの操作にはroot権限が必要なので、sudoでbundlerが使えるように、visudoでrbenvのパスを追加しておきます。

pi@raspberrypi:~ $ sudo visudo

secure_path に /home/pi/.rbenv/shims を追加

 そして今回はRubyからGPIOを操作するために、Pi Piper を使用してみます。

github.com

 Gemfileに pi_piper の記述を追加してbundle installします。

pi@raspberrypi:~ $ vi Gemfile 
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ cat Gemfile
# frozen_string_literal: true
source "https://rubygems.org"

gem 'pi_piper'
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ bundle install --path vendor/bundle
Fetching gem metadata from https://rubygems.org/...........
Fetching version metadata from https://rubygems.org/.
Resolving dependencies...
Installing eventmachine 1.0.9 with native extensions
Installing ffi 1.9.18 with native extensions
Using bundler 1.14.6
Installing pi_piper 2.0.0
Bundle complete! 1 Gemfile dependency, 4 gems now installed.
Bundled gems are installed into ./vendor/bundle.
pi@raspberrypi:~ $ 

 ここまででスクリプトを実行する準備はできたので、スクリプトを用意します。内容は下記の通り。

require 'bundler/setup'
require 'pi_piper'

pin = PiPiper::Pin.new(pin: 22, direction: :out)

loop do
  pin.on
  sleep 1
  pin.off
  sleep 1
end

 GPIOナンバーと入出力の方向を指定してPinオブジェクトを生成し、onメソッドで点灯、offメソッドで消灯しています。実行にはroot権限が必要なので、sudoでbundler経由でスクリプトを実行します。

pi@raspberrypi:~ $ sudo bundle exec ruby led-flash.rb

youtu.be

スイッチの入力検出

 では次にタクトスイッチを追加して、その入力を検出してみたいと思います。

f:id:akanuma-hiroaki:20170504135109p:plain:w300:left

f:id:akanuma-hiroaki:20170504135454j:plain:w300

赤:15番ピン(GPIO22)
黒:6番ピン(GND)
黄:16番ピン(GPIO23)


require 'bundler/setup'                                    
require 'pi_piper'                                         
                                                           
pin = PiPiper::Pin.new(pin: 23, direction: :in, pull: :up) 
                                                           
loop do                                                    
  pin.read                                                 
  if pin.value == 0                                        
    puts 'The switch has been pushed.'                     
  end                                                      
  sleep 1                                                  
end                                                        

 GPIOナンバーは23で、入出力の向きはinを指定します。プルアップ・プルダウン抵抗はプルアップを指定します。readメソッドで入力値を読み取って、ボタンが押されたら(valueが0だったら)コンソールにメッセージを出力します。

スイッチ押下中だけLED点灯

 次にスイッチの入力とLEDへの出力を組み合わせて、スイッチを押している間だけLEDを点灯させます。

require 'bundler/setup'                                            
require 'pi_piper'                                                 
                                                                   
led_pin = PiPiper::Pin.new(pin: 22, direction: :out)               
switch_pin = PiPiper::Pin.new(pin: 23, direction: :in, pull: :up)  
                                                                   
loop do                                                            
  switch_pin.read                                                  
  if switch_pin.off?                                               
    led_pin.on                                                     
  else                                                             
    led_pin.off                                                    
  end                                                              
  sleep(0.5)                                                       
end                                                                

 スイッチの入力値の関係がまだよくわかっていないのですが、押された時はvalueが0になり、off?メソッドがtrueになるので、その場合はLEDを点灯しています。

スイッチ押下でLEDのON/OFF切り替え

 そして最後にスイッチを押すことでLEDのON/OFFを切り替えるようにしてみます。

require 'bundler/setup'                                                                       
require 'pi_piper'                                                                            
                                                                                              
led_pin = PiPiper::Pin.new(pin: 22, direction: :out)                                          
led_pin.off                                                                                   
                                                                                              
switch_pin = PiPiper::Pin.new(pin: 23, direction: :in, pull: :up)                             
                                                                                              
loop do                                                                                       
  switch_pin.read                                                                             
  if switch_pin.value == 0                                                                    
    puts "Turn %s the LED since the switch has been pushed." % (led_pin.off? ? 'ON' : 'OFF')  
    led_pin.off? ? led_pin.on : led_pin.off                                                   
    led_pin.read                                                                              
  end                                                                                         
  sleep(0.5)                                                                                  
end                                                                                           

 スイッチが押されたらLEDの点灯状態を反転させています。ON/OFF切り替え後にreadメソッドを実行しないと現在の状態が認識されないようだったので、実行しています。

 Pi Piperではループを回して待ち受けるのではなく、watch や after メソッドを使ってイベントドリブンな形で実装することもできるようなのですが、ちょっと試した限りではうまくいかなかったので、いずれそちらの形で実装できるようにしてみたいと思います。

Raspberry Pi + SORACOM Air セットアップ

 前からRaspberry PiやArduino等に興味はあったものの自分では試せていなかったのですが、先日 IoT Technology Conference if-up 2017 で 3G SIM の USBドングルをいただいたので、これを機に自分でもRaspberry Piを購入して色々と試してみることにしました。同じカンファレンスで紹介されていてとても良さそうだったIoTエンジニア養成読本も買って、まずはそのハンズオンの内容をベースに動くものを作ってみようと思います。

gihyo.jp

Raspberry Piのセットアップ

 まずは下記URLからRaspbian OSのイメージをダウンロードします。HDMIモニタやUSBキーボードを接続せずにセットアップしたかったので、デフォルトでsshサーバが起動する2016-09-28のイメージを選択しました。これより後のバージョンだとデフォルトではsshサーバは起動しないようです。(結局LANケーブルが見つからずHDMIモニタとUSBキーボードを繋いでセットアップしたので、最新のを選択してもよかったのですが。。)

http://ftp.jaist.ac.jp/pub/raspberrypi/raspbian_lite/images/raspbian_lite-2016-09-28/

 ダウンロードしたファイルは解凍してimgファイルを取り出しておきます。

 続いてイメージファイルをSDカードに書き込んでいきます。今回は16GBのマイクロSDにアダプタをつけてMacBook ProのSDカードスロットにさします。

f:id:akanuma-hiroaki:20170501134021j:plain:w300

 diskutilコマンドでストレージを確認します。

pi  $ diskutil list
/dev/disk0 (internal, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:      GUID_partition_scheme                        *500.3 GB   disk0
   1:                        EFI EFI                     209.7 MB   disk0s1
   2:          Apple_CoreStorage Macintosh HD            499.4 GB   disk0s2
   3:                 Apple_Boot Recovery HD             650.0 MB   disk0s3

/dev/disk1 (internal, virtual):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:                            Macintosh HD           +499.1 GB   disk1
                                 Logical Volume on disk0s2
                                 D522C0D0-F775-4496-8BDA-640948662DCD
                                 Unlocked Encrypted

/dev/disk2 (internal, physical):
   #:                       TYPE NAME                    SIZE       IDENTIFIER
   0:     FDisk_partition_scheme                        *15.5 GB    disk2
   1:             Windows_FAT_32 NO NAME                 15.5 GB    disk2s1

 サイズから判断して /dev/disk2 がSDカードなので、ddコマンドでSDカードにOSのイメージを書き込みます。マウントしていると書き込めないので、diskutil unmountDisk コマンドでアンマウントしてから実行します。

pi  $ sudo dd if=2016-09-23-raspbian-jessie-lite.img of=/dev/rdisk2 bs=1m
Password:
dd: /dev/rdisk2: Resource busy
pi  $ 
pi  $ diskutil unmountDisk /dev/disk2
Unmount of all volumes on disk2 was successful
pi  $ 
pi  $ sudo dd if=2016-09-23-raspbian-jessie-lite.img of=/dev/rdisk2 bs=1m
1325+0 records in
1325+0 records out
1389363200 bytes transferred in 94.477593 secs (14705743 bytes/sec)
pi  $ 

 MacBook ProからSDカードを取り出してアダプタを外し、Raspberry PiのSDカードスロットにさします。今回買ったのはRaspberry Pi 3 Model Bです。

f:id:akanuma-hiroaki:20170501134726j:plain:w500

 HDMIモニタとUSBキーボードを接続して最後に電源ケーブルを接続すると、OSが起動します。

f:id:akanuma-hiroaki:20170501134859j:plain

 ログインプロンプトが表示されたらデフォルトのログインIDとパスワードでログインし、無線LANへの接続の設定を行います。wpa_passphraseコマンドを利用するのですが、wpa_supplicant.confはrootユーザしか書き込み権限を持っていないので、書籍でも紹介されているようにパイプでつないで tee コマンドをsudoで使って書き込むのが良いのですが、手元のキーボードだとパイプが入力できず、キーボードの設定を変更すれば良いと思うのですが面倒だったので、一時的にroot以外にも書き込み権限を与えてリダイレクトでファイルに追記し、そのあとでまた権限を戻しました。MY_AP_SSID と MY_AP_PASSWORD は接続するAPのSSIDとパスワードに置き換えてください。設定後はOSを再起動します。

$ sudo chmod 606 /etc/wpa_supplicant/wpa_supplicant.conf
$ wpa_passphrase MY_AP_SSID MY_AP_PASSWORD >> /etc/wpa_supplicant/wpa_supplicant.conf
$ cat /etc/wpa_supplicant/wpa_supplicant.conf
$ sudo chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf
$ sudo reboot

 再起動後にipコマンドで無線LANへ接続できていてIPアドレスが割り当てられていることを確認します。

pi@raspberrypi:~ $ ip a show dev wlan0
3: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether b8:27:eb:e6:89:f8 brd ff:ff:ff:ff:ff:ff
    inet 192.168.10.10/24 brd 192.168.10.255 scope global wlan0
       valid_lft forever preferred_lft forever
    inet6 2408:212:2862:5c00:b342:9793:7abe:c897/64 scope global noprefixroute dynamic 
       valid_lft 2591786sec preferred_lft 604586sec
    inet6 fe80::bc23:73fb:4394:7972/64 scope link 
       valid_lft forever preferred_lft forever

 同じLAN内のRaspberry Pi端末には raspberrypi.local でアクセスできるので、下記のようにMacBook Proからsshでログインします。

pi  $ ssh pi@raspberrypi.local
The authenticity of host 'raspberrypi.local (2408:212:2862:5c00:b342:9793:7abe:c897)' can't be established.
ECDSA key fingerprint is SHA256:B98CBwQsKcPnKMeIGBNQ065GNnMXZvBm1pKoHc7+0Zw.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'raspberrypi.local,2408:212:2862:5c00:b342:9793:7abe:c897' (ECDSA) to the list of known hosts.
pi@raspberrypi.local's password: 

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sat Apr 29 16:26:21 2017

 下記コマンドでRaspbianを最新の状態にアップデートします。

% sudo apt-get update
% sudo apt-get upgrade
% sudo apt-get dist-upgrade

 最後にpasswdコマンドでデフォルトのログインパスワードをオリジナルのものに変更して、基本的なセットアップは終了です。

SORACOM Air での接続セットアップ

 カンファレンスでいただいた3G Sim の USBドングルを使って、SORACOM Airでネットワーク接続できるようにセットアップします。事前にSORACOM AirのSimを購入して、SORACOMのユーザアカウントの作成とSimの登録を済ませておきます。

f:id:akanuma-hiroaki:20170501140826j:plain:w500

f:id:akanuma-hiroaki:20170501141019p:plain

 いただいたUSBドングルはABIT AK-020です。

f:id:akanuma-hiroaki:20170501141204j:plain:w500

 3G接続に必要なパッケージをインストールします。

pi@raspberrypi:~ $ sudo apt-get install -y usb-modeswitch wvdial
Reading package lists... Done
Building dependency tree       
Reading state information... Done
usb-modeswitch is already the newest version.
The following extra packages will be installed:
  libpcap0.8 libuniconf4.6 libwvstreams4.6-base libwvstreams4.6-extras ppp
The following NEW packages will be installed:
  libpcap0.8 libuniconf4.6 libwvstreams4.6-base libwvstreams4.6-extras ppp wvdial
0 upgraded, 6 newly installed, 0 to remove and 0 not upgraded.
Need to get 1390 kB of archives.
After this operation, 3127 kB of additional disk space will be used.
Get:1 http://mirrordirector.raspbian.org/raspbian/ jessie/main libpcap0.8 armhf 1.6.2-2 [121 kB]
Get:2 http://mirrordirector.raspbian.org/raspbian/ jessie/main libwvstreams4.6-base armhf 4.6.1-7 [235 kB]
Get:3 http://mirrordirector.raspbian.org/raspbian/ jessie/main libwvstreams4.6-extras armhf 4.6.1-7 [448 kB]
Get:4 http://mirrordirector.raspbian.org/raspbian/ jessie/main libuniconf4.6 armhf 4.6.1-7 [173 kB]
Get:5 http://mirrordirector.raspbian.org/raspbian/ jessie/main ppp armhf 2.4.6-3.1 [306 kB]
Get:6 http://mirrordirector.raspbian.org/raspbian/ jessie/main wvdial armhf 1.61-4.1 [107 kB]
Fetched 1390 kB in 2s (493 kB/s)
Can't set locale; make sure $LC_* and $LANG are correct!
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
        LANGUAGE = (unset),
        LC_ALL = (unset),
        LC_CTYPE = "UTF-8",
        LANG = "en_GB.UTF-8"
    are supported and installed on your system.
perl: warning: Falling back to a fallback locale ("en_GB.UTF-8").
locale: Cannot set LC_CTYPE to default locale: No such file or directory
locale: Cannot set LC_ALL to default locale: No such file or directory
Preconfiguring packages ...
Selecting previously unselected package libpcap0.8:armhf.
(Reading database ... 31414 files and directories currently installed.)
Preparing to unpack .../libpcap0.8_1.6.2-2_armhf.deb ...
Unpacking libpcap0.8:armhf (1.6.2-2) ...
Selecting previously unselected package libwvstreams4.6-base.
Preparing to unpack .../libwvstreams4.6-base_4.6.1-7_armhf.deb ...
Unpacking libwvstreams4.6-base (4.6.1-7) ...
Selecting previously unselected package libwvstreams4.6-extras.
Preparing to unpack .../libwvstreams4.6-extras_4.6.1-7_armhf.deb ...
Unpacking libwvstreams4.6-extras (4.6.1-7) ...
Selecting previously unselected package libuniconf4.6.
Preparing to unpack .../libuniconf4.6_4.6.1-7_armhf.deb ...
Unpacking libuniconf4.6 (4.6.1-7) ...
Selecting previously unselected package ppp.
Preparing to unpack .../ppp_2.4.6-3.1_armhf.deb ...
Unpacking ppp (2.4.6-3.1) ...
Selecting previously unselected package wvdial.
Preparing to unpack .../wvdial_1.61-4.1_armhf.deb ...
Unpacking wvdial (1.61-4.1) ...
Processing triggers for man-db (2.7.0.2-5) ...
Processing triggers for systemd (215-17+deb8u6) ...
Setting up libpcap0.8:armhf (1.6.2-2) ...
Setting up libwvstreams4.6-base (4.6.1-7) ...
Setting up libwvstreams4.6-extras (4.6.1-7) ...
Setting up libuniconf4.6 (4.6.1-7) ...
Setting up ppp (2.4.6-3.1) ...
Setting up wvdial (1.61-4.1) ...
locale: Cannot set LC_CTYPE to default locale: No such file or directory
locale: Cannot set LC_ALL to default locale: No such file or directory

Sorry.  You can retry the autodetection at any time by running "wvdialconf".
   (Or you can create /etc/wvdial.conf yourself.)
Processing triggers for libc-bin (2.19-18+deb8u7) ...
Processing triggers for systemd (215-17+deb8u6) ...

 SORACOM Airでネットワーク接続するためのスクリプトをダウンロードして実行権限を付与します。

pi@raspberrypi:~ $ curl -O http://soracom-files.s3.amazonaws.com/connect_air.sh
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2843  100  2843    0     0  23012      0 --:--:-- --:--:-- --:--:-- 23113
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ ls -l
total 4
-rw-r--r-- 1 pi pi 2843 Apr 30 04:08 connect_air.sh
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ chmod +x connect_air.sh 
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ ls -l
total 4
-rwxr-xr-x 1 pi pi 2843 Apr 30 04:08 connect_air.sh
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ sudo mv connect_air.sh /usr/local/sbin/
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ ls -l /usr/local/sbin/connect_air.sh 
-rwxr-xr-x 1 pi pi 2843 Apr 30 04:08 /usr/local/sbin/connect_air.sh

 そしてUSBドングルにSimを入れて、Raspberry PiのUSBポートに挿し、スクリプトを実行します。

pi@raspberrypi:~ $ sudo /usr/local/sbin/connect_air.sh 
Found AK-020
Configuring modem ... done.
waiting for modem device..done.
Resetting modem ...done
could not initialize AK-020
waiting for modem device
--> WvDial: Internet dialer version 1.61
--> Cannot get information for serial port.
--> Initializing modem.
--> Sending: ATZ
ATZ
OK
--> Sending: ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0
ATQ0 V1 E1 S0=0 &C1 &D2 +FCLASS=0
OK
--> Sending: AT+CGDCONT=1,"IP","soracom.io"
AT+CGDCONT=1,"IP","soracom.io"
OK
--> Modem initialized.
--> Sending: ATD*99***1#
--> Waiting for carrier.
ATD*99***1#
CONNECT 21000000
--> Carrier detected.  Starting PPP immediately.
--> Starting pppd at Sun Apr 30 04:19:32 2017
--> Pid of pppd: 1873
--> Using interface ppp0
--> local  IP address 10.247.81.162
--> remote IP address 10.64.64.64
--> primary   DNS address 100.127.0.53
--> secondary DNS address 100.127.1.53

 SORACOMのユーザーコンソールから確認すると、SessionがOnlineになっていて、接続できていることが確認できます。

f:id:akanuma-hiroaki:20170501142949p:plain

ifup されてきました(if-up2017に参加してきました)

 先日ソラコムさん主催の IoT Technology Conference if-up 2017 に行ってきました。ifup というのはLinuxでネットワークインタフェースを有効にするためのコマンドで、参加者それぞれの IoT Technology に関するインタフェースをUPしてほしいということでカンファレンスのタイトルに使われたそうです。セッションの内容はどれも興味深く、参考になるものばかりで、まんまと私も ifup されてきたわけですが、特にソラコムCTOの安川さんによる、「SORACOM Inside」という、SORACOMの設計思想やアーキテクチャを紹介されたキーノートの内容がとても参考になったので、その中で特に印象深かった点を紹介させていただきます。

f:id:akanuma-hiroaki:20170430000154j:plain:w300f:id:akanuma-hiroaki:20170430000202j:plain:w500

 安川さんのセッション資料はこちらで公開されています。

www.slideshare.net

Polaris, Dipper, Hubble

 SORACOMプラットフォームは、パケット転送等の主要機能を担う Polaris(北極星)、認証や課金などの周辺機能を担う Dipper(北斗七星)、監視やデプロイを担う Hubble(宇宙望遠鏡)という要素で構成されているそう。プロダクトの役割と思想がマッチしたこういうネーミングはかっこいいなーと思いました。内部のメンバーとしても、それぞれのプロダクトの役割について共通の認識が持てるのではないかと思います。

ローンチ時からグローバル対応

 SORACOMユーザーコンソールはローンチ時からグローバル対応していて、HTML/JS/CSSによるSPAとして構成し、APIでSORACOMプラットフォームと連携し、S3にアップロードすることでCloudFrontで世界に配信されるようにしているとのこと。また、最初から多言語に対応し、タイムゾーンはUTCに統一しているとのことでした。一度普通のWebアプリとして構成してしまったものを後からSPAの構成に変更するのは大変そうですが、最初からこの構成を意識していれば、確かにS3にアップロードするだけでユーザーコンソールアプリがデプロイできるのは良いなと思いました。タイムゾーンもJSTをベースにしようとすると、色々な箇所でミスなくJSTに統一するのも大変ですし、UTCに統一することができれば後から世界展開するときにも確かに面倒なことが少なそうです。

疎結合化と非同期化

 SORACOMでは各サービス間の連携はs3を経由するなどして疎結合化されていて、一方に障害が起こっても、他方に直接的な影響がないようにしているとのこと。また、データ形式さえ決めておけば、それぞれのサービスの開発も非同期で行えるので、開発速度の向上にも有効なようです。いわゆるマイクロサービスの構成かと思いますが、前述のSPAの構成と同様で、一度モノリシックな構成で作ってしまうと後からマイクロサービスに分けるのは現実的ではなかったりします。スタートアップのサービス立ち上げ当初は特にモノリシックな一つのWebアプリとして一気に作ってしまうケースが多いかと思いますが、マイクロサービスの構成を最初から意識して作られている点はすごいと思いました。

DevOpsとOpsDev

 ソラコムの開発者は全員DevOpsを実践されているそうですが、運用の守りが手薄にならないよう、運用作業省力化のための開発を専門的に行うOpsDevエンジニアを導入されているそう。Hubbleの中でも障害を検知した時は、インスタンスの入れ替えで復旧できるようなものは、自動的に復旧させるようにしているとのことです。検知から通知まではどこの会社でも普通に実施していると思いますが、自動復旧までやれているケースはあまりないのではないでしょうか。また、OpsDevという考え方は聞いたことがありませんでしたが、運用省力化のための開発が好きなエンジニアがいるケースであれば、有効な選択肢だと思いました。うちの会社でも取り入れてみようかと思っています。

当たり前をちゃんとやれてるのがすごい

 講演後に安川さんとお話しさせていただいたときに、「内容としては当たり前のことばかりなので、講演前は聞いている方の反応がどうなるか不安があった」ということでした。マイクロサービスの考え方など、確かに一つ一つは広まっているものも多いですが、それぞれをしっかりとやり切れているというのはなかなかないと思いますし、とても参考になりました。また、システムの構成以外にも、ソラコムさんではリーダーシップ・ステートメントや、毎日行われているSyncというミーティングなど、チームアップの面でも参考にさせていただきたい点が多いので、またお話させていただきたいなぁと思っています。

 それと、今回参加者には参加特典としてIoTデバイスがプレゼントされていて、私は3G SIMのUSBドングルをもらいました。そこでこれを機にRaspberry Piも買ったので、色々やってみようと思います。

 今回一つ心残りだったのは、休憩時間中にまつもとゆきひろさんと玉川さんと3人でお話させていただく機会があったのですが、なかなかこのお二人と一緒に話をさせてもらう機会はないと思うので、3人で写真を撮らせてもらえば良かったと後になって思いました。次に機会があれば撮らせていただこうと思っています。

f:id:akanuma-hiroaki:20170430000122j:plain:w300

各種パラメータ最適化手法の実装(SGD, Momentum, AdaGrad, Adam)

 今回は「ゼロから作るDeepLearning」で紹介されている各種パラメータ最適化手法を、書籍のPythonのサンプルコードをベースに、Rubyで実装してみました。

www.oreilly.co.jp

 各手法のロジックについては書籍で説明されていますので割愛します。また、前回の記事で書いたように、Rubyでは値の受け渡しが参照の値渡しになるので、パラメータのハッシュの各値は配列として保持する前提です。

SGD(確率的勾配降下法)

 SGDは前回の記事でもすでに使っていたのと同じで、別クラスとして分けただけのものです。

class SGD
  def initialize(lr: 0.01)
    @lr = lr
  end

  def update(params:, grads:)
    params.keys.each do |key|
      params[key][0] -= @lr * grads[key]
    end
  end
end

Momentum

 paramsのハッシュの各値を配列として扱っている以外は、PythonのコードをそのままRubyに置き換えています。インスタンス変数 v にはparamsと同じ構造の値を持ちますが、インスタンス内でしか使わないため、ハッシュの値は配列にはせずにそのままパラメータを保持しています。

 ゼロ行列の生成は Numo::NArray.zeros メソッドを使っています。

Numo::NArray.zeros
http://ruby-numo.github.io/narray/narray/Numo/NArray.html#zeros-class_method

class Momentum
  def initialize(lr: 0.01, momentum: 0.9)
    @lr = lr
    @momentum = momentum
    @v = nil
  end

  def update(params:, grads:)
    if @v.nil?
      @v = {}
      params.each do |key, value|
        @v[key] = Numo::DFloat.zeros(value.first.shape)
      end
    end

    params.keys.each do |key|
      @v[key] = @momentum * @v[key] - @lr * grads[key]
      params[key][0] += @v[key]
    end
  end
end

AdaGrad

 こちらもMomentumと同様にPythonのコードを置き換えています。

 行列に対しての平方根の計算は、Numo::DFloat::Math.sqrt メソッドを使っています。

Numo::DFloat::Math.sqrt
http://ruby-numo.github.io/narray/narray/Numo/DFloat/Math.html#sqrt-class_method

class AdaGrad
  def initialize(lr: 0.01)
    @lr = lr
    @h = nil
  end

  def update(params:, grads:)
    if @h.nil?
      @h = {}
      params.each do |key, value|
        @h[key] = Numo::DFloat.zeros(value.first.shape)
      end
    end

    params.keys.each do |key|
      @h[key] += grads[key] * grads[key]
      params[key][0] -= @lr * grads[key] / (Numo::DFloat::Math.sqrt(@h[key]) + 1e-7)
    end
  end
end

Adam

 こちらも同様の置き換えです。

class Adam
  def initialize(lr: 0.001, beta1: 0.9, beta2: 0.999)
    @lr = lr
    @beta1 = beta1
    @beta2 = beta2
    @iter = 0
    @m = nil
    @v = nil
  end

  def update(params:, grads:)
    if @m.nil?
      @m = {}
      @v = {}
      params.each do |key, value|
        @m[key] = Numo::DFloat.zeros(value.first.shape)
        @v[key] = Numo::DFloat.zeros(value.first.shape)
      end
    end

    @iter += 1
    lr_t = @lr * Numo::DFloat::Math.sqrt(1.0 - @beta2 ** @iter) / (1.0 - @beta1 ** @iter)

    params.keys.each do |key|
      @m[key] += (1 - @beta1) * (grads[key] - @m[key])
      @v[key] += (1 - @beta2) * (grads[key] ** 2 - @v[key])

      params[key][0] -= lr_t * @m[key] / (Numo::DFloat::Math.sqrt(@v[key]) + 1e-7)
    end
  end
end

MNISTデータセットによる最適化手法の比較

 上記の最適化手法の実装について、MNISTデータセットを用いた学習の比較を行います。こちらも基本的には書籍のPython実装をベースにしていて、5 層のニューラルネットワークで、各層 100 個のニューロンを持つ ネットワークという構成です。活性化関数にはReLUを用いています。

 まずは複数レイヤのネットワークの処理を行うためのMultiLayerNetクラスの実装です。以前のTwoLayerNetを、3層以上のネットワークにも対応させた形です。

require 'numo/narray'
require './layers.rb'

class MultiLayerNet
  def initialize(input_size:, hidden_size_list:, output_size:, activation: :relu, weight_init_std: :relu, weight_decay_lambda: 0)
    @input_size          = input_size
    @output_size         = output_size
    @hidden_size_list    = hidden_size_list
    @hidden_layer_num    = hidden_size_list.size
    @weight_decay_lambda = weight_decay_lambda
    @params              = {}

    # 重みの初期化
    init_weight(weight_init_std)

    # レイヤの生成
    activation_layer = {
      sigmoid: Sigmoid,
      relu:    Relu
    }
    @layers = {}
    (1..@hidden_layer_num).each do |idx|
      @layers["Affine#{idx}"] = Affine.new(w: @params["w#{idx}"], b: @params["b#{idx}"])
      @layers["Activation_function#{idx}"] = activation_layer[activation].new
    end

    idx = @hidden_layer_num + 1
    @layers["Affine#{idx}"] = Affine.new(w: @params["w#{idx}"], b: @params["b#{idx}"])

    @last_layer = SoftmaxWithLoss.new
  end

  def params
    @params
  end

  def init_weight(weight_init_std)
    all_size_list = [@input_size] + @hidden_size_list + [@output_size]
    (1..(all_size_list.size - 1)).each do |idx|
      scale = weight_init_std
      if %i(relu he).include?(weight_init_std)
        scale = Numo::DFloat::Math.sqrt(2.0 / all_size_list[idx - 1])
      elsif %i(sigmoid xavier).include?(weight_init_std)
        scale = Numo::DFloat::Math.sqrt(1.0 / all_size_list[idx - 1])
      end

      Numo::NArray.srand
      @params["w#{idx}"] = [scale * Numo::DFloat.new(all_size_list[idx - 1], all_size_list[idx]).rand_norm]
      @params["b#{idx}"] = [Numo::DFloat.zeros(all_size_list[idx])]
    end
  end

  def predict(x:)
    @layers.values.inject(x) do |x, layer|
      x = layer.forward(x: x)
    end
  end

  # x: 入力データ, t: 教師データ
  def loss(x:, t:)
    y = predict(x: x)

    weight_decay = 0
    (1..(@hidden_layer_num + 1)).each do |idx|
      w = @params["w#{idx}"].first
      weight_decay += 0.5 * @weight_decay_lambda * (w ** 2).sum
    end
    @last_layer.forward(x: y, t: t) + weight_decay
  end

  def accuracy(x:, t:)
    y = predict(x: x)
    y = y.max_index(1) % 10
    if t.ndim != 1
      t = t.max_index(1) % 10
    end

    y.eq(t).cast_to(Numo::UInt16).sum / x.shape[0].to_f
  end

  def gradient(x:, t:)
    # forward
    loss(x: x, t: t)

    # backward
    dout = 1
    dout = @last_layer.backward(dout: dout)

    layers = @layers.values.reverse
    layers.inject(dout) do |dout, layer|
      dout = layer.backward(dout: dout)
    end

    grads = {}
    (1..(@hidden_layer_num + 1)).each do |idx|
      grads["w#{idx}"] = @layers["Affine#{idx}"].dw + @weight_decay_lambda * @layers["Affine#{idx}"].w.first
      grads["b#{idx}"] = @layers["Affine#{idx}"].db
    end

    grads
  end
end

 そして各手法での学習と、結果のグラフ描画を行うためのスクリプトの実装です。基本的な処理は前回の誤差逆伝播法での学習処理と同じで、SGD以外にもMomentum、AdaGrad、Adamでの学習を行い、結果を比較しています。

require 'numo/gnuplot'
require './mnist.rb'
require './optimizers.rb'
require './multi_layer_net.rb'

# 0: MNISTデータの読み込み
x_train, t_train, x_test, t_test = load_mnist(normalize: true)

train_size = x_train.shape[0]
batch_size = 128
max_iterations = 1500

# 1: 実験の設定
optimizers = {
  sgd:      SGD.new,
  momentum: Momentum.new,
  adagrad:  AdaGrad.new,
  adam:     Adam.new
}

networks = {}
train_loss = {}
optimizers.each do |key, optimizer|
  networks[key]   = MultiLayerNet.new(input_size: 784, hidden_size_list: [100, 100, 100, 100], output_size: 10)
  train_loss[key] = []
end

# 2: 訓練の開始
max_iterations.times do |i|
  Numo::NArray.srand
  batch_mask = Numo::Int32.new(batch_size).rand(0, train_size)
  x_batch = x_train[batch_mask, true]
  t_batch = t_train[batch_mask]

  optimizers.each do |key, optimizer|
    grads = networks[key].gradient(x: x_batch, t: t_batch)
    optimizers[key].update(params: networks[key].params, grads: grads)

    loss = networks[key].loss(x: x_batch, t: t_batch)
    train_loss[key] << loss
  end

  next unless i % 100 == 0

  puts "========== iteration: #{i} =========="
  optimizers.keys.each do |key|
    loss = networks[key].loss(x: x_batch, t: t_batch)
    puts "#{key}: #{loss}"
  end
end

# 3: グラフの描画
x = (0..(max_iterations - 1)).to_a
Numo.gnuplot do
  set xlabel: 'iterations'
  set ylabel: 'loss'
  set yrange: 0...1
  plot x, train_loss[:sgd],      { w: :lines, t: 'SGD',      lc_rgb: 'green',  lw: 1 },
       x, train_loss[:momentum], { w: :lines, t: 'Momentum', lc_rgb: 'orange', lw: 1 },
       x, train_loss[:adagrad],  { w: :lines, t: 'AdaGrad',  lc_rgb: 'red',    lw: 1 },
       x, train_loss[:adam],     { w: :lines, t: 'Adam',     lc_rgb: 'blue',   lw: 1 }
end

 これをirbから実行すると下記のようにコンソールに結果が100イテレーションごとに表示され、最後にグラフが描画されます。

irb(main):001:0> load './optimizer_compare_mnist.rb'
========== iteration: 0 ==========
sgd: 2.490228492804417
momentum: 2.492025460726973
adagrad: 2.0825493921580134
adam: 2.276848398313694
========== iteration: 100 ==========
sgd: 1.5001470235084333
momentum: 0.42706984858496944
adagrad: 0.2004084345838115
adam: 0.34096154614776963
========== iteration: 200 ==========
sgd: 0.7664658855425125
momentum: 0.2733076953685949
adagrad: 0.11048856792711875
adam: 0.24828428524427817
========== iteration: 300 ==========
sgd: 0.5159466996794285
momentum: 0.25594092908625543
adagrad: 0.13587365073017388
adam: 0.20545236418926946
========== iteration: 400 ==========
sgd: 0.5042479159281286
momentum: 0.24538385847033825
adagrad: 0.11124732792005954
adam: 0.1797581753729203
========== iteration: 500 ==========
sgd: 0.32290967978019125
momentum: 0.1599522422679423
adagrad: 0.05731788233265379
adam: 0.0888823836035264
========== iteration: 600 ==========
sgd: 0.44467997494741673
momentum: 0.2578398459161452
adagrad: 0.1316129116477675
adam: 0.22439066383913017
========== iteration: 700 ==========
sgd: 0.28407622085704987
momentum: 0.10056311655844065
adagrad: 0.07989693533502204
adam: 0.0821317626167635
========== iteration: 800 ==========
sgd: 0.2706466278682429
momentum: 0.15550352523100197
adagrad: 0.06489312962717893
adam: 0.08528336870483003
========== iteration: 900 ==========
sgd: 0.2240422184352822
momentum: 0.11062658792202897
adagrad: 0.059913720263603615
adam: 0.03302573552710864
========== iteration: 1000 ==========
sgd: 0.3832020077768542
momentum: 0.13726781722583942
adagrad: 0.03417701415203686
adam: 0.053558781255080776
========== iteration: 1100 ==========
sgd: 0.38619949224379774
momentum: 0.15175966760909282
adagrad: 0.04222220798211423
adam: 0.06822940475295906
========== iteration: 1200 ==========
sgd: 0.2998755819916694
momentum: 0.07572742725924923
adagrad: 0.075962654676941
adam: 0.02748595912322749
========== iteration: 1300 ==========
sgd: 0.25322815781566416
momentum: 0.06003606774412698
adagrad: 0.03236788855975958
adam: 0.053987864918752786
========== iteration: 1400 ==========
sgd: 0.2720482764348912
momentum: 0.09105631835160209
adagrad: 0.044338756972504875
adam: 0.0668196066196452
=> true

f:id:akanuma-hiroaki:20170421083501p:plain

 イテレーションの回数を1,500回としていますが、手元のVM環境ではこれ以上回数を増やすと途中で強制終了されてしまいました。Pythonコードをそのままの構成で移植しているので、もっとRubyに最適化した実装を考慮する必要がありそうです。

 Numo.gnuplot でのグラフ描画時は、setメソッドによる設定はplotより前に実行しておかないとグラフに反映されないようでした。

 コードは下記リポジトリにも公開しています。

github.com

ニューラルネットワークの誤差逆伝播法による学習アルゴリズムの実装

 今回も引き続き「ゼロから作るDeepLearning」をベースに、前回数値微分で実装した学習アルゴリズムの誤差逆伝播法版をRubyで実装してみました。計算の内容等は書籍を参照いただくとして、Rubyで実装した際のポイントを説明していきたいと思います。

www.oreilly.co.jp

活性化関数レイヤ実装

 今回はニューラルネットワークの各レイヤをそれぞれ一つのクラスとして実装しています。活性化関数レイヤはReLUレイヤとSigmoidレイヤです。

ReLUレイヤ

 ReLUレイヤは下記のように実装しました。

class Relu
  def initialize
    @mask = nil
  end

  def forward(x:)
    @mask = (x <= 0)
    out = x.copy
    out[@mask] = 0
    out
  end

  def backward(dout:)
    dout[@mask] = 0
    dout
  end
end

 順伝播時のforwardメソッドの引数としてはNArray配列を期待しています。NArray配列 x に対して (x <= 0) をすると0以下の要素が1、それ以外が0のBit配列を返しますので、それをマスクに使い、入力に対して0以下の要素を0に置き換えた結果を返しています。また、マスクは逆伝播時にも使うのでインスタンス変数に保持します。

 逆伝播時のbackwardメソッドでは順伝播時に保持したマスクを元に、上流からの入力に対してマスクの要素が1になっている要素に0を設定しています。

Sigmoidレイヤ

 Sigmoidレイヤの実装は下記のように行いました。

require './sigmoid.rb'

class Sigmoid
  def initialize
    @out = nil
  end

  def forward(x:)
    @out = sigmoid(x)
    @out
  end

  def backword(dout:)
    dout * (1.0 - @out) * @out
  end
end

 順伝播時は以前実装したsigmoidメソッドを呼んでいるだけで、その結果をインスタンス変数に保持しておきます。

 逆伝播時は順伝播時の出力を元に計算を行なった結果を返します。

AffineレイヤとSoftmaxレイヤの実装

 ニューラルネットワークの順伝播において重みの計算とバイアスの加算を行なっていた層をAffineレイヤとして実装し、出力層ではソフトマックス関数を用いて出力を正規化するSoftmaxレイヤを実装し、それぞれの順伝播、逆伝播の処理を実装します。

Affineレイヤ

 Affineレイヤの実装は下記の通りです。

class Affine
  def initialize(w:, b:)
    @w = w
    @b = b

    @x = nil
    @original_x_shape = nil

    # 重み・バイアスパラメータの微分
    @dw = nil
    @db = nil
  end

  def dw
    @dw
  end

  def db
    @db
  end

  def forward(x:)
    # テンソル対応
    @original_x_shape = x.shape
    x = x.reshape(x.shape[0], nil)
    @x = x

    @x.dot(@w.first) + @b.first
  end

  def backward(dout:)
    dx = dout.dot(@w.first.transpose)
    @dw = @x.transpose.dot(dout)
    @db = dout.sum(0)

    dx.reshape(*@original_x_shape)
  end
end

 initializeメソッドでは重み、バイアスパラメータをインスタンス変数に格納し、それ以外にも処理に必要になるインスタンス変数を定義しています。

 順伝播時は入力の行列の形と入力行列を保持し、入力行列と重みパラメータの内積にバイアスを加算した結果を返します。

 逆伝播時はまず内積の逆伝播の計算として重みパラメータと順伝播時の入力値の転置行列を使った計算を行なっています。NArray行列の転置行列はtransposeメソッドで取得できます。

Numo::NArray#transpose
http://ruby-numo.github.io/narray/narray/Numo/NArray.html#transpose-instance_method

 バイアスの逆伝播の計算では行列の0番目の軸(データ単位)に対しての合計を求めるため、sumメソッドのパラメータに0を指定しています。

Numo::UInt32#sum
http://ruby-numo.github.io/narray/narray/Numo/Int32.html#sum-instance_method

 最後に入力値の逆伝播の計算結果を入力値と同じ行列の形にreshapeして返しています。入力値の形状は変数に配列データとして格納していますが、reshapeメソッドのパラメータは配列ではないので、* で配列を展開して渡しています。

Softmaxレイヤ

 Softmaxレイヤは損失関数である交差エントロピー誤差の計算も含めて、SoftmaxWithLossクラスとして下記のように実装しました。

require './softmax.rb'
require './cross_entropy_error.rb'

class SoftmaxWithLoss
  def initialize
    @loss = nil
    @y = nil # softmaxの出力
    @t = nil # 教師データ
  end

  def forward(x:, t:)
    @t = t
    @y = softmax(x)
    @loss = cross_entropy_error(@y, @t)

    @loss
  end

  def backward(dout: 1)
    batch_size = @t.shape[0]
    if @t.size == @y.size # 教師データがon-hot-vectorの場合
      return (@y - @t) / batch_size
    end

    dx = @y.copy

    (0..(batch_size - 1)).to_a.zip(@t).each do |index_array|
      dx[*index_array] -= 1
    end

    dx / batch_size
  end
end

 順伝播時は以前実装したsoftmaxメソッドとcross_entropy_errorメソッドを使用しています。入力値をsoftmaxメソッドで正規化し、その結果と教師データをcross_entropy_errorメソッドに渡して交差エントロピー誤差を計算しています。

 逆伝播時はデータ一個あたりの誤差を伝播するために誤差をデータ数で割っています。

 行列の要素の参照方法については、Pythonのndarrayの場合は配列を二つ渡すと、それぞれを行・列のインデックスとして要素を参照してくれますが、NArray行列で同じような指定をすると、一つ目の配列で指定した全ての行で、二つ目の配列に指定した全ての要素が取得されてしまいます。

>>> array
array([[ 1,  2,  3,  4],
       [ 5,  6,  7,  8],
       [ 9, 10, 11, 12]])
>>> array[[0, 2], [1, 3]]
array([ 2, 12])
irb(main):021:0> array                
=> Numo::UInt32#shape=[3,4]           
[[1, 2, 3, 4],                        
 [5, 6, 7, 8],                        
 [9, 10, 11, 12]]                     
irb(main):022:0> array[[0, 2], [1, 3]]
=> Numo::UInt32(view)#shape=[2,2]     
[[2, 4],                              
 [10, 12]]                            

 そこでzipメソッドを使ってそれぞれの配列から一つずつデータを取得し、直接一つの要素を参照するようにしました。

irb(main):024:0* [0, 2].zip([1, 3]).each do |array_index| 
irb(main):025:1*   puts array[*array_index]               
irb(main):026:1> end                                      
2                                                         
12                                                        
=> [[0, 1], [2, 3]]                                       

誤差逆伝播法でのニューラルネットワーク実装

 上記で実装したレイヤを組み合わせて、誤差逆伝播法でのニューラルネットワークを実装します。下記のように一つのクラスとして実装します。

require 'numo/narray'
require './numerical_gradient.rb'
require './layers.rb'

class TwoLayerNet
  def initialize(input_size:, hidden_size:, output_size:, weight_init_std: 0.01)
    # 重みの初期化
    Numo::NArray.srand
    @params = {
      w1: [weight_init_std * Numo::DFloat.new(input_size, hidden_size).rand_norm],
      b1: [Numo::DFloat.zeros(hidden_size)],
      w2: [weight_init_std * Numo::DFloat.new(hidden_size, output_size).rand_norm],
      b2: [Numo::DFloat.zeros(output_size)]
    }

    # レイヤの生成
    @layers = {
      affine1: Affine.new(w: @params[:w1], b: @params[:b1]),
      relu1:   Relu.new,
      affine2: Affine.new(w: @params[:w2], b: @params[:b2])
    }
    @last_layer = SoftmaxWithLoss.new
  end

  def params
    @params
  end

  def predict(x:)
    @layers.values.inject(x) do |x, layer|
      x = layer.forward(x: x)
    end
  end

  # x: 入力データ, t: 教師データ
  def loss(x:, t:)
    y = predict(x: x)
    @last_layer.forward(x: y, t: t)
  end

  def accuracy(x:, t:)
    y = predict(x: x)
    y = y.max_index(1) % 10
    if t.ndim != 1
      t = t.max_index(1) % 10
    end

    y.eq(t).cast_to(Numo::UInt16).sum / x.shape[0].to_f
  end

  def numerical_gradients(x:, t:)
    loss_w = lambda { loss(x: x, t: t) }

    {
      w1: numerical_gradient(loss_w, @params[:w1].first),
      b1: numerical_gradient(loss_w, @params[:b1].first),
      w2: numerical_gradient(loss_w, @params[:w2].first),
      b2: numerical_gradient(loss_w, @params[:b2].first)
    }
  end

  def gradient(x:, t:)
    # forward
    loss(x: x, t: t)

    # backward
    dout = 1
    dout = @last_layer.backward(dout: dout)

    layers = @layers.values.reverse
    layers.inject(dout) do |dout, layer|
      dout = layer.backward(dout: dout)
    end

    {
      w1: @layers[:affine1].dw,
      b1: @layers[:affine1].db,
      w2: @layers[:affine2].dw,
      b2: @layers[:affine2].db
    }
  end
end

 initializeでは重みとバイアスパラメータの初期化と、各レイヤのインスタンスの生成を行なっています。重みとバイアスのパラメータを配列として保持しているのは、学習結果をTwoLayerNetクラス内のパラメータに反映したものを各レイヤで参照できるようにするためです。Affineクラスのインスタンス生成時にパラメータを w: @params[:w1] という形で渡していますが、Rubyでは参照の値渡しになるため、TwoLayerNet側で @params[:w1] に計算結果を代入しても、 Affineインスタンス側では初期化時に渡されたパラメータを参照し続けます。そこで参照先を配列にして、計算結果はその配列の中身を更新する形にしています。書籍のPythonコードでは参照渡しになるため、配列として保持しなくてもTwoLayerNet側での変更がAffineインスタンス側で参照されています。

 それと、パラメータをランダムに生成する前に、Numo::NArray.srandメソッドでseedが変わるようにしています。これをしないとスクリプト実行時に毎回同じ内容のパラメータが作成されてしまいます。

 また、ニューラルネットワークでは各レイヤが実行される順番が重要なので、Pythonの場合は通常のDictionaryではなくOrderedDictを使っていますが、RubyのHashでは順番が保持されるため、通常のHashをそのまま使っています。

 勾配の計算処理のgradientメソッドでは、まず順伝播の処理を行うためにlossメソッドを実行します。lossメソッドではpredictメソッドを呼び出し、injectメソッドで各レイヤのforward処理を実行し、順伝播の処理を行なっています。そして最後に last_layer変数に保持しているSoftmaxWithLossインスタンスの順伝播処理で交差エントロピー誤差を計算しています。

 続いて逆伝播処理ではまずSoftmaxWithLossインスタンスの逆伝播処理を行なったあと、各レイヤを逆順に逆伝播処理を行ない、計算結果を返しています。

 精度確認用のaccuracyメソッドでは、まず各レイヤの順伝播処理を行い、その結果一番確度の高い要素のインデックスと教師データを付き合わせて正解率を計算しています。max_indexメソッドでは各データの最大値を判定したいので、引数で1軸目を指定しています(0だと全ての要素の中からの最大値を判定する)。結果は行列全ての要素の中でのインデックス値を返すので、10で割ることで各データのインデックス値に変換しています。

 eqメソッドでは一致する要素は1、一致しない要素は0のNumo::Bit配列を返します。正解数としてBitが1の要素の合計を計算するため、Numo::Bit配列をcast_toメソッドでInt配列に変換しています。この時、各ビット値は1か0なので、Int8でも正しく変換できますが、合計を計算するときにInt8の範囲を超えると正しく合計値が取得できません。今回の入力データの件数は60,000件あるので、Int8だと範囲を超えてしまうため、UInt16に変換した上で合計値を取得しています。

誤差逆伝播法の勾配確認

 誤差逆伝播法で求めた勾配が正しいかどうかを確認するため、数値微分で求めた勾配と比較して確認します。

require './mnist.rb'
require './two_layer_net.rb'

x_train, t_train, x_test, t_test = load_mnist(normalize: true, one_hot_label: true)

network = TwoLayerNet.new(input_size: 784, hidden_size: 50, output_size: 10)

x_batch = x_train[0..2, true]
t_batch = t_train[0..2, true]

grad_numerical = network.numerical_gradients(x: x_batch, t: t_batch)
grad_backprop = network.gradient(x: x_batch, t: t_batch)

grad_numerical.keys.each do |key|
  diff = (grad_backprop[key] - grad_numerical[key]).abs.mean
  puts "#{key}: #{diff}"
end

 MNISTデータの先頭3件を使い、数値微分での計算と誤差逆伝播法での計算を一度行い、それぞれの結果の差を計算しています。実行結果は下記のようになり、ほぼ差がないことが確認できます。

[vagrant@localhost vagrant]$ ruby gradient_check.rb
w1: 2.616843054226442e-13                          
b1: 8.347379796928845e-13                          
w2: 1.0232621862468414e-12                         
b2: 1.2012612987666316e-10                         

誤差逆伝播法を使った学習

 前回の記事で実装したミニバッチ学習と評価を、誤差逆伝播法を使うように変更します。前回との違いはnumerical_gradientsメソッドではなくgradientメソッドを使うようにした点です。

require 'numo/narray'
require 'numo/gnuplot'
require './mnist.rb'
require './two_layer_net.rb'

# データの読み込み
x_train, t_train, x_test, t_test = load_mnist(normalize: true, one_hot_label: true)

network = TwoLayerNet.new(input_size: 784, hidden_size: 50, output_size: 10)

iters_num = 10_000 # 繰り返し回数
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1

train_loss_list = []
train_acc_list = []
test_acc_list = []

iter_per_epoch = [train_size / batch_size, 1].max

iters_num.times do |i|
  Numo::NArray.srand
  batch_mask = Numo::Int32.new(batch_size).rand(0, train_size)
  x_batch = x_train[batch_mask, true]
  t_batch = t_train[batch_mask, true]

  # 勾配の計算
  #grad = network.numerical_gradients(x_batch, t_batch)
  grad = network.gradient(x: x_batch, t: t_batch)

  # パラメータの更新
  %i(w1 b1 w2 b2).each do |key|
    network.params[key][0] -= learning_rate * grad[key]
  end

  loss = network.loss(x: x_batch, t: t_batch)
  train_loss_list << loss

  next if i % iter_per_epoch != 0

  train_acc = network.accuracy(x: x_train, t: t_train)
  test_acc = network.accuracy(x: x_test, t: t_test)
  train_acc_list << train_acc
  test_acc_list << test_acc
  puts "train acc, test acc | #{train_acc}, #{test_acc}"
end

# グラフの描画
x = (0..(train_acc_list.size - 1)).to_a
Numo.gnuplot do
  plot x, train_acc_list, { w: :lines, t: 'train acc', lc_rgb: 'blue' },
       x, test_acc_list, { w: :lines, t: 'test acc', lc_rgb: 'green' }
  set xlabel: 'epochs'
  set ylabel: 'accuracy'
  set yrange: 0..1
end

 実行結果は下記のようになります。シェルからスクリプトファイルを実行してもグラフの描画が行われなかったので、irbからロードすることで実行し、グラフが表示されるようにしています。

irb(main):001:0> load './train_neuralnet.rb'
train acc, test acc | 0.12578333333333333, 0.1225
train acc, test acc | 0.9026833333333333, 0.9071
train acc, test acc | 0.92225, 0.923
train acc, test acc | 0.9348833333333333, 0.9331
train acc, test acc | 0.9436, 0.9403
train acc, test acc | 0.9513666666666667, 0.9503
train acc, test acc | 0.9568833333333333, 0.9565
train acc, test acc | 0.9614666666666667, 0.9594
train acc, test acc | 0.96415, 0.9611
train acc, test acc | 0.9659, 0.9616
train acc, test acc | 0.9677833333333333, 0.9622
train acc, test acc | 0.97175, 0.9655
train acc, test acc | 0.97275, 0.9666
train acc, test acc | 0.9745666666666667, 0.968
train acc, test acc | 0.9764166666666667, 0.9682
train acc, test acc | 0.9780666666666666, 0.9696
train acc, test acc | 0.9788166666666667, 0.9704
=> true

f:id:akanuma-hiroaki:20170415143057p:plain

実行速度

 手元のVagrant環境で実行した結果、Ruby版とPython版のそれぞれの実行速度は下記のようになりました。

Ruby: 4分41秒 Python: 39秒

 Python版の方がかなり早いです。今回はPython版をベースにRuby版を実装したので、パフォーマンスチューニングが可能かやってみたいところです。

 コードは下記リポジトリでも公開しています。

github.com