Apple Watch で計測したランニングの記録から機械学習で目標タイムを設定してみた

# はじめに

はじめまして、NTTドコモサービスイノベーション部1年目の内村です。
普段の業務ではドコモのネットワーク品質向上のため、ネットワーク分析業務に取組んでいます。

突然ですが、皆さんの中にもリモートワーク、オンライン授業を受けることが多く、運動不足を感じておられる方も多いのではないでしょうか。私も在宅ワークをする機会が多く部屋から出ない日もあり、運動不足を感じていました。このままではまずいと、6月からランニングを始めたのですが、最近寒くなってきたこともありただ走るだけではモチベーションが上がらない…

そこで今回はランニングをする目的作りとモチベーションアップのため、Apple Watchで記録したこれまでのランニングデータを元に自分のランニングの目標タイム設定を行ってみたいと思います。

![image-20211204155040968.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294942/77da8da6-91a7-da2e-0087-842f7280c2d4.png)


それでは、NTTドコモ R&D AdventCalendar2021(カレンダー2) 18日目楽しんで読んでいただければ幸いです。

# 本記事で扱う内容

- IOS端末からヘルスケアデータの抽出と学習データの準備
- タイムを推定するモデルの作成
- 400mごとのタイム推定

# 本記事の環境

- Apple Watch series 3 (ver 7.3)
- iPhone 8 (ios 14.8.1)
- Google colab

# ヘルスケアデータの準備

## ヘルスケアデータの取得

最初にヘルスケアデータをiPhoneから取り出します。
エクスポート自体はとても簡単でiPhoneのヘルスケアアプリから「すべてのヘルスケアデータを書き出す」をタップすることで、任意の方法で取り出すことができます。

ヘルスケアデータの構成は、下記のようになっています。「ecport.xml」には、心拍数や歩数、移動距離などあらゆるログが保存されています。「route_yyyy-MM-dd_h.mmtt.gpx」には、ランニングやハイキングなど運動中に移動した距離が、ワークアウトごとに保存されています。

> apple_health_export
> 　├ export.xml
> 　├ export_cda.xml
> 　├ /workout-routes
>  　├ route_2021-06-01_7.05pm.gpx
>  　├ route_2021-06-05_11.02am.gpx
>  　├ ・・・・・ 

## ヘルスケアデータの確認

export.xmlのデータは以下の形で読み込みを行います。

``` python
import pandas as pd
import xmltodict

input_path = './apple_health_export/export.xml'
with open(input_path, 'r') as xml_file:
    input_data = xmltodict.parse(xml_file.read())

records_list = input_data['HealthData']['Record']
df = pd.DataFrame(records_list)
df['@type'].unique()
```

実際に確認してみると、心拍数、歩数など多種多様なログが残されているのが分かります。
[公式document](https://developer.apple.com/documentation/healthkit/data_types)を調べると各@typeの説明がすぐ見つかります。試しに'HKQuantityTypeIdentifierHeartRate'を調べたところ計測値は心拍数でした。また、運動中かどうかでも心拍数を記録する@typeを分けているようです。参考にヘルスケアデータから確認できるデータの例を載せておきます。

| @type                                  | 計測値   |
| -------------------------------------- | -------- |
| 'HKQuantityTypeIdentifierHeartRate'    | 心拍数   |
| 'HKQuantityTypeIdentifierStepCount'    | 歩数     |
| 'HKQuantityTypeIdentifierWalkingSpeed' | 歩行速度 |

ランニング中の心拍数データが見つからなかったため、今回はワークアウトデータのルートのみを利用することにします。

## ワークアウトデータの確認

route_yyyy-MM-dd_h.mmtt.gpxのデータは緯度、経度、標高、時間をレコードの要素としてランニングなどのワークアウト中、ログを取り続けていようです。[記事](https://qiita.com/toran/items/6b4bcd103292164e8e28)を参考に下記のコードで.gpxファイルのログを確認してみると、ほとんど毎秒ログを取っているようです。また、高さも数センチ精度で取れているようで驚きです。

```  python
import gpxpy
import gpxpy.gpx
from pytz import timezone

# タイムゾーン
dt_tz = timezone('Asia/Tokyo')
# 日時文字列形式
dt_fmt = '%Y-%m-%d %H:%M:%S'
def gpx2df(path_gpx):
  # 配列の初期化
  DateTime = []
  Lat      = []
  Lng      = []
  Alt      = []
  # GPXファイルの読み込み
  gpx_file_r = open(path_gpx, 'r')
  # GPXファイルのパース
  gpx = gpxpy.parse(gpx_file_r)
  # GPXデータの読み込み
  for track in gpx.tracks:
      for segment in track.segments:
          # ポイントデータリストの読み込み
          points = segment.points
          # ポイントデータの長さ
          N = len(points)
          # ポイントデータの読み込み
          for i in range(N):
              # ポイントデータ
              point = points[i]
              # データ抽出
              datetime = point.time.astimezone(dt_tz).strftime(dt_fmt)
              lat      = point.latitude
              lng      = point.longitude
              alt      = point.elevation
              # データ代入
              DateTime.append(datetime)
              Lat.append(lat)
              Lng.append(lng)
              Alt.append(alt)
  df = pd.DataFrame({ "datetime": DateTime, "lat":Lat, "lng":Lng, "alt":Alt})
  return df
```

ワークアウトデータの一部抜粋

|   id |                時間 |  緯度[度] |   経度[度] | 標高[m] |
| ---: | ------------------: | --------: | ---------: | ------: |
| 2800 | 2021-09-26 10:28:26 | 35.335565 | 139.632768 |    3.72 |
| 2801 | 2021-09-26 10:28:27 | 35.335578 | 139.632793 |    3.83 |
| 2802 | 2021-09-26 10:28:28 | 35.335592 | 139.632817 |    3.94 |



実際にログを[Folium](http://python-visualization.github.io/folium/)(pythonの地図表示ライブラリ)で確認すると、以下のようにどの地点を通過したか簡単に分かります。こうして見るとGPSの精度は素晴らしいですね、通ったルートがほぼ正確に記録されています。

```python
import folium
path = './apple_health_export/workout-routes/route_2021-09-26_10.54am.gpx'.format(path_dir)
df = gpx2df_2(path)
m = folium.Map()
locs = df.loc[:,["lat", "lng"]].values
folium.PolyLine(locs, color="red", weight=7).add_to(m)
m.fit_bounds([[df["lat"].min(),df["lng"].min()], [df["lat"].max(), df["lng"].min()]])
m
```


![image-20211206081306469.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294942/8a0d57f8-e87d-fd6f-59f5-e36a0e1229b6.png)

## ワークアウトデータの整形

ここまでで、ランニング中の各地点での時刻と緯度・経度の抽出までは行えましたが、学習データとしては緯度・経度から移動距離に変換する必要があります。移動距離は１つ前のレコードの緯度・経度との差分を取ることで得ることができます。また、高低差に関しても同様に差分を計算することで求めることができます。

```python
from geopy.distance import geodesic

def latlon2m(row):
  # (緯度, 経度)
  if row.isnull().sum() < 1:
    pos_1 = (row["lat"], row["lng"])
    pos_2 = (row["lat_old"], row["lng_old"])
    dis = geodesic(pos_1, pos_2).m
  else:
    dis = 0
  return dis

def make_feature(df):
  #lat lng alt datetimeから学習用のデータセットを作成する
  df['datetime'] = pd.to_datetime(df['datetime'], format='%Y-%m-%d %H:%M:%S')
  #1.1レコード前のデータを結合
  df_ = df.shift().add_suffix("_old")
  df = pd.concat([df, df_], axis=1)
  #2.距離・高低を計算
  df["distance"] = df.apply(latlon2m, axis=1)
  df["alt_diff"] = df["alt"] - df["alt_old"]
  df["dist_total"] = df["distance"].cumsum()
  return df
```



ワークアウトデータから作成した特徴量の一部抜粋

|   id |                時間 | 進んだ距離[m] | 高低差[m] | 総距離[m] |
| ---: | ------------------: | ------------: | --------: | --------: |
| 2800 | 2021-09-26 10:28:26 |          2.76 |      0.11 |    7668.3 |
| 2801 | 2021-09-26 10:28:27 |          2.69 |      0.11 |    7671.0 |
| 2802 | 2021-09-26 10:28:28 |          2.67 |      0.10 |    7673.7 |



# 学習データの準備

私の約半年間ランニングをしたログの数を確認すると、全部で58となります。
運動が足りてないせいで、データも不足してしまっています…。

無いものは仕方ないので、図のように各ワークアウトのログを400mごとに分割して集計を行い特徴量とし、400m進むのにどれだけの時間がかかるのかを推定します。

![image-20211204160951548.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294942/b660505e-168b-bf9c-4f63-f1cf93b3f4fb.png)


```python
def make_dataset(df):
  df = make_feature(df)
  #3.レコードをまとめて学習用データセットにする
  i=0
  df_dataset = pd.DataFrame()
  Dist = []
  Time = []
  Alt_up = []
  Alt_dwn = []
  DateTime = []
  Dist_total = []
  while True:
    if (df.iloc[-1]["dist_total"])>=(i+1)*400:
      df_ = df[(df["dist_total"]>=i*400) & (df["dist_total"]<(i+1)*400)]
      if len(df_) > 1:
        Time.append((df_["datetime"].iloc[-1] - df_["datetime"].iloc[0]).total_seconds())
        Alt_up.append(df_[df_["alt_diff"]>0]["alt_diff"].sum())
        Alt_dwn.append(df_[df_["alt_diff"]<0]["alt_diff"].sum())
        DateTime.append(pd.to_datetime(df_['datetime'], format='%Y-%m-%d %H:%M:%S').iloc[0])
        Dist_total.append(df_.iloc[0]["dist_total"])
        Dist.append(df_["distance"].sum())
      i += 1
    else:
      break
  df_dataset = pd.DataFrame({ "time": Time, "dist":Dist, "dist_total":Dist_total,"alt_up":Alt_up, "alt_dwn":Alt_dwn, "datetime":DateTime})
  return df_dataset

```



作成した特徴量と400m走るのにかかった時間の抜粋

|   id | 時間[s] | 進んだ距離[m] | 総距離[m] | 登り累計[m] | 下り累計[m] |
| ---: | ------: | ------------: | --------: | ----------: | ----------: |
|    5 |   128.0 |         308.8 |    2001.1 |        0.77 |      -10.81 |
|    6 |   136.0 |         399.4 |    2402.9 |        0.18 |       -5.17 |
|    7 |   152.0 |         399.2 |    2802.6 |        7.20 |      -11.54 |



ここでは割愛しますが、GPSの誤差が大きいデータや時間、速度が明らかにおかしいデータの削除と学習に使えそうな特徴量の追加を行いました。以下が今回準備した特徴量を一部抜粋した散布図です。


![ダウンロード (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294942/28e0442b-8377-d962-3af4-1d3cc48d3eed.png)

time:400m走るのにかかった時間[s]、dist_total:その日走った総距離[m]、alt_up:400m中の上り累計[m]、alt_dwn:400m中の下り累計[m]、month:月

400m走るのに必要な時間の中央値は131秒となりました。1km走るのに5分半程度かかることになるので、感覚的にも妥当な結果になっています。高低差を示す"alt_up"、"alt_dwn"を見ると、値の大きいところでは40m以上の上り下りの区間があることが分かります。（この登りがいつも辛い）。そして、半年間走り続けてもペースに大きな変化が無く、6月の平均が131秒、11月の平均が130秒だったのは残念なポイントでした。

まだまだ、分析を続けたいところですが、時間も無いので最後のモデル作成に進みます。

# タイムの推定

これまでのところで予測に使えるデータの準備はできたので、推定モデルをサクッと準備して精度を確認したいと思います。本来なら時系列モデルを扱いランニングの途中経過を踏まえた予測をしたいのですが、諸事情（アプリを実装する時間・技術が無かった＆予測した後に実際に走って精度を確かめたい）のため、今回は走る直前までに利用可能な特徴量を元に[LightGBM](https://lightgbm.readthedocs.io/en/latest/)で予測することを前提とします。

## モデル作成

```python
from sklearn.model_selection import train_test_split

#データの準備
l_test = (df_dataset["month"]==11) & (df_dataset["day"] == 17)
X_test = df_dataset[l_test].loc[:,l_feature].values
X_train = df_dataset[~l_test].loc[:,l_feature].values
y_test = df_dataset[l_test]["time"].values
y_train = df_dataset[~l_test]["time"].values
X_train, X_eval, y_train, y_eval = train_test_split(X_train, y_train, test_size=0.2, shuffle=True)

#モデルの学習
import lightgbm

params = {"metric": "rmse", "learning_rate": 0.1, "num_boost_round":10000,
          "early_stopping_rounds":100, "verbose_eval":50}
lgb_train = lgb.Dataset(X_train, y_train)
lgb_eval = lgb.Dataset(X_eval, y_eval)
gbm = lgb.train(params, lgb_train, valid_sets=lgb_eval)
y_predict = gbm.predict(X_test)
```



## 推定結果

モデルを作成・学習した後、実際に走ってみたところ、誤差は8.90秒（誤差率6.8%）でした。1kmあたりに直すと22秒の誤差となるため、あまり良い精度とはいえない結果となってしまいました。

最後にlightgbmのモデルがタイムを予測する際にどの特徴量を重視したのか確認したいと思います。やはりどれだけ上り下りががあるか、次いで距離[m]、それまでの総距離[m]が重要指標となりました。分析からも分かっていたことですが日付[月]の重要度はやはり低く、推定に大きく影響しないようです…

```
fti = gbm.feature_importances_
df = pd.DataFrame(fti, index = l_feature)
df
```



| 特徴量        | 重要度 |
| ------------- | ------ |
| 上り累計[m]   | 216    |
| 下り累計[m]   | 216    |
| 進んだ距離[m] | 192    |
| 総距離[m]     | 184    |
| 日付[月]      | 32     |



# まとめ

今回は、Apple Watchで計測したヘルスケアデータを使いランニングの目標タイム設定にチャレンジしてみました。

程々の精度は出るだろうと思っていたのですが、その日ごとのコンディションによってタイムにばらつきがあるようで精度としてはいまいちな結果となりました。今回はワークアウトのルートデータしか活用しませんでしたが、睡眠時間や心拍数などを活用することで良い精度やコンディションの分析まで出来るのかなと思います。

個人的には初めてヘルスケアデータに触り測定項目や精度を知ることができ、楽しく分析できました。本当はApple Watchのアプリまで実装して表示しつつ走るところまでしたかったのですが、時間と技術力が足らず断念してしまいした。来年機会があれば、再チャレンジしたいと思います！！

ここまで読んでいただきありがとうございました。25日までアドベントカレンダーは続くので、引き続き楽しんでください。

では、みなさま良いクリスマスを🎄🎄🎄

# 参考

[Apple Developer Documentation](https://developer.apple.com/documentation/healthkit/data_types)

[Python3.7によるGPXファイルの読み込みと移動距離の算出 - Qiita](https://qiita.com/toran/items/6b4bcd103292164e8e28)

[Folium — Folium 0.12.1 documentation](http://python-visualization.github.io/folium/)

[Welcome to LightGBM’s documentation! — LightGBM 3.3.1.99 documentation](https://lightgbm.readthedocs.io/en/latest/)