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

ニューラルネットワークの数値微分による学習アルゴリズムの実装

 今回も引き続き「ゼロから作るDeepLearning」をベースに、数値微分による学習アルゴリズムをRubyで実装してみました。

www.oreilly.co.jp

 最初に書いておくと、今回の数値微分での実装は、実装はシンプルなもののその分処理に時間がかかり、手元の環境では繰り返し処理を終わらせることができませんでした。次回以降で書籍の次の章で解説されている、誤差逆伝播法での実装に変更してみたいと思います。

2層ニューラルネットワークのクラス

 書籍のPython実装に基づき、2層のニューラルネットワークをTwoLayerNetという一つのクラスとして実装します。まずはコード全体です。

require 'numo/narray'
require './softmax.rb'
require './sigmoid.rb'
require './cross_entropy_error.rb'
require './numerical_gradient.rb'

class TwoLayerNet
  def initialize(input_size, hidden_size, output_size, weight_init_std = 0.01)
    @params = {}
    @params[:w1] = weight_init_std * Numo::DFloat.new(input_size, hidden_size).rand_norm
    @params[:b1] = Numo::DFloat.zeros(hidden_size)
    @params[:w2] = weight_init_std * Numo::DFloat.new(hidden_size, output_size).rand_norm
    @params[:b2] = Numo::DFloat.zeros(output_size)
  end

  def params
    @params
  end

  def predict(x)
    w1 = @params[:w1]
    w2 = @params[:w2]
    b1 = @params[:b1]
    b2 = @params[:b2]

    a1 = x.dot(w1) + b1
    z1 = sigmoid(a1)
    a2 = z1.dot(w2) + b2
    softmax(a2)
  end

  def loss(x, t)
    y = predict(x)
    cross_entropy_error(y, t)
  end

  def accuracy(x, t)
    y = predict(x)
    y = y.max_index(1) % 10
    t = t.max_index(1) % 10

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

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

    grads = {}
    grads[:w1] = numerical_gradient(loss_w, @params[:w1])
    grads[:b1] = numerical_gradient(loss_w, @params[:b1])
    grads[:w2] = numerical_gradient(loss_w, @params[:w2])
    grads[:b2] = numerical_gradient(loss_w, @params[:b2])

    grads
  end
end

初期化

 まずはinitializeメソッドで重みとバイアスのパラメータを初期化し、インスタンス変数にHashとして保持します。合わせて外部からパラメータを参照するためのメソッドも用意しておきます。

def initialize(input_size, hidden_size, output_size, weight_init_std = 0.01)
  @params = {}
  @params[:w1] = weight_init_std * Numo::DFloat.new(input_size, hidden_size).rand_norm
  @params[:b1] = Numo::DFloat.zeros(hidden_size)
  @params[:w2] = weight_init_std * Numo::DFloat.new(hidden_size, output_size).rand_norm
  @params[:b2] = Numo::DFloat.zeros(output_size)
end

def params
  @params
end

 initializeメソッドの引数には、入力層のニューロン数、隠れ層のニューロン数、出力層のニューロン数を渡します。

 重みの初期値の生成は、まず Numo::DFloat.new で “入力層ニューロン数 x 隠れ層ニューロン数” もしくは “隠れ層ニューロン数 x 出力層ニューロン数” の行列を用意し、rand_normメソッドで標準正規分布に従う乱数を設定します。

Class: Numo::DFloat — Documentation by YARD 0.9.8

 バイアスの初期値は Numo::DFloat.zeros メソッドで全てゼロの配列を用意します。

Class: Numo::NArray — Documentation by YARD 0.9.8

推論処理

 画像データを引数にとり、推論処理を行います。

def predict(x)
  w1 = @params[:w1]
  w2 = @params[:w2]
  b1 = @params[:b1]
  b2 = @params[:b2]

  a1 = x.dot(w1) + b1
  z1 = sigmoid(a1)
  a2 = z1.dot(w2) + b2
  softmax(a2)
end

 処理内容としては前回の記事で実装したものと同じで、隠れ層の活性化関数にシグモイド関数、出力層の活性化関数にソフトマックス関数を使用しています。

def sigmoid(x)
  1 / (1 + Numo::DFloat::Math.exp(-x))
end
def softmax(a)
  if a.ndim == 2
    a = a.transpose
    a = a - a.max(0)
    y = Numo::DFloat::Math.exp(a) / Numo::DFloat::Math.exp(a).sum(0)
    return y.transpose
  end

  c = a.max
  exp_a = Numo::DFloat::Math.exp(a - c)
  sum_exp_a = exp_a.sum
  exp_a / sum_exp_a
end

損失関数

 入力データ(画像データ)と教師データ(正解ラベル)を引数にとり、損失関数の値を求めます。

def loss(x, t)
  y = predict(x)
  cross_entropy_error(y, t)
end

 画像データからpredictメソッドで推論を行なった結果と正解ラベルから交差エントロピー誤差を求めています。

def cross_entropy_error(y, t)
  if y.ndim == 1
    t = t.reshape(1, t.size)
    y = y.reshape(1, y.size)
  end

  batch_size = y.shape[0]
  -(t * (Numo::DFloat::Math.log(y))).sum / batch_size # one-hot表現用
end

認識精度の計算

 入力データ(画像データ)と教師データ(正解ラベル)を引数にとり、認識精度を計算します。

def accuracy(x, t)
  y = predict(x)
  y = y.max_index(1) % 10
  t = t.max_index(1) % 10

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

 画像データからpredictメソッドで推論を行なった結果は0〜9の数字である確度(確率)の配列として返されるため、どの数字である確率が最も高いかを max_index メソッドを用いて取得します。推論はバッチ処理で行うため、複数の画像データに対する結果として二次元配列として返ってくるので、二次元目を基準に最大値を求めるため、max_indexの引数には1を渡しています(0次元目、1次元目と考えるため)。ただし、max_index では多次元配列の場合、全ての要素数に対してのインデックス値(10 x 10の配列だったら0〜99の値)として戻ってくるため、10で割ったあまりを求めることで、0〜9のラベルを表すようにしています。

y = y.max_index(1) % 10

 これは正解ラベルについても同様で、 one-hot表現で渡されている複数データについての正解ラベルは二次元配列なので、10で割ることで0〜9のラベルを表すようにしています。

t = t.max_index(1) % 10

 そして最後に推論結果のラベル配列と正解ラベル配列をeqメソッドで比較し、正解数(配列の値が1になっている数)を入力データの数で割ることで、正解率を求めています。この辺りのeqメソッドの使い方等は前回記事と同様です。

重みパラメータに対する勾配の計算

 入力データ(画像データ)と教師データ(正解ラベル)を引数にとり、各パラメータに対する勾配を計算します。

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

  grads = {}
  grads[:w1] = numerical_gradient(loss_w, @params[:w1])
  grads[:b1] = numerical_gradient(loss_w, @params[:b1])
  grads[:w2] = numerical_gradient(loss_w, @params[:w2])
  grads[:b2] = numerical_gradient(loss_w, @params[:b2])

  grads
end

 損失関数を計算結果を取得するlambdaを用意し、勾配計算用のnumerical_gradientメソッドに各パラメータ共に渡し、結果をHashに格納して返します。

def numerical_gradient(f, x)
  h = 1e-4
  grad = Numo::DFloat.zeros(x.shape)

  x.size.times do |i|
    tmp_val = x[i]

    x[i] = tmp_val + h
    fxh1 = f.call(x)

    x[i] = tmp_val - h
    fxh2 = f.call(x)

    grad[i] = (fxh1 - fxh2) / (2 * h)
    x[i] = tmp_val
  end

  grad
end

 numerical_gradientメソッドでは中心差分での数値微分によって各パラメータの勾配を計算します。まず結果の格納用に入力パラメータと同じ形のゼロ配列を用意します。そして入力パラメータの各要素について (f(x + h) - f(x - h)) / 2h を計算して結果格納用の配列に格納し、最後にその配列を返しています。Numo::NArrayの多次元配列では単一のインデックスで配列の内容を参照する場合、全要素をフラットに並べた時のインデックス値を意味するので、x.size で全要素数を取得して、timesでその要素数分ループを回して、順次配列の内容を参照するようにしています。

ミニバッチ学習と評価の実装

 TwoLayerNetクラスを使ってMNISTデータセットを用いた学習と評価を行います。学習の実装は、訓練データから無作為に一部のデータを取り出して入力データとする、ミニバッチ学習で行います。まずはコード全体です。

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

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

network = TwoLayerNet.new(784, 50, 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|
  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)

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

  loss = network.loss(x_batch, t_batch)
  train_loss_list << loss

  next if i % iter_per_epoch != 0

  train_acc = network.accuracy(x_train, t_train)
  test_acc = network.accuracy(x_test, 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

MNISTデータの読み込みとパラメータ初期化

 まずは以前実装したMNISTデータのロード処理を使ってMNISTデータを読み込んだ後、TwoLayerNetの初期化と、繰り返し数等の設定を行います。

x_train, t_train, x_test, t_test = load_mnist(true, true, true)

network = TwoLayerNet.new(784, 50, 10)

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

 ニューロン数の構成は、入力層は28 x 28の画像データなので 784、出力層は0-9の数字を表すので 10、隠れ層は50としています。また、確率勾配降下法によるパラメータ更新の繰り返し数は10,000回で、ミニバッチのサイズは100としています。

ミニバッチデータの取得

 繰り返し処理の中では、まず60,000件の画像データの中からミニバッチデータを取得しています。

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]

 Numo::Int32.new でバッチサイズ分の要素数の配列を用意し、randメソッドで0以上60,000未満のランダム値を生成しています。これによって、60,000件の画像データのどのインデックス値のデータを取得するかを決めています。そしてそのインデックス値に該当する画像データと正解ラベルを取得していますが、それぞれ 60,000 x 784、60,000 x 10 の二次元配列になっているため、ランダムに生成したインデックス値の配列が一次元目を表すことを意味するように、二次元目はtrueを指定し、該当する二次元目のデータは全て取得するようにしています。

勾配の計算とパラメータの更新

 ミニバッチデータをTwoLayerNetの勾配計算メソッドに渡して勾配データを取得し、学習率をかけたものを各パラメータから引くことで、各パラメータを更新します。

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

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

損失関数の計算と記録

 ミニバッチデータから損失関数を計算し、繰り返しごとの結果を格納し、経過を記録します。

loss = network.loss(x_batch, t_batch)
train_loss_list << loss

認識精度の計算

 トレーニングデータとテストデータに対する認識精度を計算します。

next if i % iter_per_epoch != 0

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

 繰り返しごとに毎回認識精度を計算すると時間がかかってしまうため、1エポック(学習において全ての訓練データを使い切った時の回数。今回は60,000件の訓練データに対してミニバッチサイズが100なので、 60,000 / 100 = 600回)ごとに認識精度を計算してその値を結果配列に格納し、コンソールにも表示します。

認識精度推移のグラフ描画

 最後に認識精度の推移をグラフに描画します。

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

 グラフの描画にはgnuplotを使い、Rubyでgnuplotを扱うためのgemとして、Numo::Gnuplot を使わせていただきました。

github.com

 こちらも参考にさせていただきました。

MF / 【Ruby】numo-gnuplotで遊ぶ

 以前に Jupyter Notebook 上で Nyaplot を使わせてもらっていたことはあるのですが、ターミナル上で動かして手軽にグラフを表示したいというときは Numo::Gnuplot が手軽に使えて良さそうです。

次は誤差逆伝播法

 冒頭でも書きましたが、今回実装はしたものの、繰り返しの一回の処理でもかなり時間がかかり、10,000回の繰り返しは現実的な時間では終わりそうもありませんでした。書籍でも言われていますが、数値微分による実装は実装はシンプルなものの処理にはかなり時間がかかるので、次は誤差逆伝播法での実装で試してみたいと思います。

 今回のコードは下記リポジトリで公開してあります。

github.com

ニューラルネットワークの推論処理

 前回に引き続き書籍「ゼロから作るDeepLearning」をベースに、前回NArray配列として扱うようにしたMNISTデータに対して推論処理を行うニューラルネットワークを実装してみます。

www.oreilly.co.jp

 ニューロンの構成は下記の通りです。

  • 入力層: 784 # 28 x 28 の画像データのピクセル数
  • 隠れ層1: 50
  • 隠れ層2: 100
  • 出力層: 10 # 結果として10クラス(0から9の数字)に分類する

サンプルコード全体

 まずはサンプルコードの全体を掲載しておきます。

require 'numo/narray'
require 'json'
require './sigmoid.rb'
require './softmax.rb'
require './mnist.rb'

def get_data
  x_train, t_train, x_test, t_test = load_mnist(true, true, false)
  [x_test, t_test]
end

def init_network
  nw = JSON.load(File.read('sample_weight.json'))
  network = {}
  nw.each do |k, v|
    network[k.to_sym] = Numo::DFloat[*v]
  end
  network
end

def predict(network, x)
  w1 = network[:w1]
  w2 = network[:w2]
  w3 = network[:w3]
  b1 = network[:b1]
  b2 = network[:b2]
  b3 = network[:b3]

  a1 = x.dot(w1) + b1
  z1 = sigmoid(a1)
  a2 = z1.dot(w2) + b2
  z2 = sigmoid(a2)
  a3 = z2.dot(w3) + b3
  softmax(a3)
end

x, t = get_data
network = init_network

batch_size = 100
accuracy_cnt = 0
x.to_a.each_slice(batch_size).with_index do |x_batch, idx|
  y_batch = predict(network, Numo::DFloat[*x_batch])
  p = y_batch.max_index(1) % 10
  accuracy_cnt += p.eq(t[(idx * batch_size)..(idx * batch_size + (batch_size - 1))]).cast_to(Numo::UInt8).sum
end

puts "Accuracy: #{accuracy_cnt.to_f / x.shape[0]}"

MNISTデータの取得

 前回実装したMNISTデータのロード処理(mnist.rb)を使ってMNISTデータのテストデータを取得します。

def get_data
  x_train, t_train, x_test, t_test = load_mnist(true, true, false)
  [x_test, t_test]
end

重みパラメータのロード

 学習済みの重みとバイアスのパラメータは、書籍のサンプルコードで pickle ファイルとして提供されているものをあらかじめJSONに変換してファイルに保存しておき(sample_weight.json)、それを読み込んでいます。

def init_network
  nw = JSON.load(File.read('sample_weight.json'))
  network = {}
  nw.each do |k, v|
    network[k.to_sym] = Numo::DFloat[*v]
  end
  network
end

推論処理

 上記でロードしたテストデータと重みパラメータに対して、隠れ層での活性化関数にはシグモイド関数、出力層での活性化関数にはソフトマックス関数を使って推論処理を行います。シグモイド関数は sigmoid.rb、ソフトマックス関数は softmax.rb として保存して読み込んでおきます。

def sigmoid(x)
  1 / (1 + Numo::DFloat::Math.exp(-x))
end
def softmax(a)
  c = a.max
  exp_a = Numo::DFloat::Math.exp(a - c)
  sum_exp_a = exp_a.sum
  exp_a / sum_exp_a
end
def predict(network, x)
  w1 = network[:w1]
  w2 = network[:w2]
  w3 = network[:w3]
  b1 = network[:b1]
  b2 = network[:b2]
  b3 = network[:b3]

  a1 = x.dot(w1) + b1
  z1 = sigmoid(a1)
  a2 = z1.dot(w2) + b2
  z2 = sigmoid(a2)
  a3 = z2.dot(w3) + b3
  softmax(a3)
end

処理の実行

 上記メソッドを使って処理を実行します。

x, t = get_data
network = init_network

batch_size = 100
accuracy_cnt = 0
x.to_a.each_slice(batch_size).with_index do |x_batch, idx|
  y_batch = predict(network, Numo::DFloat[*x_batch])
  p = y_batch.max_index(1) % 10
  accuracy_cnt += p.eq(t[(idx * batch_size)..(idx * batch_size + (batch_size - 1))]).cast_to(Numo::UInt8).sum
end

puts "Accuracy: #{accuracy_cnt.to_f / x.shape[0]}"

 get_data で取得した画像データを100件ずつバッチ処理します。NArrayの二次元配列データはそのままループすると一次元配列として並べて各要素が参照されてしまうので、to_a で通常の配列データに変換した上で、 each_slice で100件ずつのまとまりにし、with_index でインデックスを取得します。

x.to_a.each_slice(batch_size).with_index do |x_batch, idx|
  ...
end

 ループの中では100件分の画像データを再度NArray配列に変換してpredictメソッドに渡し、推論処理を実行した結果を y_batch として受け取っています。predict では100件分の各画像データについて、0-9の各数字に対しての確度が確率として返されるので、100 x 10 の二次元配列になります。

y_batch = predict(network, Numo::DFloat[*x_batch])

 その y_batch に対して max_index メソッドで二次元目の配列データについて最も数値が大きい要素のインデックスを取得しています。ただし取得されるインデックスは 100 x 10 を一次元配列として並べた場合のインデックスになるので、10で割った余りを取得することで、0-9の分類に変換しています。

Class: Numo::Int32 — Documentation by YARD 0.9.8

p = y_batch.max_index(1) % 10

 処理している画像データに対応するラベルデータを取得します。each_sliceとwith_indexを使った場合、インデックスとしてはeach_sliceのまとまりごとに0から順番に振られるので、インデックスにバッチサイズ(100)をかけることで配列データの始点を特定し、それにさらにバッチサイズを加算したものから1を引くことで、終点を特定して、ラベルデータ配列 t から該当するラベルデータを取得します。

t[(idx * batch_size)..(idx * batch_size + (batch_size - 1))]

 それを分類結果データ p と eq メソッドで比較します。

Class: Numo::Int32 — Documentation by YARD 0.9.8

 eqメソッドでは配列の各要素を比較し、一致する場合は1を、一致しない場合は0を配列として返します。

irb(main):043:0* a
=> Numo::Int32#shape=[5]
[1, 3, 5, 7, 9]
irb(main):044:0> b
=> Numo::Int32#shape=[5]
[1, 2, 5, 7, 8]
irb(main):045:0> a.eq(b)
=> Numo::Bit#shape=[5]
[1, 0, 1, 1, 0]

 返される配列のデータ型はNumo::BitなのでNumo::UInt8に変換し、合計値を取得することで推論が正しかった要素の数が取得できます。

accuracy_cnt += p.eq(t[(idx * batch_size)..(idx * batch_size + (batch_size - 1))]).cast_to(Numo::UInt8).sum

 そして最後に正解数を要素数で割ることで、正解率を計算しています。

puts "Accuracy: #{accuracy_cnt.to_f / x.shape[0]}"

 今回実装したコードはこちらにも公開しました。

github.com