iPhoneのHealthデータをエクスポートしてゴニョゴニョしてみる その5

この記事の続きです。

deutschina.hatenablog.com

iPhoneのHealthデータの加工の流れがあらかた出来たので、Nokia Healthから持ってきた歩数(activity)データとぶつけてみるところから。

いざ突合へ

イメージとしては、Nokia Health側のデータにiPhoneのデータをぶっ込む感じにしたいので、Nokia Health側のファイル(activity.csv)にレシピを追加していきます。最終的に追加したのはこんな感じ。

f:id:deutschina:20180523153739p:plain

最初にUnionして、合計しながらいらない列を消していくというイメージになってます。Nokia Health側の分析を省いてしまっていますが、簡単に言うと日ごとの歩数が既にファイルに用意されているので、iPhone側のファイルのように下処理はやっていません。

UnionとJoinは違う!

お恥ずかしい話、これをきちんと理解していなくて、何が起こっているのかに小一時間かかったのは、ここだけの話です。何はともあれ、2つのテーブル(正確にはcsvファイル)を1つに統合するために、Unionを使ったわけです。実際の設定内容はこんな感じ。

f:id:deutschina:20180523153734p:plain

Union Data(2)と書いてあるように、activities(.csvNokia)とexport20180520211840(.csviPhone)をガッチャンコさせています。日付の項目(Date)を共通にして、歩数が入る項目はStepsとmax_sum_valueはそれぞれ独立させて持たせています。後で出てきますが、両方の値が入っている場合は、Nokia Health側の値を優先とする関数を後で仕込むつもりです。

Unionを設定した後にレコードイメージ(画面に出ているのはあくまでサンプリングされた一部データというのは前回書いたとおり)を見てみたのですが、イメージ的には日付1レコードに対して、Stepsとmax_sum_valueの両方にそれぞれ値が入っていることを期待したのですが、実際にはどちらか片方にしか値が入っておらず、同じ日付のエントリが複数ある状態。なんで?と小一時間考え込んで達した結論としては、

・・・あ、UnionとJoinは違う

ということ。Unionはあくまで2つのテーブルをマージするのが目的。だから脳内で勝手にキーだと思っていた日付(Date)が被っても当たり前なんですよね。自分が想像していた結果を期待するなら、むしろキーを指定して同じキーであれば1つのレコードに統合するというJoinの方だったというオチでした。

そこで、この関数をJoinに差し替えることも少しだけ考えたのですが、どうせ後でAggregateするつもりだったので、そのまま行く事にしました。この時点では日付ごとにデータがサマリされているので、件数もせいぜい数千件程度になっているだろうという読みもありました。

最後の集計

ここで、ようやくNokia側とiPhone側の歩数データを1つのレコードに入れるために、Aggregateをします。

f:id:deutschina:20180523153729p:plain

Group byがDateなので、1日につき1レコードになり、同じレコード上のStepsとmax_sum_valueに、Nokia側の歩数とiPhone側の歩数が入ってきます。次のステップでは、Nokia型の歩数がない場合に限り、iPhone側のデータの歩数を採用するように、New FormulaでIF文を仕込んでいます。

f:id:deutschina:20180523153723p:plain

これで新しい項目に、最終的に自分の取り決めに沿って確定した「歩数」が入ってくるので、最後に古い項目(Steps, max_sum_value)をドロップ。

f:id:deutschina:20180523153714p:plain

これで一連の流れが完成します。

データの出力

データのフローはできたので、これに全データを読み込ませて、最終的に1つの完成された日ごとの歩数情報のファイルをcsvとして出力してみます。

f:id:deutschina:20180523161441p:plain

デフォルト設定のまま実行しちゃうのもアレなので、設定をいろいろ見ておくことに。

f:id:deutschina:20180523161436p:plain

ファイルは、BigQueryとしても出力できるのですが、今回は素直にcsvをcloudのバケットに出力しときます。

f:id:deutschina:20180523161430p:plain

詳細な設定をみると、ファイルを小さい単位に分割するかとか、CSVファイルの1行目に列名をインサートするかみたいな設定を行うことが出来ます。隠れてしまっていますが、ファイルを圧縮するための設定もあります。

f:id:deutschina:20180523161423p:plain

設定をして、ジョブを開始して、JOBSというリンクを見ると、ジョブのフローと現在どこを処理しているのかというのが見えたります。並列的に処理できるところは複数のジョブを同時に実行していたりするんですよね。

f:id:deutschina:20180523163835p:plain

できたものは。。。

ジョブが終わると、Cloudのバケットにファイルが吐き出されるので、これを開いて中身を確認してみることができます。で、早速開いてみると・・・。

f:id:deutschina:20180523165028p:plain

分かっていたけど、地味っすね。。。

とボヤきたくもなります。これが30分値とか1時間値だと、一日のうちでどの時間帯に多く歩けば、減量に効果があるかみたいなことも分かるかもしれないので、少し残念ですね。もちろん、今回はNokia Healthを優先するという取り決めをしたため、日単位で合計するしかなかったものの、秒単位でデータの揃っているiPhone側のデータを正にすることで、時間帯ごとのデータも取れそうだと言うことは付け加えておきます。

f:id:deutschina:20180523165036p:plain

最終的に出力されたデータの統計的な情報が見えたりするのもDataprepの素敵なところかなということで、無理矢理締めたいと思います。

(たぶんつづく)

Google Cloud Platform エンタープライズ設計ガイド

Google Cloud Platform エンタープライズ設計ガイド

iPhoneのHealthデータをエクスポートしてゴニョゴニョしてみる その4

この記事の続きです。

deutschina.hatenablog.com

やりたいことはだいたい自動提案されてくる

Dataprepを使っていいなと思うのは、処理した列を選択(クリック)すると、右側に処理候補がいくつか自動提案されてきて、やりたいことが、だいたいその中に入っているということ。

列の削除

例えば、この列いらないと思ってクリックすると、

f:id:deutschina:20180523083800p:plain

右側の一番上に、Delete column (列の削除)というのが表示されて、すでに選択された状態になっている。ここでAddをクリックしたら、ステップの追加は完了。これは簡単だよね。

列を追加して新しい関数を仕込む

f:id:deutschina:20180523083806p:plain

次は、startDateという日付+時刻の項目の中から、日付部分のみを抜き出す列を用意した部分(New Formula)。既存の列にDATEFORMATを被せるだけで良いのかなと思ったけど、あとで消せば良いので別項目に持って行くことに。

不要な行を消す

f:id:deutschina:20180523083810p:plain

いらない行を消すのは、Keep Rowsを使う。いらない行を消すというよりは、必要な行を残すという表現の方が合うかも。

行の合計

行を集約する場合は、Aggregateを使用する。

f:id:deutschina:20180523083815p:plain

この例では、歩数を合計するSUM関数を使い、さらにどの項目をグループ化するか(Group by)を指定してることで、各日ごとの歩数の合計が算出される。

f:id:deutschina:20180523083821p:plain

最後にもう1回Aggreageしているのは、同じ日に複数の入力系統からの歩数データがあることを確認したから。自分に甘い(笑)ので、MAX関数を使ったけど、例えば特定の入力系統だけを信頼するなら、Keep Rowsを使うという考えもあるし、採用すべき入力系統が日によって違うなら、AVERAGEを取るなり、自分に厳しい人ならMINを使うという手もあると思います。

長くなったので、記事を改めます。次はいよいよ2つのファイルをマージしてジョブを流すところまで行ってみたいと思います。

(つづく)

iPhoneのHealthデータをエクスポートしてゴニョゴニョしてみる その3

この記事の続きです。

deutschina.hatenablog.com

Google Dataprep登場!

ひとまずiPhoneのHealthデータを取り出すことが出来たので、いよいよNokia Healthのデータと突合してみます。Nokia Healthからの歩数データの記録が抜けている部分をiPhoneのデータで補完をするという話でした。pandasのdataframeでやるのも良いのだけれども、せっかくなので、最近Courseraで学んだGoogle Dataprepを使ってみることにします。

f:id:deutschina:20180522223827p:plain

これは、最終的にNokia Healthの歩数データとマージするところまでを含んだデータフローになってます。上のactivities.csvというのがNokia Health側のファイル、export20180520211840.csvというのがiPhoneからのデータの入ったファイル。最後に2本の線が1本にまとまっているのが、2つのファイルをマージするという流れを表しているわけですね。

マージすると言っても、2つのファイルが持っている項目が異なったり、そもそも歩数データ以外の(今は)必要のないデータも入っているので、そこらへんの整理からやる必要がある訳です。まずは、iPhone側のデータから。

レシピを作る

データを加工するにあたって、レシピというのを作り、その中にさらにステップを追加していくというのが基本の流れ。今回作ったレシピには、こんな感じで11ステップ含めてみました。

f:id:deutschina:20180523070902p:plain

これだけ見るとウヘーと思うかも知れないけど、やっていることはいらない項目をドロップ(Delete)しているのと、あとは日ごとに集計(Sum)して、さらにいらない行を消したりしている(Keep Rows)ぐらいで、特段難しいことはしていない。Dataprepでは、これらのステップをグラフィカルなUIで簡単に作れるのと、大量データでもきちんと対応出来る(生データは40万件以上ある)のが良いところかなと。

レシピの変更画面に入って、ハマったところなどを書き出しておこうと思います。

f:id:deutschina:20180523071709p:plain

列名とデータの間に分布の棒グラフが出るあたりが、既になんだか良い感じですよね。ここで、1つ注意しないといけないのは、ココに出ている分布だったり、画面の下にある行数というのは、全データではなくて、サンプリングされたデータであるということ。これを見落としてCourseraのテストでなかなかパスできずに痛い目に遭いました。

f:id:deutschina:20180523072633p:plain

冷静に見ると分かるのですが、40万件のデータがなぜか4万件程度になっており、自分が使ったデータの場合はだいたい10%位のデータだけが抽出されていたと言う事になります。と言っても、ここに載っていないデータが処理されないという意味ではなく、あくまでこの画面での表示上の話。ちなみに、この画面にあるInitial sampleというリンクをクリックすると、いま抽出されているサンプルの情報が表示されます。例えば、表示されているサンプルが偏りすぎているみたいな場合は、サンプリングをやり直す事が出来ます。ただ、Dataflowのジョブが流れて、それなりに時間が掛かる(そしてリソースを消費するので、わずからながらに課金される)という事はアタマの片隅に入れておいた方が良いと思います。

f:id:deutschina:20180523073818p:plain

新しいサンプルを作って選択したところ。画面に表示されているサンプルの内容、値の散らばりが変わっているのが分かる。

長くなったので、続きはまた今度。

(つづく)

iPhoneのHealthのデータをエクスポートしてゴニョゴニョしてみる(その2)

この記事の続きです。

deutschina.hatenablog.com

前の記事で、iPhoneのHealthデータをCSVに変換しました。後で本丸であるNokia Health(旧Withings)のデータとマージするんだけど、その前に、もう少しデータの中身を確認しておこうということで、pandasのdataframeの出番ですね。

Healthのデータの中身を見てみよう

>>> import pandas as pd
>>> df1 = pd.read_csv('tmp_files/export20180520211840.csv', low_memory=False)
>>> df1.describe(include='all')

include='all'オプションを付けたのは、数値項目以外の情報も見たかったから。出てきたのがこちら。(※右端切れてます)

f:id:deutschina:20180522073208p:plain

項目ごとに深掘りする必要性があるかを見ていこう。

  • type 保持しているレコードの区分のようなものに見える。最頻値「HKQuantityTypeIdentifierDistanceWalkingRunning」は、字面の通りなら歩いたりランニングした距離ということになる。unique値が8ということは、このデータの塊は8種類のデータから出来ているということになる。
  • sourceName: 項目名と最頻値から推測すると、データの出元ということになる。iPhone7 Plusからの値が最頻値ではあるが、これもUniqueが4なので4種類のデータがありそう。
  • sourceVersion: 何かのバージョンというぐらいしか分からない。全部で42万レコードのうち16万レコードぐらいにしか値が入っていないと読み取れる。Dropしても良いのかな。
  • unit: countという如何にも歩数っぽく見える単位が全体の半分を占めている。
  • creationDate: レコードが登録された日付と読める。最頻値で2000回以上あるので、一括してインポートした日付なのかなと推定。
  • startDate, endDate: 開始・終了の日付。startDateとendDateの最頻値が全く同じに見える。期間値を取ると思っていたので、少し意外。とはいえ、最頻でも6回なので、計測の対象となった時間の情報が入っていると考えるのが正しそう。
  • value: これが実際の計測値と思われる。ただ、最小値0.000108で最大値19059なので、全部が全部歩数データではないと言えそう。
  • 残りの項目は、MetaAttributeから拾ってきたもの。あまり有益な情報がなさそう。"HKWasUserEntered"は自動入力か手入力かというフラグ、"Withings User Identifier"はユーザID情報に見えるので、あまり重要ではないと判断できそう。ただ、deviceは値が入ったり入っていなかったり。

先頭の数行を出してみる。

>>> df1.head()

f:id:deutschina:20180522073617p:plain

カテゴリ値に何があるかを見てみたい。

>>> set(df1.type.values)
{'HKQuantityTypeIdentifierActiveEnergyBurned',
 'HKQuantityTypeIdentifierBodyFatPercentage',
 'HKQuantityTypeIdentifierBodyMass',
 'HKQuantityTypeIdentifierBodyMassIndex',
 'HKQuantityTypeIdentifierDistanceWalkingRunning',
 'HKQuantityTypeIdentifierFlightsClimbed',
 'HKQuantityTypeIdentifierHeight',
 'HKQuantityTypeIdentifierStepCount'}

歩数が欲しいなら、HKQuantityTypeIdentifierStepCountのデータを拾えと言われている気がする。

>>> set(df1.sourceName.values)
{'Health Mate', "Ken's iPhone6", "Ken's iPhone7 Plus", 'UP'}

Health MateはNokiaのアプリの名前、iPhone6, iPhone7 Plusはアプリを経由していない値、UPは自転車乗るときに時々使っていたやつと思われる。

>>> set(df1.unit.values)
{'%', 'cm', 'count', 'kcal', 'kg', 'km'}

小数点付きのcountは、どうやらBodyMassIndexのことを指しているんだな。ん?これってBMIのことか!やばい。自分のBMIを公衆の面前に晒してしまった。

・・・ま、7年前のデータだから良いけどね。

歩数関連のデータを深掘りしてみる

先ほど確認したように、歩数関連のデータはtypeの値が'HKQuantityTypeIdentifierStepCount'のものを拾えば良さそう。

>>> df1[df1.type=='HKQuantityTypeIdentifierStepCount'].describe(include='all')

f:id:deutschina:20180522074547p:plain

良い感じと思ったものの、value値の散らばりが1~19059というのが気になる。75%値で95なので、19059というのは何かずば抜けた外れ値という可能性もありそう。

となると、10000歩以上記録されているレコードを確認してみよう。

>>> df1[(df1.type=='HKQuantityTypeIdentifierStepCount') & (df1.value>10000)]

f:id:deutschina:20180522074820p:plain

その日の午前0時から夕方または夜までの歩数の合計のように見える。上のイメージでは隠れているけど、全部で141件しかないので、常にこのパターンで記録されているという感じではなさそう。逆に歩数が少ないのは何をやっているのか?

>>> df1[(df1.type=='HKQuantityTypeIdentifierStepCount') & (df1.startDate>"2018-01-15") & (df1.startDate<"2018-01-31")]

※歩数が少ないエントリが2018年アタマに多かったように見えたので、日付で絞っている。
f:id:deutschina:20180522075011p:plain

sourceNameがiPhoneになっているのと、startDateとendDateの間が数秒になっている。本来の目的である連携漏れのデータはこちら側に転がっていそうな感じ。

ふむふむ。なんとなく全容が見えた気がするので、Nokia Health側のデータとの突き合わせをやってみよう。

(たぶんつづく)

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

iPhoneのHealthデータをエクスポートしてゴニョゴニョしてみる

最近機械学習の流れで、やっぱり自分でこねくり回せるデータの方が色々覚えるかなということで、自分が持っている加工しがいのあるデータを使ってみようかと思い立った週末の午後。と言っても、2010年から取り続けているWithings(現Nokia Health)の体重関連データ、あとは歩数の記録なんかぐらいしか見当たらない。WithingsとiPhoneのHealthデータは基本的に連携していたので、良い感じでデータが残っているものの、なぜか知らぬ間に連携が解除されていた期間があり、そこら辺を補完する意味で、まずはiPhone Healthのデータを解析してみることにしたという顛末記です。

iPhoneのHealthアプリのデータをエクスポートする方法

iOSのバージョンによって多少メニューが異なるもののだいたいこんな感じ。

1. Healthアプリの人影アイコンをタップ

f:id:deutschina:20180520214536j:plain

2. 一番下にあるExport Health Dataというメニューを選択

f:id:deutschina:20180520214753j:plain

3. 時間かかるけど良い?という確認を無視して、そのままExportをする

f:id:deutschina:20180520214850j:plain

f:id:deutschina:20180520214900j:plain

4. エクスポートが終わったら、メールに添付するなりしてファイルを入手する

f:id:deutschina:20180520214933j:plain

自分の場合は、メールに添付して送信。ZIPファイルの中に2つのXMLファイル(export.xml, export_cds.xml)が入っていたので、ざっくりと中身を確認してみる。まずはサイズの小さそうなexport_cda.xmlから。

import xml.etree.ElementTree as ET

with open('tmp_files/export_cda.xml', 'rb') as fd:
    root = ET.parse(fd).getroot()

どんな情報がありそうかざっくり確認。

>>> root.attrib
{'{http://www.w3.org/2001/XMLSchema-instance}schemaLocation': 'urn:hl7-org:v3 ../../../CDA%20R2/cda-schemas-and-samples/infrastructure/cda/CDA.xsd'}

>>>root.tag
'{urn:hl7-org:v3}ClinicalDocument'

>>>for child in root:
>>>    print(child.attrib)
{'code': 'US'}
{'root': '2.16.840.1.113883.1.3', 'extension': 'POCD_HD000040'}
{'root': '2.16.840.1.113883.10.20.22.1.2'}
{'extension': 'Health Export CDA', 'root': '1.1.1.1.1.1.1.1.1'}
{'codeSystem': '2.16.840.1.113883.6.1', 'codeSystemName': 'LOINC', 'code': '34109-9', 'displayName': 'Note'}
{}
{'value': '20180518144502+0900'}
{'code': 'N', 'codeSystem': '2.16.840.1.113883.5.25'}
{}
{}

Clinical documentという時点で、必要としている情報ではなさそう。にしては、ファイルサイズがでかいのが気になるけど、これは必要となったときに戻ってくると言うことにしましょう。ということで、ターゲットはもう1つのexport.xmlにすることに。

export.xmlの中身を確認してみる

同じように、export.xmlの中身を確認してみます。

with open('tmp_files/export.xml', 'rb') as fd:
    root = ET.parse(fd).getroot()

同じくルートを取ってからの中身の確認。先ほどの手順と同じなので詳細は省きますが、recordというノードの下に欲しそうなデータが転がっている事が判明。

>>> root[2].attrib
{'creationDate': '2016-08-14 12:43:21 +0900',
 'endDate': '2011-01-14 08:38:33 +0900',
 'sourceName': 'Health Mate',
 'sourceVersion': '2150100',
 'startDate': '2011-01-14 08:38:33 +0900',
 'type': 'HKQuantityTypeIdentifierBodyMassIndex',
 'unit': 'count',
 'value': '31.2459'}

ふむ。では、こんな感じで情報を溜めておけば、

>>> records = []
>>> for child in root:
>>>    if child.tag == 'Record':
>>>        records.append(child.attrib)

中身はこうなる。

>>> records[:5]
[{'creationDate': '2016-08-14 12:43:21 +0900',
  'endDate': '2011-01-14 08:38:33 +0900',
  'sourceName': 'Health Mate',
  'sourceVersion': '2150100',
  'startDate': '2011-01-14 08:38:33 +0900',
  'type': 'HKQuantityTypeIdentifierBodyMassIndex',
  'unit': 'count',
  'value': '31.2459'},
 {'creationDate': '2016-08-14 12:43:21 +0900',
  'endDate': '2010-08-26 08:17:40 +0900',
  'sourceName': 'Health Mate',
  'sourceVersion': '2150100',
  'startDate': '2010-08-26 08:17:40 +0900',
  'type': 'HKQuantityTypeIdentifierBodyMassIndex',
  'unit': 'count',
  'value': '30.7669'},
 {'creationDate': '2016-08-14 12:43:21 +0900',
  'endDate': '2010-10-21 08:09:23 +0900',
  'sourceName': 'Health Mate',
  'sourceVersion': '2150100',
  'startDate': '2010-10-21 08:09:23 +0900',
  'type': 'HKQuantityTypeIdentifierBodyMassIndex',
  'unit': 'count',
  'value': '31.1798'},
 {'creationDate': '2016-08-14 12:43:21 +0900',
  'endDate': '2010-12-10 09:23:06 +0900',
  'sourceName': 'Health Mate',
  'sourceVersion': '2150100',
  'startDate': '2010-12-10 09:23:06 +0900',
  'type': 'HKQuantityTypeIdentifierBodyMassIndex',
  'unit': 'count',
  'value': '31.1798'},
 {'creationDate': '2016-08-14 12:43:21 +0900',
  'endDate': '2010-03-18 07:24:23 +0900',
  'sourceName': 'Health Mate',
  'sourceVersion': '2150100',
  'startDate': '2010-03-18 07:24:23 +0900',
  'type': 'HKQuantityTypeIdentifierBodyMassIndex',
  'unit': 'count',
  'value': '31.5762'}]

これを基本として、あとでNokia Health側の情報と突き合わせることを考えて、XMLCSVに変換する事にしました。本当はJSONに変換してゴニョゴニョする予定だったけど、諸事情により中止。この顛末もそのうち記事にするかも。

最終的にCSV変換用に書いたソースがこちら。


Import XML file from Apple Health then convert to ...

長期間のデータが入っているので、アプリのバージョンが変わると、使われている属性(attributes)の顔ぶれが変わっているという問題の対応が必要でした。簡単に言うとアタマのエントリからkeys()を取るだけではダメだった。あとは、数字が入っているはずのvalueという項目になぜか文字列が入っているエントリがあり、最初は項目を変えて残す方向にしていたけど、あまり重要そうではなかったので無視するように変更したあたりがポイントかと。

これで、CSV化が完成したので、今度はこのファイルの中身をゴニョゴニョする感じで行く予定です。

(つづくかも)