Pythonによる体重・体脂肪データの解析 その1 Pandas & Plot編
その2はこちら
自転車トレーニングを始めた頃から現在まで、かれこれ17年ほど体重と体脂肪のデータを記録しています。基本的に毎日、風呂に入る直前と出た直後に体重計に乗り、体重と体脂肪を測定。数値をメモ帳に手書きで書き溜めておき、これを気が向いたときにエクセルのスプレッドシートに打ち込みます。今ならUSBとかクラウド対応の体重体脂肪計とかありますけど、今使っている単純なタニタの体重・体脂肪計にはそういう機能は無いですね。
データのプロットについてですが、エクセルのグラフは審美眼的に受け入れがたいものがありますので、外部のプログラムを利用することになります。当初はエクセルからcsv(というかタブ区切りのtsv)を出力し、これをMathematicaに読ませて補間処理をし、そこから出したデータをIgorProでグラフにしていました。
IgorProが手元から無くなったタイミングで、Matlabに移植して処理の一貫化を測りましたが、データのインポートはtsv経由でやっていました。
今回、Pandasの練習も兼ねて、一念発起してエクセルのxlsxからPythonに直接読み込みして一貫処理することにしました。こうしておけば将来もしエクセルを投げ捨てることがあっても、google spreadsheetやらなんやらにも移行できますし。もしかしたらクラウド体重計のデータをAPIで吸い上げる、なんてこともできるかも知れません。
方針
取りあえずデータ処理の方針をリストアップしておきます。
・毎日のデータは風呂前体重・風呂前体脂肪・風呂後体重・風呂後体脂肪の4値。
・ただし、出張・旅行・酒飲んで風呂に入り損ねた・測定をすっかり忘れた、などの理由でデータ欠損する事がある。
・体重データは最初の頃はひとつしか無く、途中から風呂前・後の2値とることにした経緯があり、この差を埋めるために2値あるときは低い方の値を採用することにした(ヒヒヒ 笑)
・体脂肪データは風呂前・後で差が出やすいので分けて残しておき観察する
・これら都合3値について欠損値は前後の存在するデータから線形で補完する。
・補間後のデータについて、毎日のデータのふらつきを平滑化するために7日間移動平均(7d MA)と28日間移動平均(28d MA)を取る。
コーディングはJupyter Notebook上でPython 3.8.5を使用して行いました。
データ
データ形式はこんな感じ。約6200行のデータで、欠損値は0で埋める事になっています。
ライブラリ
ライブラリの読み込み部。numpyとpandasは必要。いつもはmatplotlibの目盛りの処理に幾つもLocatorを入れているのですが、今回は一個だけで良かったようです。普段余り使わない日付関係のライブラリも複数必要になりました。
import numpy as np
import datetime
import matplotlib.pyplot as plt
#from matplotlib.ticker import (MultipleLocator,FixedLocator,AutoMinorLocator)
from matplotlib.ticker import MultipleLocator
import matplotlib.dates as mdates
import pandas as pd
データ読み込み
fname='weight.xlsx'
df=pd.read_excel(fname, index_col=0, header=None, usecols=[0,1,2,3,4],
names=['date', 'weight_before', 'fat_before', 'weight_after', 'fat_after'])
あ、エクセルからズドンとDataFrameに落とせるのスゴい楽。ここで
index_col=0 先頭コラム(0)をインデックスにするため。
header=None 1行目をデータの名前に使用させない。
usecols 6列目以降にゴミがあるようなので、それを読み込ませない。
names 上でheaderを取得させなかったので、ここで明示的に与える
結果:おお、日付が自動認識されて綺麗に読み込まれている。よきよき。
データ前処理
プロットする前のデータ処理をします。
df=df.replace(0, 1000)
df['weight'] = df[['weight_before','weight_after']].min(axis=1)
df=df.replace(1000,np.nan)
df=df.interpolate(method='linear', axis=0)
1行目:データに含まれる欠損値0をいったん1000に置き換えます。これは体重のうち小さいほうを選ぶ際に0が選ばれないようにするためです。
2行目:新しいコラム'weight'を作成し、風呂前・風呂後の体重の小さい方を入れます。axis=1をつかって行方向の処理である事を指定。
3行目:今度は1000をNaNと置き換え。こうしておくと次の行の補間処理でここが欠損なんだなと認識してもらえる。
4行目:線形補間をおこなう。axis=0で列方向の処理である事を指定。
次に移動平均の処理。
df[['weightMA7','fat_beforeMA7','fat_afterMA7']] = \
df[['weight','fat_before','fat_after']].rolling(window=7).mean()
df[['weightMA28','fat_beforeMA28','fat_afterMA28']] = \
df[['weight','fat_before','fat_after']].rolling(window=28).mean()
df[['A', 'B', 'C']]で部分データフレームを作り、これに対してRolling処理を行う。mean()を指定することで、与えられたウィンドウ幅の個数のデータに対し平均を行う。結果は新しいコラムとしてdfに保存する。この一部コラムにのみ処理を適用し、結果をDataFrameに追加するスタイルは実用性高いな!
データのプロット
プロットにはpandasとmatplotlibをコンボで使います。まずは共通設定を
plt.rcParams.update({
'font.family': 'sans-serif', # フォントはサンセリフ体
'font.sans-serif': 'Arial', # フォントファミリーはArial
'text.usetex': False, # LaTeXのインタプリタを使うか?No
'lines.linewidth': 2, # デフォルトの線幅は2
'font.size': 20, # デフォルトのフォントサイズは20
'xtick.direction': 'in', # x軸の目盛りは内側に描画
'ytick.direction': 'in', # y軸の目盛りは内側に描画
'axes.grid.axis': 'both', # グリッドはxy軸とも描画
'axes.grid.which': 'both', # グリッドはmajor/minorとも描画
'axes.grid': True, # グリッドは描画(だぶったかな?)
'lines.marker': 'o', # デフォルトのマーカーは丸
'lines.markersize': 1, # デフォルトのマーカーサイズは1
#
'legend.facecolor': 'white', # 凡例の枠内の色は白
'legend.framealpha': 1, # 凡例の枠内は不透明(α=1)とする
'legend.edgecolor': 'black', # 凡例の枠の色は黒
'legend.borderpad': 0.2, # 凡例の内側の余白は詰め気味に
'legend.fontsize': 14, # 凡例のテキストのサイズは14
'legend.labelspacing': 0.1, # 凡例のラベルとテキストの間の距離。詰め気味。
'legend.loc': 'lower center', # 凡例の位置。中央下側。
#
'figure.figsize': (14, 10), # 図全体のサイズは横14x縦10とする。単位は?
'savefig.facecolor': 'white', # 図の背景は白
'savefig.dpi': 300, # pngなどでエクスポートするときの解像度
'pdf.compression': 9 # pdfでエクスポートするときの圧縮度
})
プロットを作ります。今回は上下にスプリットした図。
上がax1、下がax2にアサインされます。
fig, (ax1, ax2) = plt.subplots(2,1)
上側(体脂肪)のプロット
上の図を作っていきます。プロット本体はpandasでラップされたplot関数を使います。なので、matplotlibのオプションを利用可能です。注意点は、普段ならax1.plot(...)とメソッドを使うところ、どの軸に書くかをax=ax1と言ったように指定する事。データ・セントリックな考え方ですね。
df['fat_before'].plot(linestyle='None',color=[0,0,1],ax=ax1,label='Before Bath')
df['fat_after'].plot(linestyle='None',color=[0,0.75,0],ax=ax1,label='After Bath')
df['fat_afterMA7'].plot(color=[0,0,0.5],ax=ax1,label=' 7d MA')
df['fat_beforeMA7'].plot(color=[0,0.5,0],ax=ax1,label=' 7d MA')
df['fat_afterMA28'].plot(color=[0,0.75,1],ax=ax1,label='28d MA')
df['fat_beforeMA28'].plot(color=[0,1,0],ax=ax1,label='28d MA')
Y軸の装飾を詰めていきます。
ax1.set_ylim([5,19])
ax1.set_ylabel('Fat Ratio [%]', fontdict={'fontweight': 'bold','fontstyle': 'italic'})
ax1.set_yticks(np.arange(6,20,2))
ax1.set_yticklabels(np.arange(6,20,2), fontdict={'fontstyle': 'italic'})
ax1.yaxis.set_minor_locator(MultipleLocator(1))
1行目: 体脂肪は5%~19%までプロット。
2行目: Y軸のラベルを指定するが、ここはボールドイタリックにしたいので、fontdictを使って指定。
3−4行目: 主目盛りは6から18まで2おきにつけ、ラベルも同じ場所に置く。なので18ではなく、np.arange(6,20,2)と指定しなければならない事に注意。
5行目: 副目盛りは1間隔で入れる。
さあX軸が大変だ。調べるのに異様に時間がかかりました。
start_date = '2004/07/01'
end_date = '2022/01/01'
x_start_date = datetime.datetime.strptime(start_date,'%Y/%m/%d')
x_end_date = datetime.datetime.strptime(end_date,'%Y/%m/%d')
ax1.set_xticks(np.arange(0,1e4,1e3))
ax1.set_xticklabels(np.arange(0,1e4,1e3), fontdict={'fontstyle': 'italic'})
ax1.set_xlim([x_start_date,x_end_date])
ax1.set_title('Weight and Fat Ratio', fontdict={'fontweight': 'bold'})
# Major ticks every 6 months.
fmt_half_year = mdates.MonthLocator(interval=6)
ax1.xaxis.set_major_locator(fmt_half_year)
# Minor ticks every month.
fmt_month = mdates.MonthLocator()
ax1.xaxis.set_minor_locator(fmt_month)
ax1.tick_params(axis='x',labelrotation=90)
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y/%-m'))
ax1.set_xlabel(None)
1-4行目: まず、プロットの開始終了日時を指定。1/1と7/1で区切ることにする。これをdatetime.datetime.strptimeで文字列->datetimeに変換する。
5-6行目: ダミーの目盛りを書く。これをしないと誰もfontdict単体で受け付けてくれないので、イタリック体にできないのだ。(誰かもっと良いやり方を教えて…)
7-8行目: ダミーの目盛り指定がプロット範囲を破壊するので、その後でxlim設定をする。それからこのプロット全体のタイトルをax1のタイトルとして表示。
9-14行目: これは以下のリンクから見つけた表現で、主目盛りは6ヶ月おき、副目盛りは1ヶ月おきで取るようにする。1月の日数が異なるので30とか設定するとうまく行かない。
15行目: X軸の目盛りラベルを90度回転する。
16行目: X軸の目盛りラベルの形式を2021/9のようにする。%-mは最初が0になるばあい(01月とか)の0を除去する指定。
17行目: X軸のラベルは無しとする。
グリッドの指定
ax1.grid(b=True, which='major', color=[0.4,0.4,0.4], linestyle='-')
ax1.grid(b=True, which='minor', color=[0.8,0.8,0.8], linestyle='-')
主目盛りのグリッドは濃いめ、副目盛りのグリッドは薄めのグレー。
凡例の指定。
handles, labels = ax1.get_legend_handles_labels()
order = [0,2,4,1,3,5]
ax1.legend([handles[idx] for idx in order],[labels[idx] for idx in order],ncol=2)
上でプロットの順番は重ねたい順番に並べているが、それだと凡例がごちゃごちゃになってしまう。ここでorderで指定した順番に凡例のアイテムのハンドルとplot時にしていしたラベルを同時に並べ替える。
体脂肪率の凡例は2列にする。
下側(体重)のプロット
考え方は体脂肪のプロットと同じですね。こっちのほうが凡例の並べ替えなど要らないので楽です。
df['weight'].plot(linestyle='None',color=[1,0,0],ax=ax2,label='Weight')
df['weightMA7'].plot(color=[0.5,0,0],ax=ax2,label=' 7d MA')
df['weightMA28'].plot(color=[1,0.4,1],ax=ax2,label='28d MA')
weight_low = 58
weight_high = 68
ax2.set_ylim([weight_low,weight_high])
ax2.set_ylabel('Weight [kg]', fontdict={'fontweight': 'bold','fontstyle': 'italic'})
ax2.set_yticks(np.arange(weight_low,weight_high+1,2))
ax2.set_yticklabels(np.arange(weight_low,weight_high+1,2), fontdict={'fontstyle': 'italic'})
ax2.yaxis.set_minor_locator(MultipleLocator(1))
ax2.set_xticks(np.arange(0,1e4,1e3))
ax2.set_xticklabels(np.arange(0,1e4,1e3), fontdict={'fontstyle': 'italic'})
ax2.set_xlim([x_start_date,x_end_date])
# Major ticks every 6 months.
fmt_half_year = mdates.MonthLocator(interval=6)
ax2.xaxis.set_major_locator(fmt_half_year)
# Minor ticks every month.
fmt_month = mdates.MonthLocator()
ax2.xaxis.set_minor_locator(fmt_month)
ax2.tick_params(axis='x',labelrotation=90)
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%Y/%-m'))
ax2.set_xlabel(None)
ax2.grid(b=True, which='major', color=[0.4,0.4,0.4], linestyle='-')
ax2.grid(b=True, which='minor', color=[0.8,0.8,0.8], linestyle='-')
ax2.legend()
図の表示範囲の調整
今回の場合X軸のラベルの縦幅が普通より広いので下の図と重なってしまいます。こんなトラブルを解消する重要なおまじない。
fig.tight_layout()
図の保存
図をPDFとPNGにエキスポートします。
plt.savefig('./weight_fat.pdf', bbox_inches='tight')
plt.savefig('./weight_fat.png', bbox_inches='tight')
完成したプロット
完成品はこちらになります。
コードだけで長くなったので解析については次回!
#時系列 #時系列データ #時系列分析 #データ解析 #データ分析 #データ可視化 #体重 #体脂肪 #Python3 #Pandas #matplotlib