iPhoneの磁気センサーを用いて屋内位置情報を機械学習してみる

Qiita初投稿です。お手柔らかにお願いします。
趣味はキックボクシングとぷよぷよです。どちらも下手の横好きですが。
世界初AI格闘エンジニア目指してます。

普段のお仕事ですが、IoTデバイスを使用した位置情報（誰がどこにいるか）ソリューションの開発を行なっており、仕事の傍AIに興味がありDeepLearning,機械学習を勉強しております。
先日E資格を取得し、現在は統計検定とSQLのお勉強中です。

さて今回は、せっかくですので仕事でも活かせるか研究も兼ねて、iPhoneの磁気センサーを用いて地磁気を取得し、機械学習で屋内の位置情報が測定出来ないか実験してみました。

すでにNTTデータなどが[地磁気を用いた高精度屋内位置情報サービス](https://www.nttdata.com/jp/ja/news/services_info/2018/050101/)の提供を開始し、実用段階に入っているようです。さすがにここまでの精度は出せないにしろ、チャレンジしてみる価値はあるように思います。。。と自分に言い聞かせ進めます。

# その前に地磁気とは
>地磁気（ちじき、英: geomagnetism、Earth's magnetic field）は、地球が持つ磁性（磁気）である。 及び、地磁気は、地球により生じる磁場（磁界）である。 磁場は、空間の各点で向きと大きさを持つ物理量（ベクトル場）である。
[Wikibepiaより](https://ja.wikipedia.org/wiki/%E5%9C%B0%E7%A3%81%E6%B0%97)

という事なので、基本的には各地点で特有のベクトル量を持っており、精度はさておきiPhoneの磁気センサーを用いれば地磁気が測定可能なはずです。ただ屋内には鉄骨があり、磁場と干渉しますので、１箇所１箇所地道に測定する必要があります。もっと言うと太陽フレアや、磁場そのものも常に変化するので、一意の値にはならないのですが。。。

# 実験手順

1. 屋内を歩き回り、地磁気x,y,z値とX,Y座標を測定
2. 測定値を用いて機械学習モデルを構築
3. Macでローカルサーバを構築し、API経由でモデルにアクセス出来るよう設計
4. iOSアプリから都度リクエストし、位置情報をマッピング

##1.屋内を歩き回り、地磁気x,y,z値とX,Y座標を測定

実験はこんな感じの弊社100平方mぐらいの敷地で行いました。
（ホントはもっと広い方がデータも集めやすく、位置による磁場の差異も出て来てくれそうではありますが）
<img width="466" alt="map@2x.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35469/0f8bbd90-8b02-a5c2-9370-509ced4c2652.png">

部屋の左上を座標軸(0,0)と置き、移動しながら磁気センサーの値(x,y,z)と、座標(X,Y)を地道に計測します。
磁気センサーを使用するには[CoreMotion](https://developer.apple.com/documentation/coremotion)フレームワークを利用し、測定したデータはCSV(x,y,z,X,Y)にして保存します。

```Swift:ViewController.swift
    var x:Double = 0.0
    var y:Double = 0.0
    var z:Double = 0.0
    
    @IBOutlet weak var imageView: UIImageView!
    
    let cmManager = CMMotionManager()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        if cmManager.isMagnetometerAvailable {
            // １秒間隔で更新
            cmManager.magnetometerUpdateInterval = 1.0
            // クロージャの定義
            let handler:CMMagnetometerHandler = {(magnetoData:CMMagnetometerData?, error:Error?) -> Void in
                self.measureMagnetoData(magnetoData:magnetoData)
            }
            // キューに追加しスタート
            cmManager.startMagnetometerUpdates(to:OperationQueue.main, withHandler:handler)
        }
    }

    func measureMagnetoData(magnetoData:CMMagnetometerData?){
        
        if let data = magnetoData {
            x = data.magneticField.x
            y = data.magneticField.y
            z = data.magneticField.z
        }
    }

    // タッチした座標を取得
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        
        let touch = touches.first!
        let location = touch.location(in: self.imageView)
                   
        let locationX = round(location.x * 10000) / 10000
        let locationY = round(location.y * 10000) / 10000
    }   
```
##2. 測定値を用いて機械学習モデルを構築

今回は目的変数が複数(X,Y座標)ありますが、通常の回帰では目的変数は１つしか予測出来ません。
学習済みモデルを複数用いるような方法もありますが、Scikit-learnには便利な[MultiOutputRegressor](https://scikit-learn.org/stable/modules/generated/sklearn.multioutput.MultiOutputRegressor.html)なる、複数の予測値に対応したアルゴリズムがありましたので、こちらを用いる事にしました。
引数には好みの回帰アルゴリズムを指定出来るのですが、今回はとりあえずKaggleで流行りの勾配ブースティングを用いました。
勾配ブースティングとは、アンサンブル学習の一種で複数の決定木を用意し、正解と予測値の残差をデータに加え、次回の学習にそれらを用いて学習するアルゴリズムです。
ランダムフォレストと似ていますが、こちらはバギングの一種で、データと特徴をサンプリングし、並列に決定木を学習して、最後にその平均値を取ります。

学習済みモデルは、API経由で呼び出せるように一旦ローカルに保存する事にします。

```Python:multiRegressor.py
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.multioutput import MultiOutputRegressor
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import mean_squared_error
import pickle

df = pd.read_csv("./data.csv", header=0)
# 入力データ
X = df.iloc[:, 0:3]
# 正解データ
Y = df.iloc[:, 3:5]

# データ分割(2割をテスト用に）
X_train, X_test, Y_train, Y_test = train_test_split(X,
    Y,
    test_size=0.20,
    random_state=1)

# 回帰モデルを構築
regressor = GradientBoostingRegressor(random_state=1)

# 訓練
multioutput = MultiOutputRegressor(estimator=regressor).fit(X_train, Y_train)

# 予測
train_pred = multioutput.predict(X_train)
test_pred = multioutput.predict(X_test)

# 平均二乗誤差
print('MSE_train : ', mean_squared_error(Y_train, train_pred))
print('MSE_test : ', mean_squared_error(Y_test, test_pred))

# モデルを保存する
filename = 'model.sav'
pickle.dump(multioutput, open(filename, 'wb'))
```

##3. Macでローカルサーバを構築し、API経由でモデルにアクセス出来るよう設計

ここが一番苦労しました。
普段はフロントエンドエンジニアですので、インフラの知識などないに乏しいものです。
クラウドを一から用意するのも大変ですので、自分の手元で確認出来れば良いのでローカル環境を構築する事にしました。

Pythonには[Flask](http://flask.pocoo.org/)なる軽量フレームワークがあり、APIも簡単に作成出来そうでしたので、こちらを利用します。
MacとiPhoneを同じWi-Fi環境下に置き、以下を実行するだけで、ローカルサーバーが立ち上がります。

```
$ python coodinate.py
```


```Python:coodinate.py
import pickle
from flask import Flask, request, jsonify, abort, make_response

api = Flask(__name__)

# 位置情報の予測
@api.route('/coodinate', methods=['GET'])
def get_coodinate():

    # QueryStringからパラメータの取得
    x = request.args.get('x')
    y = request.args.get('y')
    z = request.args.get('z')

    # 2.の学習済みモデルのロード
    model = pickle.load(open('model.sav', 'rb'))
    pred = model.predict([[x, y, z]])

    result = { 'X': pred[0, 0], 'Y': pred[0, 1]}
    return make_response(jsonify(result))

# エラーハンドリング
@api.errorhandler(404)
def app_not_found(error):
    return make_response(jsonify({'error': error.description }), 404)

# ホスト0.0.0.0, ポート8080番でローカルサーバーを起動
if __name__ == '__main__':
    api.run(host='0.0.0.0', port=8080)
```

##4. iOSアプリから都度リクエストし、位置情報をマッピング

最後に、iOSアプリからAPI経由で3.の機械学習モデルにアクセスし、位置情報を予測します。
通信にはライブラリを使うまでもなかったので、URLSessionを用いました。
JSONのデコードにはSwift4から使用出来るようになった[JSONDecoder](https://developer.apple.com/documentation/foundation/jsondecoder)を利用する事にします。

```Swift:ViewController.swift
    // API呼び出し用タイマー
    var timer: Timer?
    
    // JSONから変換する構造体
    struct Coodinate: Codable {
        let X: CGFloat
        let Y: CGFloat
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib. 
        // 2秒ごとにAPI経由で位置情報を取得
        self.timer = Timer.scheduledTimer(timeInterval:2,
                                          target:self,
                                          selector:#selector(ViewController.timerUpdate),
                                          userInfo:nil, repeats: true)
    }

    @objc func timerUpdate() {

        // プライベートアドレスを指定
        self.getCoodinate(url: "http://192.168.XXX.XXX/coodinate")
    }

    func getCoodinate(url urlString: String, queryItems: [URLQueryItem]? = nil) {
        
        // QueryStringにセンサーデータを追加
        let queryItems = [URLQueryItem(name: "x", value: String(self.x)),
                          URLQueryItem(name: "y", value: String(self.y)),
                          URLQueryItem(name: "z", value: String(self.z))]
        
        var compnents = URLComponents(string: urlString)
        compnents?.queryItems = queryItems
        let url = compnents?.url
        
        let task = URLSession.shared.dataTask(with: url!) { data, response, error in
            
            // クライアントエラー
            if let error = error {
                print("Client error:\(error.localizedDescription)\n")
                return
            }
            
            guard let data = data, let response = response as? HTTPURLResponse else {
                print("No responce")
                return
            }
            
            if response.statusCode == 200 {
                
                do {
                    // JSONをデコード
                    let coodinate = try! JSONDecoder().decode(Coodinate.self, from: data)

                    // マップにピンを表示
                    DispatchQueue.main.async {
                        
                        // 古いピンがあれば削除
                        for aView in self.imageView.subviews {
                            let pinView = aView as? UIImageView
                            pinView?.removeFromSuperview()
                        }
                        
                        // 新たなピンを追加
                        let image: UIImage! = UIImage(named: "pin2")
                        let pinView = UIImageView(image: image)
                        pinView.frame = CGRect(x: coodinate.X - image.size.width/2,
                                               y: coodinate.Y - image.size.height/2,
                                               width: image.size.width,
                                               height: image.size.height)
                        self.imageView.addSubview(pinView)
                    }
                } catch {
                    print("Serialize Error")
                }
            } else {
                print("Server srror")
            }
        }
        // 通信開始
        task.resume()
    }
```
こんな感じで、自身に移動に合わせてマップにピンが刺さります。
<img width="375" alt="IMG_2846.JPG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35469/d9e16f11-6955-192d-4255-7d92c530ca64.jpeg">

#結果
近しい所もあるにはあるし、かなりはずれる所もあり、実用には程遠い残念な結果に。。。

原因はいろいろ考察出来そうですが、

+ そもそも考え方に無理がある
+ 単純に学習データ量の少なさ
+ iPhoneの向きや傾きによって磁気データが変化し測定値が安定しない！（何か台に乗せて固定させる？）
+ ハイパーパラメータ、アルゴリズムの変更

など改善の余地はたくさん残されていそうです。
まずは、機械学習勉強中の身ですのでやってみる事に意味があるのではないでしょうか！？
まだまだ初学者ですので、ご指摘事項がありましたらバンバンお寄せ下さい！