ROXX 社内の開発組織向けラジオの取り組み紹介

この記事は個人ブログと同じ内容です

ROXX 社内の開発組織向けラジオの取り組み紹介

こんにちは、株式会社ROXX でエンジニアをやっている ぐっきーです。

今回は最近始めた開発組織内の交流イベント ROXX DevRadio についてご紹介したいと思います。

この記事はこんな方に向けた内容を発信しています。 - 自社を盛り上げたいが、どんなことやったらいいか迷っている方 - ROXX の開発組織に興味を持っていただいている方 - ROXX の開発組織に在籍している方

ROXX DevRadio について

ROXX DevRadio の様子

概要

社内の気になるメンバーをお迎えして深掘りするラジオ形式のイベントです。 - 主にゲストの方のパーソナリティと開発文化に関わる内容を中心に扱う - 約30分のトークセッション形式 - 収録した動画はイベント後に社内の slack で共有

リアルタイムでの公開収録 - リスナーの方にもお昼たべながら参加して聞き専もよし、わいわい盛り上げてもよしスタイルで運営しております。

取り組みの背景

元々ROXXの中で agent bank, back check という2つの事業部(現在は Records 事業部、 CTO 室、コーポレートITが追加)に対してそれぞれ開発組織がありましたが、相互の交流はほとんどない状況でした。 そのためプロジェクト間の経験や知識の共有がしづらい雰囲気がありました。

そこで CTO の kotamat さんが当時旗振りをしてくれつつ、社内の各セクションの交流を増やすという目的で全員参加で毎月 Meetup イベントを開催しておりました。

例えばこんなのとか - 社内で TDD のワークショップを開催しました! - RSGT2023をチームに持ち帰るための取り組み紹介 - GPTを使って1hでアプリ作ってみよう - etc...

しかし1年ほど継続した結果、開発メンバーが増えたことにより全員参加が難しくなったり、企画や LT 準備に負担を感じることがあったりでだんだんと運営自体が重くなってきたという課題感がありました。

そこで解決策として、「重すぎないイベント運営によって、社内のDev組織間・メンバー間で、カジュアルに情報交換・交流が行える場を提供する」というコンセプトでまずは「知っている人」を増やすという目的から DevRadio の取り組みを始めることとしました。

運営体制の紹介

kotamat さんから運営委員を引き継ぎ、社内の有志(たまたま各部署から1名ずつ)の4人 + プロダクト開発部 GM の宮竹さんのサポートの体制で運営しています。

運営準備など裏側でやっていること

運営側もつらくないことを意識して開催フォーマットを型化することで、ふりかえりから事前準備含めて約 1 時間ちょっとの工数で準備ができています。(これ大事)

  • ふりかえり: 30分
  • ゲスト決め
  • ゲストへの参加依頼
  • show note づくり: 30分
  • 事前、事後アンケート
  • 社内告知

過去回の紹介

第 1 回

ゲスト:高畠 正和さん ポジション:agent bank プロダクトマネージャー 話した内容:

- 地元、北海道話
- 営業→エンジニア→PdM。何があった?
- とっておきの失敗話
- 今後の野望があれば...

第 2 回

ゲスト:三浦 史也さん ポジション:コーポレートIT 話した内容:

- ROXXで(音楽関係で)やりたいこと
- 実際セキュリティ監視やセキュリティ診断って何やってるの?
    - どんなインシデント対応やログ調査をやってきたか
    - 今後ROXXでどんなことをしていきたいか?
    - つらかったこと、つらかった現場ばなし
- プロダクト開発部の人達との接点、またどのように関わっていけるのが理想的か

第 3 回

ゲスト:竹原 駿平さん ポジション:back check テックリード 話した内容:

- ROXX 来る前の話
- ROXXでテックリードとして働くまでのキャリアの話
    - 技術キャッチアップを高度にしていくための工夫ってある?
- 普段からやってる積み上げ、個々の場面で意識していることは?
- 子育て、スプラ、いつやってんの?

参加してくれた方の声

参加したくれた方達の感想としても下記のようにポジティブなお声をいただけております!

  • リスナーの皆さん
    • 内容が濃くて超面白かったです!
    • 時間の長さもちょうどよく聞きやすかった。
    • めちゃくちゃ良かったです! 丁度仕事がたまってたんですが、こういう形式だったので参加することができました。
  • ゲストの皆さん(依頼を投げたら皆さん、快く引き受けていただき大変に感謝です)
    • 自分自身のこれまでを言語化する良い機会でした。
    • ゲストとして参加して楽しかった。
    • 事前準備の負担もさほどなかった。

リスナーの方向け、おすすめの楽しみ方

これは社内のメンバーが読んでくれたらうれしいなと思い添えておきます。

  • お気軽に収録のMTGに参加、リアクション等を投げて参加しつつ楽しんでいってください。
  • 作業の集中時間の BGM としてご活用ください。
  • ○○さん、DevRadio でこんなこと話してたよね〜など社内のメンバー間での雑談のネタとしてご活用ください。

運営として、参加してくれた方にこんなことしていただけたら喜びます。

  • △△さんのこんな話聞いてみたいなどのご要望をいただけると嬉しいです!
  • 皆さんの方から「最近やってるこんなこと紹介したいからゲストとして呼んでほしい」とか声かけてくれたらめちゃめちゃ嬉しいです。
  • 運営として DevRadio はじめイベント企画などを通して ROXX の開発組織を一緒に盛り上げたいという方がいたら声をかけてくれたら最高にうれしいです。

今後の意気込み

ROXX はいま今後のスケールに向けて、開発組織に面白い人達がどんどん入ってきているフェーズです。 運営メンバーの考えとして、多様な背景や考え方、スキルセットの掛け合わせがより多く生まれるほど、働く人にとっても顧客にとってもわくわくするような体験を提供できる機会が創出できると信じています。

今後もイベント企画等を通して組織の横の繋がりを拡大することで、業務におけるコラボレーションのハードルをどんどん下げていくような取り組みをやっていく予定です。

もし ROXX の開発組織やイベント運営にご興味を持っていただけた方がいらしたら、ぜひお気軽にお声がけください。

Twitter: Gukki- ぐっきー_@Area029S

時系列分析による時系列データの解析と未来予測(ARIMA, SARIMA)

この記事は個人ブログと同じ内容です

www.ritolab.com


時系列分析の基礎を確認しつつ、「データの確認・理解」「定常データへの変換」「モデル構築(ARIMA, SARIMA)」と一連の時系列分析の流れを実施し、時系列データの未来予測を行っていきます。

時系列分析とは

時系列分析は、時間的な順序で取られたデータ(=時系列データ)の特性やパターンを分析し、過去の振る舞いから将来の振る舞いを予測するための統計的手法です。

時系列データとは

時系列データは、一定の時間間隔(日次、週次、月次など)で観測されたデータポイントから構成されるデータです。

このような時系列データは、経済・株価・気象・トラフィックデータなど、多くの実世界の現象を表現するために使用されます。

  • ある地域の毎日の気温
  • ある店舗の日次の売上

通常の時系列データでは観測者によって観測の時間間隔が設定されます。

時系列データの特徴

  • 時系列データは一度しか観測されない
  • 観測値から平均や分散などを推定することはできない
    • 時間の非独立性
      • 時系列データの観測値は時間的に依存しており、過去の値が現在の値に影響を与える可能性がある。したがって、単純な平均や分散の推定では、データの時間的なパターンや相関関係を考慮することができない。
    • 季節性や周期性
      • 時系列データには季節性や周期性が存在する場合がある。これらの要素は平均や分散に影響を与える可能性があり、単純な統計的手法では適切にモデリングできない。
    • トレンド
      • 時系列データには長期的なトレンドが存在することがある。トレンドは平均値の変動を引き起こし、推定結果に影響を与える可能性がある。

時系列データとは区別するべきデータ

地震観測データや為替取引の Tick データは、「点過程(ポイントプロセス)データ」または「マーク付き点過程データ」と呼ばれます。これらのデータは、発生時間と発生間隔に意味があり、観測者が発生間隔を設定できません。

これらも時系列分析の対象となり得ますが、通常の時系列データとは異なる特性を持つため、それに応じた解析手法やモデルの適用が必要となります。

時系列分析で出来ること・わかること

時系列分析では、以下について知ることができます。

  • トレンド
    • 時系列データに現れる長期的な変化や傾向(トレンド)を把握できます。トレンドの有無や方向、変動のパターンを特定することができます。
  • 季節性
    • 時系列データに現れる季節的な変動を把握できます。特定の季節パターンや周期性を検出し、季節要素の影響を理解することができます。
  • 周期性
    • 時系列データに周期的な変動がある場合、その周期や周期の長さを特定することができます。サイクルの長さや振幅の変動を分析することができます。
  • 予測
    • 過去の時系列データを基に将来の値を予測することができます。予測モデルを構築し、将来の傾向や変動を推定することができます。
  • 異常値検出
    • 時系列データから異常値や外れ値を検出することができます。異常な振る舞いや予測モデルからの逸脱を特定し、異常値の原因や特徴を分析することができます。
  • 時系列データ間の相関関係
    • 他の時系列データや外部要因との関係を分析し、相互の影響や連動性を理解することができます。

これらの分析を通じて、時系列データの特徴や変動要因を理解し、将来の予測や意思決定に活用することができます。

時系列分析の手順

  1. データの特性の理解
    • 時系列データの基本的な特性を調査し、傾向(トレンド)、季節性、周期性、ランダム性などのパターンを特定。
  2. モデリング
    1. 時系列データにモデルを適用し、データの生成プロセスを表現するための数学的なモデルを構築。
    2. 代表的なモデル
      • ARIMA(Autoregressive Integrated Moving Average)
      • SARIMA(Seasonal ARIMA)
      • VAR(Vector AutoRegression)など
  3. 予測
    • 構築したモデルを使用し将来のデータポイントを予測。
  4. モデルの診断と改善
    • 構築したモデルの適合度や残差の診断を行い、モデルの改善や修正を行う。モデルの信頼性と予測精度を向上させていく。

サンプルデータ

今回は、航空機の乗客数データを使って未来の予測や季節性などの理解を行っていきます。

その際に、ARIMA モデル、及び SARIMA モデルを作成します。

R 言語の標準データセットとして提供されている「AirPassengers」を利用しますが、今回は Python で進めるため、Kaggle で AirPassengers データセットをダウンロードしておきます。

Air Passengers - Kaggle

データの読み込みと調整

ライブラリの読み込みを行い、データを読み込みます。

import pandas as pd
import numpy as np
import statsmodels
import statsmodels.api as sm

from matplotlib import pylab as plt
import seaborn as sns
%matplotlib inline
sns.set()

from matplotlib.pylab import rcParams
rcParams['figure.figsize'] = 16, 8

# データ読み込み
df = pd.read_csv('AirPassengers.csv')

読み込んだデータを確認します。

# df

    Month   #Passengers
0   1949-01 112
1   1949-02 118
2   1949-03 132
3   1949-04 129
4   1949-05 121
... ... ...
139 1960-08 606
140 1960-09 508
141 1960-10 461
142 1960-11 390
143 1960-12 432


# df.dtypes

Month          object
#Passengers     int64
dtype: object

データを使いやすくするためにカラム名をリネームし、Month をインデックスにしておきます。

## カラム名リネーム
df = df.rename(columns={'#Passengers': 'Passengers'})
## datetime 化
df['Month'] = pd.to_datetime(df['Month'] )

# Month をインデックスにする
df.set_index('Month', inplace=True)

再度データを確認します。

# df

      Passengers
Month   
1949-01-01  112
1949-02-01  118
1949-03-01  132
1949-04-01  129
1949-05-01  121
... ...
1960-08-01  606
1960-09-01  508
1960-10-01  461
1960-11-01  390
1960-12-01  432
144 rows × 1 columns


# df.dtypes

Passengers    int64
dtype: object

Month をインデックスに変換したのは、整列・集計・プロットなどを行いやすくするためです。

データの確認

データの読み込みと整理が済んだので改めてデータを見ていくと、1949 年 1 月から、1960 年 12 月までの乗客数データが収録されています。 プロットして見てみます。

plt.plot(df['Passengers'])

視覚的には、どことなく似たような周期のアップダウンを繰り返しながら上昇しているように見えます。

この時系列データが非定常性を持つかを数値的に判断するために、ディッキーフラー検定を実施してみます。

Dickey-Fuller 検定(ディッキーフラー検定)

ディッキーフラー検定は、自己回帰モデルにおける単位根の有無の検定です。

  • 帰無仮説:「データ系列に単位根が存在する」
  • 対立仮説:「データ系列は定常性を有す」

時系列データの平均、分散、自己相関係数などの計算をはじめとした時系列データの分析をする際には、事前に検定を行い、分析対象の時系列データが定常性を有するか確かめることが必要です。チャートを見ると非定常性が明らかな感じがしますが、見た目だけ判断せず検定を行って確認をしておきます。

単位根

単位根(unit root) は、時系列データの性質を表す統計的な概念です。単位根を持つ時系列データは、長期的なトレンドや構造的な変化が存在し、平均値が一定でない(恒久的にランダムウォークをするような特性を持っている)ことを示します。単位根は統計的仮説検定によって検出され、データの非定常性を考慮する必要があります。

つまり、Dickey-Fuller 検定によって帰無仮説が棄却されない場合、単位根が存在すると判断し、その時系列データは非定常性である。と結論づけられます。

非定常性

非定常性(non-stationary) とは、時系列データの統計的性質が時間に依存し、一定のパターンや特性を持たない状態を指します。非定常性を持つ時系列データは、平均や分散が時間の経過とともに変動する傾向 があります。

非定常性を示す時系列データには、以下のような特徴があります:

  • トレンド(Trend)
    • データが長期的に上昇または下降する傾向がある場合、トレンドが存在します。トレンドは、統計的に見て平均値が時間とともに変化することを示します。
  • 季節性(Seasonality)
    • データに周期的なパターンや季節的な変動がある場合、季節性が存在します。季節性は、統計的に見て周期的な変動があることを示します。
  • 周期性(Cyclicity)
    • データに定期的な周期性があり、季節性とは異なる場合、周期性が存在します。周期性は、統計的に見て一定の期間で変動が生じることを示します。
  • 自己相関(Autocorrelation)
    • データの過去の値との相関関係があり、現在の値が過去の値に依存する場合、自己相関が存在します。自己相関は、統計的に見てデータが時間的な依存関係を持つことを示します。

非定常性を持つ時系列データは、定常性の仮定を満たさないため、統計モデリングや予測の精度を下げる可能性があります。そのため、非定常性のデータを扱う場合は、定常性を復元するための前処理や変換が必要となる場合があります。例えば、トレンドの除去や差分化、季節性の調整などが一般的なアプローチとして使用されます。これにより、データの統計的性質が一定である定常な状態に変換され、より正確な予測やモデリングが可能になります。

ディッキー・フラー検定の実施

Python では statsmodels の adfuller 関数で実施します。

statsmodels.tsa.stattools.adfuller

# ディッキー・フラー検定の実施
result = sm.tsa.stattools.adfuller(df['Passengers'])

# 結果の表示
print('ADF統計量(ADF Statistic):', result[0])
print('p値(p-value):', result[1])
print('臨界値(Critical Values):')
for key, value in result[4].items():
    print('\t%s: %.3f' % (key, value))

結果出力

ADF統計量(ADF Statistic): 0.8153688792060421
p値(p-value): 0.9918802434376409
臨界値(Critical Values):
    1%: -3.482
    5%: -2.884
    10%: -2.579

ディッキー・フラー検定の結果を解釈するためには、ADF 統計量と p 値を考慮し、臨界値と比較して帰無仮説を棄却するかどうかを確認します。

ADF 統計量は 0.8153688792060421 です。この値を臨界値と比較することで、時系列データの非定常性を評価します。臨界値は 1%、5%、および 10% の有意水準に対する値であり、以下のようになります

  • 1%: -3.482
  • 5%: -2.884
  • 10%: -2.579

結果を解釈するためには、ADF 統計量を臨界値と比較します。ADF 統計量が臨界値よりも小さい場合は定常性の存在が示唆され、帰無仮説を棄却できます。逆に、ADF 統計量が臨界値よりも大きい場合、定常性の存在が示されず、帰無仮説を棄却できません。

今回の場合、ADF 統計量の値(0.8153688792060421)は臨界値(-3.482、-2.884、-2.579)よりも大きいため、定常性の存在が示されません。また、p 値が非常に高い(0.9918802434376409)ため、帰無仮説を支持し、定常性の存在を示す証拠はほとんどありません。

したがって、与えられた結果からは「帰無仮説を棄却できない」ということになり、「データ系列に単位根が存在する」つまり「定常性を有するとは言えず、非定常性を有する可能性が高い」と結論付けられます。

非定常性・定常性を持つデータであるか

乗客数の原系列データ(未加工のもとのデータ)を見れば、年々数値が上昇(トレンドがある)していたり、そのトレンドの中でも似たようなアップダウン(周期性・季節性)があることがなんとなくわかると思います。

時系列分析におけるポイントとして、予測のためのモデル構築を行う際には、こういった「非定常データ」を「定常データ」という、トレンドや周期性・季節性を排除したデータへ変換してからモデルを構築する必要がある。という背景があります。

非定常データから定常データへの変換を行っていく過程で、変換後のデータが定常性を持つデータになっているかを確認するために、ディッキー・フラー検定は有効です。

時系列データの理解

続いて、今回の時系列データがどのような性質をもっているのかを確認していきます。

自己相関と偏自己相関を視覚化し、データの構造やパターンを理解します。

自己相関(Autocorrelation)

自己相関は、時系列データ内の異なる時間ラグ間の相互関係を測る統計量です。具体的には、ある時刻のデータと一定の時間ラグだけずれた時刻のデータとの間の相関を計算します。自己相関を調べることで、時系列データの周期性やパターンの特徴を把握することができます。

自己相関のグラフは、時系列データの過去の値と現在の値との間の相関関係を示します。このグラフを通じて、データがどれくらい自己相関を持っているか、周期性や季節性のパターンが存在するか、および他のタイプの相関関係があるかを視覚的に確認することができます。自己相関が高い場合、データが過去の値に依存しており、トレンドや周期性のパターンが存在する可能性があります。

また、自己相関、及び次に紹介する偏自己相関のグラフは一般に コレログラム(Correlogram) と呼ばれることもあります。

# 自己相関
fig = sm.graphics.tsa.plot_acf(df['Passengers'], lags=40)

x=0 の地点にある値が基点となるデータで、x=1 の地点にある値がラグ 1 となるデータです。つまり今回の乗客数データでは、x=0 が基点月で、x=1 が前月、x=1 が二ヶ月前で... という読み方ができます。

このコレログラムを見ると、 基点月と前月は強い正の自己相関を示しています。

また、全体的に一定のパターンで推移していそうなことも読み取れます。

グラフの青色の範囲は 95%信頼区間(=ACF{自己相関関数}の推定値が統計的に有意でないと考えられる範囲)を示してしますが、範囲を抜けているラグが 1 年くらいというのもあること、チャートの推移から1 年周期でのパターン(12の周期)があることが読み取れます。

偏自己相関(Partial Autocorrelation)

偏自己相関は、ある時刻と別の時刻の間で、他の時刻の影響を取り除いた相関を測る統計量です。つまり、一連の中間時刻を通じて制約されない2つの時刻間の直接的な相関を計算します。偏自己相関を調べることで、直接的な相関関係を評価することができます。

偏自己相関のグラフは、時系列データの過去の値と現在の値との間の直接的な相関関係を示します。このグラフを通じて、データがどの時点で自己相関を持つのか、他の時点の影響を排除した直接の相関関係が存在するかを視覚的に確認することができます。

# 偏自己相関
fig = sm.graphics.tsa.plot_pacf(df['Passengers'], lags=40)

0 地点のデータは 1 地点前のデータと強い正の自己相関にあり、前月の乗客数が多ければ当月も多くなることがわかります。

また、12 ヶ月周期の相関が見られるので、周期性ないし季節性があるとわかります。

和分課程〜非定常データから定常データへ

これらの時系列データから予測を立てられるようにするには、先ほど確認した時系列的な変動パターンがあると予測が立てられないため、周期変動を除去し、非定常から定常性のデータに変換していく必要があります。

時系列分析において、非定常データを定常データに変換することにはいくつかの重要な意味があります。

  • 定常性の仮定
    • 時系列データが定常である場合、データの統計的特性が時間に依存しなくなります。この特性は、時系列分析において重要な仮定です。定常データは、平均や分散が一定であり、自己共分散(自己相関)が時間に依存しないという特徴を持ちます。定常データである場合、統計的手法がより正確で信頼性の高い結果を提供しやすくなります。
  • モデルの安定性
    • 非定常データをそのまま使った場合、データの統計的特性が時間に依存しているため、モデルのパラメータが時間経過とともに変化してしまう可能性があります。これによって、予測の精度が低下したり、モデルの安定性が損なわれたりすることがあります。定常データに変換することで、モデルのパラメータを安定させることができます。
  • データ解釈と比較
    • 定常データは、時間に依存しない統計的特性を持つため、データの解釈や異なる時点のデータとの比較が容易になります。また、非定常データではトレンドや季節性の影響がデータ全体に影響を及ぼすため、異なる時点のデータを比較することが困難になることがあります。

非定常データを定常データに変換するために、和分過程(差分化)や季節調整などの方法が利用されます。これらの手法を用いることで、データの非定常性を取り除き、より信頼性のある分析や予測モデルの構築が可能となります。

モデルを構築する前に、これら時系列データの非定常性を除去していく様子を見ていこうと思います。

ちなみに、データの差分を取ることで非定常性(単位根)を除去した後の過程を 和分過程(Integrated Process) といいます。

差分系列(階差系列)

差分系列(階差系列, Difference series)は、時系列データの、連続する観測値間の差分を計算した系列(1時点離れたデータとの差をとったデータ)です。差分系列を計算することで、トレンドや季節性の影響を除去し、定常性を持ったデータに近づけることができます。

差分系列は数式で表すと以下で表現できます。

 \displaystyle
\Delta y_t = y_t - y_{t-1}

 y_t は時点  t における観測値を表し、y_{t-1} はその直前の時点  t-1 における観測値を示します。差分系列  \Delta y_t は、現在の観測値  y_t から直前の観測値 y_{t-1} を引いた値として計算されます。

Passengers(乗客数)の値から階差系列を出力します。

# 差分系列
df['difference'] = df['Passengers'].diff()
# df

    Passengers  difference
Month       
1949-01-01  112 NaN
1949-02-01  118 6.0
1949-03-01  132 14.0
1949-04-01  129 -3.0
1949-05-01  121 -8.0
... ... ...
1960-08-01  606 -16.0
1960-09-01  508 -98.0
1960-10-01  461 -47.0
1960-11-01  390 -71.0
1960-12-01  432 42.0
144 rows × 2 columns

一番はじめ、1949-01-01 の difference が NaN なのは、1 地点前のデータが存在しないためです。

差分系列をプロットしてみます。

plt.plot(df['difference'])

トレンドは除去されたようです。

しかし振れ幅がだんだん大きくなっていっているので分散はまだ一定にはなっていないことがわかります。

したがって、この時点では完全に定常性を持ったデータにはなっていないことがわかります。

対数系列(Log series)

対数系列は、時系列データの各観測値に対して対数変換を適用した系列です。対数変換により非線形な変動を緩和し、データの特性を正規分布に近づけることができます。

数式では以下で表現します。

 \displaystyle
\log  y_t = log(y_t)

 y_t は個々の時点  tにおける観測値や測定値を示し、 \log y_t y_t の自然対数を表します。この数式では、各時点の  y_t に対して自然対数を適用し、その結果を  \log y_t として表現しています。

定常性を持ったデータに近づけるため、こちらも試してみます。

# 対数系列(底が10となる対数を作成する)
df['log'] = np.log10(df['Passengers'])

対数系列データを確認します。

# df

Passengers  difference  log
Month           
1949-01-01  112 NaN 2.049218
1949-02-01  118 6.0 2.071882
1949-03-01  132 14.0    2.120574
1949-04-01  129 -3.0    2.110590
1949-05-01  121 -8.0    2.082785
... ... ... ...
1960-08-01  606 -16.0   2.782473
1960-09-01  508 -98.0   2.705864
1960-10-01  461 -47.0   2.663701
1960-11-01  390 -71.0   2.591065
1960-12-01  432 42.0    2.635484
144 rows × 3 columns

グラフに描画します。

# 対数系列をプロット
plt.plot(df['log'])

トレンドはあるものの、振れ幅(分散)はあまり変わっていないように見えます。

対数系列を取ると、分散がある程度一定に抑えられることがわかりました。

対数差分系列(Log Difference series)

階差系列への変換ではトレンドが除去でき、対数系列への変換では分散をある程度一定に抑えられることがわかったので、対数差分系列への変換を行ってみます。

対数差分系列は、時系列データの対数変換と差分の組み合わせです。具体的には、各時点の観測値に対して対数変換を適用し、その後に差分を計算します。

対数差分系列は、対数変換によって非線形性を緩和し、変動の幅を縮小させる効果があります。また、差分を取ることでトレンドや季節性の影響を除去することができます。対数変換は正規分布に近い性質を持つデータに対して効果的であり、対数差分系列は定常性の要件を満たすことが多いです。

対数差分系列の数式は次のように表されます

 \displaystyle
\Delta \log y_t = \log y_t - \log y_{t-1}

ここで、 y_t は時点  t における観測値を表し、 \log y_t はその対数変換を示します。差分系列  \Delta \log y_t は、現在の対数変換後の観測値  \log y_t から直前の対数変換後の観測値  \log y_{t-1}を引いた値として計算されます。

# 対数差分系列
df['log_difference'] = np.log10(df['Passengers']).diff(periods=1)

対数差分系列データを確認します。

# df

    Passengers  difference  log log_difference
Month               
1949-01-01  112 NaN 2.049218    NaN
1949-02-01  118 6.0 2.071882    0.022664
1949-03-01  132 14.0    2.120574    0.048692
1949-04-01  129 -3.0    2.110590    -0.009984
1949-05-01  121 -8.0    2.082785    -0.027804
... ... ... ... ...
1960-08-01  606 -16.0   2.782473    -0.011318
1960-09-01  508 -98.0   2.705864    -0.076609
1960-10-01  461 -47.0   2.663701    -0.042163
1960-11-01  390 -71.0   2.591065    -0.072636
1960-12-01  432 42.0    2.635484    0.044419
144 rows × 4 columns

グラフに描画します。

# 対数差分系列をプロット
plt.plot(df['log_difference'])

トレンドが除去でき、分散もおおよそ一定に近づいたではないでしょうか。

一方で、周期性は残っているように見えます。年単位で周期があるように見受けられるため、周期性というより季節性かもしれません。

一旦、ここまで非定常データを定常に近づけるために変換処理を行ったデータに対して、再度ディッキー・フラー検定を実施し、自己相関・偏自己相関も見てみます。

# 対数差分系列の先頭行は NaN のため除去
list = df['log_difference'][1:]

# ディッキー・フラー検定の実施
result = sm.tsa.stattools.adfuller(list)

# 結果の表示
print('ADF統計量(ADF Statistic):', result[0])
print('p値(p-value):', result[1])
print('臨界値(Critical Values):')
for key, value in result[4].items():
    print('\t%s: %.3f' % (key, value))
# 出力された結果

ADF統計量(ADF Statistic): -2.7171305983880982
p値(p-value): 0.07112054815086455
臨界値(Critical Values):
    1%: -3.483
    5%: -2.884
    10%: -2.579

ADF統計量が -2.7171305983880982 であり、p値が 0.07112054815086455 です。この場合、有意水準 5% での臨界値(-2.884)を下回っていませんが、有意水準 10% での臨界値(-2.579)を下回っています。

つまり、p 値が 0.05 より大きいため、5% の有意水準帰無仮説を棄却できませんが、10% の有意水準帰無仮説を棄却できる可能性があります。

したがって、データが非定常かどうかについてはやや曖昧な結果となりますが、有意水準 10% での臨界値を下回っていることから、データには定常性がある可能性も出てきました。変換処理に効果が出ているということですね。

自己相関と偏自己相関も見てみます。

# 自己相関をプロット
fig = sm.graphics.tsa.plot_acf(list, lags=40)

# 偏自己相関をプロット
fig = sm.graphics.tsa.plot_pacf(list, lags=40)

まだ季節性が残っていることが見受けられます。

よって完全なる定常データへ変換できたか?という問いに対しては「まだ季節性が残っているため十分ではない」とし、さらなる変換が必要ということになります。

現時点で全ての非定常性要素を除去できたわけではなりませんが、このように、通常であれば非定常な時系列データを定常データに変換していき、それを使って予測モデル(ARIMA, SARIMA etc...)を構築していく流れになります。

ARIMA モデル

本記事では割愛していますが、ARMA モデル(自己回帰移動平均モデル)というものがあります。

この ARMA モデルは、定常データに対しては説明能力は良いが非定常データには使えません。そのため、差分系列をとって定常過程に変換してから、ARMA モデルを適用することを考えます。これを ARIMA(アリマ, 自己回帰和分移動平均モデル、Autoregressive Integrated Moving Average)モデルといいます。

差分系列へ ARMA モデルを適用する場合、d 次和分過程を I(d) と書くので、真ん中に入れて “ARIMA” と呼ばれます。

ARIMA = AR 過程(Auto Regressive process, 自己回帰過程)+和分課程(I)+MA 過程(Moving Average process, 平均移動過程)

ということで、データは原系列データではなく、差分系列データを用いてモデルを作成していきます。

データの分割

データを学習用とテスト用に分けます。

今回のデータでは、1949-01 〜 1957-12-01 までのデータを学習用として使い、ARIMA モデルを構築します。

1958-01 〜 1960-12 までのデータはテスト用とし、作成したモデルで同期間の予測を行った後、このテスト用データと付け合わせて実際の予測がどれだけの精度かを確認します。

# データを学習用とテスト用に分ける
df_train_arima_diff = df['difference'][:'1957-12-01'].dropna()
df_test_arima_diff = df['difference']['1958-01-01':]

次数の決定

ARIMAモデルは、3 つの主要なパラメータ(p、d、q)によって定義されます。これらのパラメータは、モデルの自己回帰(AR)成分、積分(I)成分、および移動平均(MA)成分を制御します。

  • p(自己回帰次数)
    • 自己回帰成分の次数を示します。直前の時刻の値にどれだけの過去の値を使用するかを示します。大きな値は、過去の多くの時刻の値が予測に影響を与えることを意味します。
    • AR 過程の次数
  • d(積分次数)
    • 積分成分の次数を示します。これは、元の時系列データが非定常過程(トレンドや季節性の影響を受けて変動する)であるかどうかを示します。dの値が 0 であれば、元の時系列データは定常過程と見なされます。d の値が 1 以上であれば、差分を取ることによってデータを定常化します。
  • q(移動平均次数)
    • 移動平均成分の次数を示します。誤差項に過去の誤差値をどれだけ使用するかを示します。大きな値は、過去の誤差値が予測に影響を与えることを意味します。
    • MA 過程の次数

これらの次数を最適値を見つけるために、statsmodels の arma_order_select_ic 関数を利用します。

statsmodels.tsa.stattools.arma_order_select_ic

この関数は、情報基準(Information Criterion)を使用して異なる次数の組み合わせを比較し、最適な次数を見つけるのに役立ちます。

一般的に、AIC(Akaike Information Criterion)やBIC(Bayesian Information Criterion)などの情報基準が使用されます。

sm.tsa.arma_order_select_ic(df_train_arima_diff, ic='aic', trend='n')

結果出力

{'aic':              0           1           2
 0  1001.530812  990.101618  987.950157
 1   994.820617  987.280756  982.138924
 2   990.473898  981.180360  983.831761
 3   991.560168  983.089715  978.733996
 4   982.579395  984.165016  978.372978,
 'aic_min_order': (4, 2)}

AIC が最も低いものが最も良いモデルとされ、p=4, q=2 が AIC=978.372978 と最も低く最適な次数であるという結果が出たので、これを使っていきます。

モデル作成

ARIMA モデルは statsmodels の ARIMA() で作成できます。

statsmodels.tsa.arima.model.ARIMA

from statsmodels.tsa.arima.model import ARIMA

# ARIMA モデル作成
model = ARIMA(data_diff, order=(4, 1, 2))

result = model.fit()

推定されたパラメータを確認してみます。

# result.params

ar.L1      -0.408674
ar.L2       0.041754
ar.L3      -0.208824
ar.L4      -0.333281
ma.L1      -0.179896
ma.L2      -0.817283
sigma2    853.547001
dtype: float64

モデルの適合度と仮定の検証

作成したモデルがデータにどれだけ適合しているかを、残差を使って確認します。

ARIMAモデルは、一定の仮定に基づいています。例えば、残差は平均ゼロ、定常性を持ち、自己相関を持たないという仮定があります。残差のプロットや統計テストを通じて、これらの仮定が満たされているかどうかを確認できます。

残差の正規性を見たいのでヒストグラムをプロットします。

# 残差
res = result.resid

# ヒストグラムをプロット
plt.hist(res, bins=16)

左に寄っています。正規分布に従うとは言えないでしょう。

続いて自己相関や偏自己相関を見て、周期性ないし季節性が無いことを確認します。

fig = plt.figure(figsize=(12,8))

# 自己相関
ax1 = fig.add_subplot(211)
fig = sm.graphics.tsa.plot_acf(res.values.squeeze(), lags=40, ax=ax1)

# 偏自己相関
ax2 = fig.add_subplot(212)
fig = sm.graphics.tsa.plot_pacf(res, lags=40, ax=ax2)

周期性が残ってしまっています。

季節性成分に対応する

モデルの検証を行った結果、残念ながらこれでは予測に使えないという結論になりました。

ARIMAモデルは、データの定常性や自己相関などの統計的な特性に基づいて設計されているため、周期性が残ってしまうとモデルがデータに適合しづらくなり、予測精度が低下する可能性があります。

特に、季節性が強いデータでは、ARIMAモデルだけでは十分な適合が難しいことがあります。

サンプルデータは、前項で行った変換処理を通じて季節性のあるデータであることはわかっていましたがその上で ARIMA モデルを作成したので当然の結果ではあります。

ちなみに参考までに。

この「使えない」モデルで予測を行うと以下のようになります。青い線が正解データで、赤い線が作成したモデルを使って予測したものです。

このように、周期性が残っているデータに対しては ARIMA モデルでは限界があるため、次に季節変動ありの ARIMA である SARIMA モデルを作成していきます。

SARIMAモデル

SARIMA(Seasonal ARIMA)モデルは、ARIMA モデルに季節性成分を追加したモデルです。季節性のあるデータに適用され、季節性成分をモデル化するためのパラメータを追加します。SARIMAモデルは、季節的な変動を捉えるため、季節性パターンを特定するのに有用です。

ARIMA と SARIMA は過去の値や誤差項を用いて現在の値を予測します。ARIMA と SARIMAは一般的に季節性のないデータに適用されますが、SARIMA は季節性を持つデータにも対応できます。

データの分割

データを学習用とテスト用に分けます。分ける範囲は ARIMA のときと同じです。

  • 学習用:1949-01 〜 1957-12-01
  • テスト用:1958-01 〜 1960-12

学習用データを使って SARIMA モデルを作成後、テスト用範囲を予測、結果をテスト用データと突合させて精度を確認する。という流れです。

今回も差分系列データを使っていきます。

# データを学習用とテスト用に分ける
df_train_diff = df['difference'][:'1957-12-01'].dropna()
df_test_diff = df['difference']['1958-01-01':]

SARIMAX モジュール

SARIMAX モデルは statsmodels の SARIMAX 関数で作成できます。

statsmodels.tsa.statespace.sarimax.SARIMAX

from statsmodels.tsa.statespace.sarimax import SARIMAX

次数の決定

SARIMA モデルの次数は、元のARIMAモデルの次数(p、d、q)と季節性成分の次数(P、D、Q、s)から構成されます。

  • P(季節自己回帰次数)
    • 季節性の自己回帰成分の次数です。季節性成分のパターンをモデル化します。
  • D(季節積分次数)
    • 季節性成分の積分次数です。季節性の差分を取る回数を示します。
  • Q(季節移動平均次数)
    • 季節性の移動平均成分の次数です。誤差項に季節性の過去の誤差値をどれだけ使用するかを示します。
  • s(季節周期)
    • 季節性の周期を示します。月次データであれば 12(1年の周期)、四半期データであれば 4(1年の四半期)などです。

これらの次数を見つけるために、総当りで SARIMA モデルの次数を探索し、AIC が最も低いモデルを見つけます。

(ちなみに総当りは手段の 1 つです。データの量が多い場合や計算時間が制約されている場合には他の方法も検討できます。)

処理が多いので関数として定義します。

def find_model_with_lowest_aic(df_train):
    """
    総当りで SARIMA モデルを作成し、最も低い AIC 値を持つモデルのパラメータと AIC を出力します。

    Parameters:
    df_train (DataFrame): テスト用データ

    Returns:
    string: 最も低い AIC 値を持つモデル

    """
    
    # 各パラメータの候補値リスト
    ## p, d, q
    p_list = [1, 2]
    d_list = [1]
    q_list = [1, 2]
    
    ## 季節項(P, D, Q)
    sp_list = [1, 2]
    sd_list = [1]
    sq_list = [1, 2]

    ## 何ヶ月か(s)
    m = 12

    parameter = []
    results   = []
    
    # 総当りで SARIMA モデルを作成
    for p in p_list:
        for d in d_list:
            for q in q_list:
                for sp in sp_list:
                    for sd in sd_list:
                        for sq in sq_list:
                            # パラメータを格納
                            parameter.append([p, d, q, sp, sd, sq])
                            # モデル作成
                            model = SARIMAX(df_train, order=(p, d, q), seasonal_order=(sp, sd, sq, m))
                            aic = model.fit(disp=0).aic
                            results.append({'parmeter': [p, d, q, sp, sd, sq], 'aic': aic})
                            print('parmeter', [p, d, q, sp, sd, sq], ', AIC=', aic)

    # 比較用: 最小 AIC 値の初期値を設定
    min_aic = float('inf')
    best_result = None

    # 最小 AIC 値を持つモデルを見つける
    for result in results:
        aic = result['aic']
        if aic < min_aic:
            min_aic = aic
            best_result = result
    
    print("最も低い AIC 値を持つモデル:", best_result)

各パラメータの候補値リスト(p_list, d_list, q_list, sp_list, sd_list, sq_list`)に定義されている数字は、それぞれ SARIMA モデルの次数に対する候補値です。これらの値を組み合わせてモデルを構築し、AIC を計算して最適なモデルの次数を決定します。

具体的な値はデータやドメイン知識に基づいて選択する必要がある。として、決め打ちです。とはいえ過学習のリスクや計算量コストを考えると、基本的には以下に沿うことになるかなと思っています。

  • p_list(自己回帰次数の候補値)
    • SARIMA モデルの自己回帰次数(AR次数)に関する候補値を指定
    • 通常、小さな整数値(1から3程度)を選択
  • d_list(積分次数の候補値)
    • SARIMA モデルの積分次数(差分の取る回数)に関する候補値を指定
    • データの定常性に応じて、0 または 1 を選択
  • q_list(移動平均次数の候補値)
    • SARIMA モデルの移動平均次数(MA次数)に関する候補値を指定
    • 通常、小さな整数値(1から3程度)を選択
  • sp_list(季節自己回帰次数の候補値)
    • 季節性の自己回帰次数(季節AR次数)に関する候補値を指定
    • 通常、小さな整数値(1から3程度)を選択
  • sd_list(季節積分次数の候補値)
    • 季節性の積分次数(季節差分の取る回数)に関する候補値を指定
    • データの季節性に応じて、0 または 1 を選択
  • sq_list(季節移動平均次数の候補値)
    • 季節性の移動平均次数(季節MA次数)に関する候補値を指定
    • 通常、小さな整数値(1から3程度)を選択

それではこの関数を使って次数を決定していきます。

# warning 多ければ抑制
# import warnings
# warnings.filterwarnings('ignore')

# 関数実行
find_model_with_lowest_aic(df_train_diff)

出力された結果は以下

parmeter [1, 1, 1, 1, 1, 1] , AIC= 706.6661313300135
parmeter [1, 1, 1, 1, 1, 2] , AIC= 700.7887100270527
parmeter [1, 1, 1, 2, 1, 1] , AIC= 702.2413978900132
parmeter [1, 1, 1, 2, 1, 2] , AIC= 701.9189059594827
parmeter [1, 1, 2, 1, 1, 1] , AIC= 707.7131926442661
parmeter [1, 1, 2, 1, 1, 2] , AIC= 702.5540894596875
parmeter [1, 1, 2, 2, 1, 1] , AIC= 703.8802139331958
parmeter [1, 1, 2, 2, 1, 2] , AIC= 703.5023730470815
parmeter [2, 1, 1, 1, 1, 1] , AIC= 708.2181279257733
parmeter [2, 1, 1, 1, 1, 2] , AIC= 702.7085048503061
parmeter [2, 1, 1, 2, 1, 1] , AIC= 704.100556663309
parmeter [2, 1, 1, 2, 1, 2] , AIC= 703.745258100657
parmeter [2, 1, 2, 1, 1, 1] , AIC= 709.7110131616942
parmeter [2, 1, 2, 1, 1, 2] , AIC= 700.8798954969151
parmeter [2, 1, 2, 2, 1, 1] , AIC= 702.3974872249754
parmeter [2, 1, 2, 2, 1, 2] , AIC= 702.2549954185089

最も低い AIC 値を持つモデル: {'parameter': [1, 1, 1, 1, 1, 2], 'aic': 700.7887100270527}

最も低いAIC値を持つモデルは、パラメータ p=1, d=1, q=1, P=1, D=1, Q=2 のものとなりました。 これらを当てはめて再度モデルを作成します。

# SARIMA モデル作成
#        SARIMAX(df_train_diff, order=(p,d,q), seasonal_order=(P,D,Q,m)).fit()
r_diff = SARIMAX(df_train_diff, order=(1,1,1), seasonal_order=(1,1,2,12)).fit()
# r_diff.summary()

SARIMAX Results

Dep. Variable:  difference  No. Observations:   107
Model:  SARIMAX(1, 1, 1)x(1, 1, [1, 2], 12) Log Likelihood  -347.466
Date:   Fri, 18 Aug 2023    AIC 706.931
Time:   08:31:16    BIC 722.191
Sample: 02-01-1949  HQIC    713.095
      - 12-01-1957      
Covariance Type:    opg     

          coef      std err z   P>|z|    [0.025  0.975]
ar.L1     -0.2249   0.087   -2.599  0.009   -0.395  -0.055
ma.L1     -0.9964   0.427   -2.332  0.020   -1.834  -0.159
ar.S.L12  0.5576    0.727   0.768   0.443   -0.866  1.982
ma.S.L12  -0.8143   0.772   -1.055  0.291   -2.327  0.698
ma.S.L24  0.2828    0.213   1.330   0.184   -0.134  0.700
sigma2  77.6646 32.494  2.390   0.017   13.977  141.352

Ljung-Box (L1) (Q): 0.04    
Jarque-Bera (JB):   3.46
Prob(Q):    0.85    
Prob(JB):   0.18
Heteroskedasticity (H): 1.36    
Skew:   0.44
Prob(H) (two-sided):    0.40    
Kurtosis:   2.69

モデルの適合度と仮定の検証

作成したモデルがデータにどれだけ適合しているかを、残差を使って確認します。

ARIMA のときはヒストグラムやコレログラムなどを個別に見ていきましたが、statsmodels の plot_diagnostics 関数を使うと複数の残差の診断プロットを一回で生成できます。

statsmodels.tsa.arima.model.ARIMAResults.plot_diagnostics

# 残差の診断プロット
r_diff.plot_diagnostics(lags=20);

plot_diagnostics 関数は、ARIMA および SARIMA モデルの残差の診断を行うために使用されます。生成される 4つのチャートは、モデルの残差が一定の基準を満たしているかどうかを確認するためのものです。

Standardized Residuals Plot (標準化残差プロット)
このプロットは、モデルの残差を標準化したものを時系列で表示します。残差がランダムにばらついていることが期待されます。もし残差にパターンやトレンドが見られる場合、モデルがデータに適合していない可能性があります。
Histogram Plus Estimated Density Plot (ヒストグラムと推定密度プロット)
このプロットは、残差のヒストグラムカーネル密度推定を表示します。残差が正規分布に近いかどうかを確認するのに役立ちます。理想的には、ヒストグラムと密度推定の形状が正規分布に近い形になることが望ましいです。
Normal Q-Q (Quantile-Quantile) Plot (正規Q-Qプロット)
このプロットは、残差の分位数を正規分布の分位数と比較したものです。正規分布に従う場合、プロットされた点は対角線に近い位置に分布します。プロットされた点が対角線から外れている場合、残差が正規分布から逸脱している可能性があります。
Correlogram (自己相関プロット)
このプロットは、残差の自己相関係数をタイムラグに対してプロットします。残差が白色雑音(無相関性)である場合、自己相関プロットはほとんどのラグでゼロに近くなるはずです。自己相関プロットにおけるラグがゼロでない値を示す場合、残差に時系列的なパターンが残っている可能性があります。

パターンやトレンドはなく、まあまあ正規分布に近く、QQ プロットはおおむね対角線に乗っている。コレログラムでも、周期性・季節性は見られない。ということで、おおそよ良いのではないかと思われます。

では、作成したモデルを使って実際に予測を行い、テスト用データと突合させてみます。

pred_diff = r_diff.predict('1958-01-01', '1960-12-01')

予測結果

1958-01-01    10.862560
1958-02-01   -12.957117
1958-03-01    51.532015
1958-04-01    -6.048657
1958-05-01     6.919694
1958-06-01    68.024899
1958-07-01    44.618030
1958-08-01    -2.338867
1958-09-01   -60.685910
1958-10-01   -57.188398
1958-11-01   -41.271487
1958-12-01    34.165328
1959-01-01    10.742535
1959-02-01   -13.216177
1959-03-01    53.727288
1959-04-01    -6.715341
1959-05-01     7.652743
1959-06-01    71.907824
1959-07-01    44.975607
1959-08-01    -0.446448
1959-09-01   -63.921007
1959-10-01   -59.853134
1959-11-01   -42.304585
1959-12-01    33.934304
1960-01-01    11.246330
1960-02-01   -13.367209
1960-03-01    55.074747
1960-04-01    -6.993050
1960-05-01     8.162161
1960-06-01    74.172291
1960-07-01    45.274499
1960-08-01     0.708273
1960-09-01   -65.625626
1960-10-01   -61.239695
1960-11-01   -42.781264
1960-12-01    33.904902

ここまで差分系列データで進めてきたため、結果も差分系列データのままです。実際の乗客数データに変換して戻します。

def inverse_difference(initial_value, diff_series):
    """
    差分系列から原系列に戻す

    Parameters:
    initial_value (int): 予測開始した月の前月の乗客数
    diff_series (int): 差分系列データ

    Returns:
    diff_series: 原系列データと同じ次元の予測データ

    """
    cum_sum = diff_series.cumsum()
    original_series = cum_sum + initial_value
    return original_series
# 1957-12-01 の乗客数
initial_value = df['Passengers']['1957-12-01']
# 差分系列から原系列に戻す
pred_original = inverse_difference(initial_value, pred_diff)

変換結果

1958-01-01    346.862560
1958-02-01    333.905443
1958-03-01    385.437459
1958-04-01    379.388802
1958-05-01    386.308495
1958-06-01    454.333395
1958-07-01    498.951424
1958-08-01    496.612558
1958-09-01    435.926648
1958-10-01    378.738250
1958-11-01    337.466764
1958-12-01    371.632091
1959-01-01    382.374626
1959-02-01    369.158449
1959-03-01    422.885737
1959-04-01    416.170397
1959-05-01    423.823140
1959-06-01    495.730964
1959-07-01    540.706571
1959-08-01    540.260123
1959-09-01    476.339117
1959-10-01    416.485983
1959-11-01    374.181398
1959-12-01    408.115702
1960-01-01    419.362032
1960-02-01    405.994823
1960-03-01    461.069570
1960-04-01    454.076520
1960-05-01    462.238681
1960-06-01    536.410972
1960-07-01    581.685471
1960-08-01    582.393744
1960-09-01    516.768118
1960-10-01    455.528423
1960-11-01    412.747158
1960-12-01    446.652061

予測した結果をプロットしてみます。

# 正解データと予測結果をプロット
plt.plot(df['Passengers'])
plt.plot(pred_original, "r")

青い線が正解データ。赤い線が実際に予測した部分です。

多少上ぶれている部分はあるものの、そこそこトレンドや季節性を掴めていることが確認できます。

精度検証

予測が出来たので、精度を検証してみます。

時系列データに使える精度指標としては以下になります。

  • RMSE
  • MAPE

RMSE(Root Mean Square Error, 二乗平均平方根誤差)

RMSE は、予測モデルの予測と実際の観測値との間での誤差を評価するための指標です。RMSE が小さいほど予測が実際のデータに近いことを示します。

 \displaystyle
RMSE = \sqrt{\frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2}

 n はデータポイントの数、 y_i は実際の値、 \hat{y}_i は予測値です。

scikit-learn の mean_squared_error 関数を利用します。

sklearn.metrics.mean_squared_error

from sklearn.metrics import mean_squared_error

test_original = df['Passengers']['1958-01-01':]

np.sqrt(mean_squared_error(test_original, pred_original))

# => RMSE: 21.32544528858937

RMSE は 21.3 でした。これは、予測値と実際の値との平均誤差がおおよそ 21.3 であることを意味します。

300 〜 600 くらいの予測なのでこの平均誤差が多いか少ないかは要件によるところもありますが、まだ改善はできそうです。これが一桁の平均誤差ならかなり精度の高い予測ができていると言えそうです。

MAPE(Mean Absolute Percentage Error, 平均絶対パーセント誤差)

MAPE は、予測モデルの予測値と実際の観測値との間での誤差を評価するための指標です。予測の正確さをパーセントで示します。MAPEの値が小さいほど予測が正確であることを示し、大きいほど予測の誤差が大きいことを示します。

 \displaystyle
MAPE = \frac{1}{n} \sum_{i=1}^{n} \left| \frac{y_i - \hat{y}_i}{y_i} \right| \times 100

 n はデータポイントの数、 y_i は実際の値、 \hat{y}_i は予測値です。

np.mean(np.abs((pred_original - test_original) / test_original))

# => MAPE: 0.045705066956362256

MAPE 0.0457 でした。これは予測モデルの予測が実際の観測値と比べて、平均して約 4.57 %の誤差があることを示しています。つまり、予測値が実際の値から約 4.57 %程度離れていることが平均的な誤差として示されています。

値としては小さいため、モデルの予測が相対的に実際の観測値に近いと判断できそうです。

モデルの改善

ここまでで、「データの確認・理解」「定常データへの変換」「モデル構築」と一連の時系列分析の流れを実施しました。

あとは作成したモデルの精度を上げてく工程がありますが、本記事ではここまでとします。

「モデルのチューニング・パラメータ調整」「データの変換」など、アプローチは色々とあるため、次の機会に実施したいと思います。

トレンドや季節性成分の抽出

最後に、原系列データからトレンドや季節性成分を抽出してみます。

python の Pmdarima というパッケージを利用します。

alkaline-ml/pmdarima

Pmdarima(Pyramid ARIMA)は、Pythonで時系列データの予測モデリングを行うためのツールキットです。 ARIMA, SARIMA モデルのパラメーター推定と予測モデリングのプロセスを簡素化し、ユーザーが容易に時系列データを分析できるように支援してくれます。

Pmdarima パッケージを利用すると、モデル作成のためのパラメータ(次数)を自動で選択してくれたり、モデルの適合度を評価・診断プロットを生成する機能があったりと、今回実施した手順を簡略化してモデル構築が行えたりします。

Pmdarima パッケージを利用したモデル構築は次の機会に試すとして、ここではトレンドや季節性成分の抽出を行ってみます。

# データセット読み込み
column_names = ['Month', 'Passengers']
df=pd.read_csv('AirPassengers.csv', index_col='Month', parse_dates=True, names=column_names, header=None, skiprows=1)
# df.head()
Month   Passengers
1949-01-01  112
1949-02-01  118
1949-03-01  132
1949-04-01  129
1949-05-01  121

この原系列データからトレンドや季節性成分を抽出します。

decompose 関数で成分の分解を行い、decomposed_plot 関数でそれぞれの結果をプロットします。

from pmdarima import utils
from pmdarima import arima

data = df['Passengers'].values

utils.decomposed_plot(
    arima.decompose(data,'additive',m=12), figure_kwargs = {'figsize': (16, 12)} 
)

  • data
    • 元のデータ
  • trend
    • トレンド
  • seasonal
    • 季節性成分
  • random
    • 残差成分
    • 残差成分はトレンドと季節性を除いた残りの部分を表現しています。通常、残差はランダムであるべきで、特定のパターンや構造がないことが望まれます。

トレンドと季節性成分を抽出できました。

Pmdarima パッケージも便利そうなので次の機会に深掘ってみたいと思います。


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

エンドユーザー向けプロダクトの構築とマイクロサービス化

この記事は個人ブログと同じ内容です

エンドユーザー向けプロダクトの構築とマイクロサービス化

こんにちは、株式会社ROXX で back check とうサービスを開発しているぐっきーです。 今回は back check で新しく toC 向けのプロダクトを新規リポジトリとして構築したので、その概要を紹介します。 なおこの記事では新規コードベースの立ち上げ、アーキテクチャについて説明しますが、サービスリリース自体はまだであるため、あくまで現状の進捗共有ということで解説していきす。

アーキテクチャの選択と背景

マイクロサービス

モノリスのコードベースで運用していたときの問題点として、既存のコードベースに負債もありつつ、プロセスの中でボトルネックとして話題に上がっていた問題として同じコードベースを複数チームで開発することに対するチーム間のコミュニケーションコストがありました。 これらを考慮して新規のプロダクトを立ち上げるタイミングで、責務の違う領域として新しくリポジトリを立てることとしました。 また開発者目線でも新しい技術やアーキテクチャを取り入れる機会ということで興味があったので純粋な興味という部分もありました。実際に現時点でできあがったプロダクトではメンバーの得意な技術やレイヤーを整理したアーキテクチャを取り入れたことで総合的に開発者体験は上がったと感じています。

技術スタック

技術選定

DynamoDB

DynamoDB を採用した意図としては、主に以下になります。 - RDS の料金と比較してデータの読み書きの量に応じた価格設定のため、 back check のサービスの性質上、大量アクセスを捌くようなビジネスドメインではないため値段が安く抑えられる。 - RDS だと MySQL のバージョンアップなどに伴うダウンタイムやメンテナンスコストが発生するが Dynamo DB ではそれが不要になる。 - Dynamo DB で Single Table Design を採用すると join によるテーブルを跨いだデータアクセスが不要になり早いらしい。

そもそものきっかけとしては Tech Lead が Dynamo DB の採用を提案してくれましたが、チーム自体に知見はない状態だったので不安はありました。しかし back check の開発組織としてもキャッチアップにコストをかけることが許容してもらっていたため学習を前提としつつも採用することができました。

BFF

プロダクト自体が toC 向けということもあり、 back check 上で行うリファレンスチェックの候補者、推薦者フローを今後移植してくることを想定していたため、認証サービスを共通で使えるようにするということを一番の目的として BFF を採用しました。 また front と bff 間の通信を GraphQL を採用し、スキーマから型生成させることで型安全にアクセスできるようにしました。

Lambda

BFF, 各種 backend はそれぞれ個別のサービスとして lambda 上で動かしています。

Lambda で動かすことで各サービスの実行環境の運用をマネージドサービスに委譲しつつ、Dynamo DB ストリームによるイベント処理や、SQS, SNS を使ったキューイング、メッセージングをフックに連鎖的に処理を実行させることでサービス間の連携を行っています。

共通の DI コンテナ

各サービスの初期化時に DI コンテナを一通り初期化させ読み込むような実装となっています。 サービス毎に実行環境は分かれつつ、モジュラーモノリスのようにモノリポ全体のレイヤーを domain, repository(DynamoDB, backcheck_api など個別に用意している) とまとめて?管理しており、それぞれの bind を共通の DI コンテナを使って行っているため、全体の構造把握がしやすいのがメリットです。

※ ちなみにコードベース内のレイヤーの設計は厳密に DDD を採用しているわけではありません。どちらかというと DDD の設計パターンを参考にしているといった温度感で設計しています。

また全体的に抽象(インターフェース)に依存させる設計となっているため、依存関係逆転の法則でよく言われるテストのしやすさや、再利用性の向上もありつつ、テスト駆動開発のようにスコープを絞った開発ができることから、小さいスコープで着実に開発できることも嬉しいポイントです。

Dynamo DB の設計

Dynamo DB の特性を活かせるようにという意図で、Single Table Design を採用しました。 Single Table Design では join を使ってリレーションを表現するようなことができないため、設計方法のキャッチアップに苦労しました。

私たちがとった設計の流れとしては、まず RDS のように ER 図を起こしやりたいことを可視化し、そこから管理したい各データへのアクセスパターンを洗い出します。アクセスパターンを元にどのデータをまとめて持たせるとよいかを設計し、Table の Entity を起こしていくといった方法で進めていきました。

このとき RDB の考え方と大きく違う部分として、Single Table Design では参照したいデータをマスターデータからコピーして Entity の Item に格納するように設計します。こうすることによって、アクセスパターン毎に join してテーブルを跨いだデータアクセスをする必要がなく、データ取得までの速度が速くなります。

実際に設計してみた所感として、設計の考え方の違いからとっつきづらさはあったものの、気軽に参照用のデータを捨てられる点など RDB の基本的な設計では得られないメリットにより、変更がしやすくなったように思います。

Dynamo DB の設計について詳しく知りたい方は「The DynamoDB Book」という書籍が実例を添えて詳しく説明してくれているのでおすすめです。

サービス間連携

データ連係の部分は SQS を用いたキューイングをトリガーに Lambda を起動し、 backcheck_api で内部的に公開しているエンドポイントに直接 fetch する方法で実装しています。(今回のケースでは backcheck_api でマスターとなるデータを持っており、新プロダクト側で複製したデータを保存し、加工して View で表示させています)

サービスが独立して稼働できるように担保するための設計を意識しましたが、データ連携周りは DeadLetterQueue から復帰させるケースを考慮したりと単体のアプリケーション内では考える機会の少なかった部分まで考慮する必要があり、設計に苦労しました。

また、チーム間のデータ連携が必要な部分に関して、大きな部分は backcheck_api 側を管理するチームのリーダーと弊チームのリーダーで調整を行ってもらうことで解決しました。背景として、 backcheck_api 側の実装タスクを起こして依頼するフローとしていたのですが、オーバーオールリファインメントの場などでチケットの説明が必要であり、この場に毎回出席してくれているチームリーダーに調整役となってもらうこととしました。

チーム間の連携が必要な部分に関してはこれからもでてくると思いますが、今後の展開として調整作業のバス係数が2人以上になるような属人化を省く仕組みを考えていけたらいいなと個人的には思っています。

テスト設計

テスト設計についてはそれぞれユニットテストで担保しつつ、まだアプリケーション全体を通したテスト設計までは詰められておりません。 現状はユニットテストで補えない箇所は手作業による統合テストとモンキーテストによって行っています。 今後各サービス間の連携部分の結合を網羅するテストを全部手動で運用していくことはつらいため、この辺は E2E テストを採用する話が上がっている状況であり、技術選定中です。

front については Jest と testing-library/react を用いて各 Hooks と UI などのテストを実装しています。 また UI が定まってきたら Chromatic によるビジュアルリグレッションテストを導入予定です。

ログ戦略

ロギングの詳細な設計についてはまだ追いついていない状況です。 現状はサービス間の受け渡しなど、処理のつなぎ目となるところでログを仕込んでおり、datadog に流して管理しています。

余談ですが、AWSXRay によって、処理がどこまで到達したか。各セクションでどの程度実行速度が掛かったか。が可視化されているため非常に便利です。

おわりに

さて、以上が大まかな back check の toC 向けプロダクトの概要説明でした。まだまだ一般公開しておらず、よく言われるマイクロサービスの運用においてのつらみについてはまだ充分に学習できていませんが、今後も柔軟に対応しつつ開発組織として知見を溜めてどんどん展開していけたらと思っております。 また、今回の内容に含められなかった部分についても今後どしどし紹介していきたいと思います。 最期になりますが、back check ではモダンなアーキテクチャや、組織開発、HR Tech 領域に興味のある方を絶賛募集しております。もし上記の内容にご興味を持っていただけたら、お気軽にカジュアル面談ご依頼ください。

back check のカジュアル面談の窓口が見つからなかったのでお隣の agent bank 事業部の求人を貼っておきますw

herp.careers

また個人的に話を聞いてみたいなども大歓迎ですので、お気軽に DM いただければと思います。

twitter.com

AWS SAM でローカルに閉じたサーバレスアプリケーション開発環境を構築する

この記事は個人ブログと同じ内容です

www.ritolab.com


AWS SAM を使って、ローカルに閉じた状態でのサーバレスアプリケーション開発環境を構築してみます。

AWS Serverless Application Model (SAM)

AWS SAM は、サーバーレスアプリケーションの開発とデプロイメントをシンプルに行うためのフレームワークです。

SAM は AWS CloudFormation の拡張であり、CloudFormation テンプレートにサーバーレスアプリケーションを定義することができます。

SAM は Serverless Application Model の略称です。

ローカルでのサーバレス開発環境

AWS でサーバレスアプリケーション開発を行う際に、開発時はローカルで完結させデバッグトライアンドエラーを素早く回せた方が開発効率が良いと思います。

そこで、AWS SAM と docker を使ってローカルに閉じた環境で開発ができるようにしてみます。

主に扱う AWS リソースは以下です。

また、PC は Mac での動作確認です。

AWS SAM CLI の導入

SAM ベースのアプリケーション開発環境を構築するため、AWS SAM CLI を導入します。

AWS SAM CLI のインストール

(上記リンクのページでは、Mac 以外にも LinuxWindows でのインストール手順も記載があります)

Homebrew で AWS SAM CLI をインストールします。

# sam cli インストール
brew install aws/tap/aws-sam-cli

# インストール確認
sam --version
##  SAM CLI, version 1.91.0

ベースアプリケーション作成

ローカルで API Gateway + Lambda を用いたアプリケーションを作成していきます。

AWS から公開されているチュートリアルを参考にすると分かりやすいです。

Tutorial: Deploying a Hello World application

ここでは SAM CLI を使ってベースとなるアプリケーションを作成し、そこから DynamoDB も繋げていこうと思います。

まずはアプリケーションを作成(初期化, ベース構築)するため以下のコマンドをプロジェクトルートで実行します。

sam init

sam init コマンドは新しいサーバーレスアプリケーションプロジェクトを作成するためのコマンドです。

以下のように対話型で各項目を選択していくことで、ベースとなるアプリケーションや CloudFormation の template(厳密には CloudFormation を拡張した sam 用の template)を作成してくれます。

% sam init

You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.

Which template source would you like to use?
    1 - AWS Quick Start Templates
    2 - Custom Template Location
Choice: 1

Choose an AWS Quick Start application template
    1 - Hello World Example
    2 - Data processing
    3 - Hello World Example with Powertools for AWS Lambda
    4 - Multi-step workflow
    5 - Scheduled task
    6 - Standalone function
    7 - Serverless API
    8 - Infrastructure event management
    9 - Lambda Response Streaming
    10 - Serverless Connector Hello World Example
    11 - Multi-step workflow with Connectors
    12 - Full Stack
    13 - Lambda EFS example
    14 - DynamoDB Example
    15 - Machine Learning
Template: 1

Use the most popular runtime and package type? (Python and zip) [y/N]: N

Which runtime would you like to use?
    1 - aot.dotnet7 (provided.al2)
    2 - dotnet6
    3 - go1.x
    4 - go (provided.al2)
    5 - graalvm.java11 (provided.al2)
    6 - graalvm.java17 (provided.al2)
    7 - java17
    8 - java11
    9 - java8.al2
    10 - java8
    11 - nodejs18.x
    12 - nodejs16.x
    13 - nodejs14.x
    14 - nodejs12.x
    15 - python3.9
    16 - python3.8
    17 - python3.7
    18 - python3.10
    19 - ruby3.2
    20 - ruby2.7
    21 - rust (provided.al2)
Runtime: 11

What package type would you like to use?
    1 - Zip
    2 - Image
Package type: 1

Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.

Select your starter template
    1 - Hello World Example
    2 - Hello World Example TypeScript
Template: 2

Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: N

Would you like to enable monitoring using CloudWatch Application Insights?
For more info, please view https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.html [y/N]: N

Project name [sam-app]: hello-world-app

    -----------------------
    Generating application:
    -----------------------
    Name: hello-world-app
    Runtime: nodejs18.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world-typescript
    Output Directory: .
    Configuration file: hello-world-app/samconfig.toml

    Next steps can be found in the README file at hello-world-app/README.md


Commands you can use next
=========================
[*] Create pipeline: cd hello-world-app && sam pipeline init --bootstrap
[*] Validate SAM template: cd hello-world-app && sam validate
[*] Test Function in the Cloud: cd hello-world-app && sam sync --stack-name {stack-name} --watch

アプリケーションの初期化(アプリケーションのベース作成)が完了すると、以下のようなディレクトリとファイルが作成されます。

project_root/
└── hello-world-app
    ├── README.md
    ├── events
    │   └── event.json
    ├── hello-world
    │   ├── app.ts
    │   ├── jest.config.ts
    │   ├── package.json
    │   ├── tests
    │   │   └── unit
    │   │       └── test-handler.test.ts
    │   └── tsconfig.json
    ├── samconfig.toml
    └── template.yaml

Lambda function のコードは hello-world/app.ts です。

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 *
 */

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'hello world',
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }
};

ここまでで、ベースとなるアプリケーションを作成できました。

Lambda 動作確認

Lambda をローカルで実行し動作を確認します。

sam build コマンドを hello-world-app/ 配下で実行します。

sam build コマンドによって依存関係(外部ライブラリ)の解決とコードのビルドが行われ、ローカル環境でサーバーレスアプリケーションをテストする準備が整います。

% sam build
Starting Build use cache
Manifest is not changed for (HelloWorldFunction), running incremental build
Building codeuri: /path/to/project_root/hello-world-app/hello-world runtime: nodejs18.x metadata: {'BuildMethod': 'esbuild',
'BuildProperties': {'Minify': True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions:
HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:LinkSource
Running NodejsNpmEsbuildBuilder:EsbuildBundle

Sourcemap set without --enable-source-maps, adding --enable-source-maps to function HelloWorldFunction NODE_OPTIONS

You are using source maps, note that this comes with a performance hit! Set Sourcemap to false and remove NODE_OPTIONS: --enable-source-maps to disable
source maps.


Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided

ビルドが終わったらコマンド sam local invoke を実行し Lambda を走らせます。

sam local invoke

実行結果は以下です。

% sam local invoke
Invoking app.lambdaHandler (nodejs18.x)
Local image is up-to-date
Using local image: public.ecr.aws/lambda/nodejs:18-rapid-x86_64.

Mounting /path/to/project_root/hello-world-app/.aws-sam/build/HelloWorldFunction as /var/task:ro,delegated, inside runtime container
START RequestId: 418f2531-6b7e-4fe9-ae3c-bd0d7057e5dc Version: $LATEST
END RequestId: 418f2531-6b7e-4fe9-ae3c-bd0d7057e5dc
REPORT RequestId: 418f2531-6b7e-4fe9-ae3c-bd0d7057e5dc  Init Duration: 0.80 ms  Duration: 955.80 ms Billed Duration: 956 ms Memory Size: 128 MB Max Memory Used: 128 MB
{"statusCode":200,"body":"{\"message\":\"hello world\"}"}

Lambda 関数がローカルで実行できたことを確認できました。

API Gateway エンドポイントからの動作確認

続いて、API Gateway で作成されるエンドポイントを使って Lambda の実行を確認します。

API Gateway の設定は hello-world-app/template.yaml に記載があります。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      ### Lambda に API Gateway トリガーを追加 ###
      Events: 
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
      ### [GET] https://xxxxxx/hello ###

[GET] /hello というエンドポイントになっていることが確認できます。

api を動作させるため、以下の sam コマンドを実行します。

sam local start-api

コンテナが起動しエンドポイントがマウントされます。

% sam local start-api
Initializing the lambda functions containers.
Local image is up-to-date
Using local image: public.ecr.aws/lambda/nodejs:18-rapid-x86_64.

Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]

先程 template.yaml で確認したものと同じ [GET] http://127.0.0.1:3000/hello にリクエストができるようになりました。

リクエストしてみます。

curl http://127.0.0.1:3000/hello

## => {"message":"hello world"}

API へリクエストし、レスポンスが返されたことを確認できました。

API Gateway をシュミレートした動作確認もローカルで行えました。

実際にこの template.yaml は CloudFormation の設定を sam 用に拡張したものになっているため、ここでの動作確認が行えた場合は実際に AWS 上にリソースを作成する場合も連携面や実装ロジックに関しては問題なく動作するであろう。ということが言えます。(これらは最後に、実際に AWS cloud 上にデプロイして、実際の AWS リソース上でも動作確認を行ってみます)

DynamoDB を絡めたローカル動作確認

ローカルに閉じた状態で DynamoDB まで含めて動作確認を行う場合は、別途 DynamoDB のコンテナを作成する必要があります。

それには AWS から提供されている dynamodb-local が便利です。

プロジェクトルート直下に docker-compose.yml を作成し、DynamoDB local を定義します。

また、今回は動作確認のため dynamodb-admin のコンテナも一緒に作成しています。これは、DynamoDB に収録されているデータを GUI 上で確認するためのものです。

aaronshaf/dynamodb-admin

ちなみに JetBrains の IDE を使っている場合は DynamoDB の database connection を実現できるプラグイン が用意されていますが、有料のため、今回はコンテナで用意しています。

version: '3.8'
services:
  dynamodb-local:
    image: "amazon/dynamodb-local:latest"
    container_name: dynamodb-local
    command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
    ports:
      - "8000:8000"
    volumes:
      - "./docker/dynamodb:/home/dynamodblocal/data"
    working_dir: /home/dynamodblocal
    networks:
      - default

  dynamodb-admin:
    image: aaronshaf/dynamodb-admin:latest
    container_name: dynamodb-admin
    environment:
      - DYNAMO_ENDPOINT=dynamodb-local:8000
    ports:
      - "8001:8001"
    depends_on:
      - dynamodb-local
    networks:
      - default

networks:
  default:
    name: dynamodb-local-network

定義したら docker compose up でコンテナを起動させます。

コンテナが起動したらブラウザから localhost:8001 にアクセスすると dynamodb-admin の画面にアクセスできます。

Scan, Query はもちろん、テーブル作成・削除などもできるので入れておくと便利です。

DynamoDB のローカル環境を構築したので、アプリケーションと繋げます。

環境変数

環境変数を読み込めるようにします。アクセスする DynamoDB をローカルの DynamoDB コンテナへ向けるためです。

project_root/local_env_vars.json

{
  "HelloWorldFunction": {
    "DDB_ENDPOINT": "http://dynamodb-local:8000"
  }
}

hello-world-app/template.yaml

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Policies:
        - AmazonDynamoDBFullAccess # 動作確認用リソースのため FullAccess にしていますが適宜適切なポリシーを指定します
      ### 追加ここから ###
      Environment:
        Variables:
          DDB_ENDPOINT: ''
      ### 追加ここまで ###

DDB_ENDPOINT の値を空にしているのは、外から値を指定するためです。

ローカルでの実行時に local_env_vars.json からの値を読み込んで DDB_ENDPOINT の値を上書きします。

(実際は DynamoDB のエンドポイントを指定したいのはローカルのみで、cloud 上の DynamoDB であれば指定は不要のため、template.yaml に Environment を指定せずとも環境変数を指定できれば一番良いと思います。)

Lambda function

hello-world/app.ts で DynamoDB に put するコードを記述します。

DynamoDB my-table に、 id と timestamp を書き込む簡単なものです。

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'
import crypto from 'crypto'

const client = new DynamoDBClient({
    region: 'ap-northeast-1',
    endpoint: process.env.DDB_ENDPOINT !== '' ? process.env.DDB_ENDPOINT : undefined,
})

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        const id = crypto.randomUUID()
        const timestamp = Date.now().toString()

        const input = {
            TableName: 'my-table',
            Item: {
                id: {
                    S: id,
                },
                timestamp: {
                    S: timestamp,
                },
            },
        }

        const command = new PutItemCommand(input)
        const response = await client.send(command)

        return {
            statusCode: 200,
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                message: 'success',
            }),
        }
    } catch (err) {
        console.error(err)
        return {
            statusCode: 500,
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                message: 'some error happened',
            }),
        }
    }
}

(もはや hello world の文脈は完全に消えましたが動作確認したいのでこのままいきます)

template

hello-world-app/template.yaml に、DynamoDB リソースを追加します。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Policies:
        - AmazonDynamoDBFullAccess
      Environment:
        Variables:
          DDB_ENDPOINT: ''
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: "es2020"
        Sourcemap: true
        EntryPoints:
          - app.ts
  ### 追加ここから ###
  DynamoDBTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      TableName: my-table
      PrimaryKey:
        Name: timestamp
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 1
        WriteCapacityUnits: 1
  ### 追加ここまで ###

ビルド & 動作確認

実装が済んだのでビルドして Lambda function を実行してみます。

# ビルド
sam build

# lambda 関数 を実行
sam local invoke --docker-network dynamodb-local-network --env-vars local_env_vars.json
  • --docker-network dynamodb-local-network
    • オプションでローカルの DynamoDB コンテナネットワークを指定しています。これによって Lambda のコンテナと DynamoDB のコンテナ間の通信を可能にしています。
  • --env-vars local_env_vars.json
    • ローカルの DynamoDB にリクエストを送信するようにエンドポイントを記述した環境変数ファイルを読み込んでいます。

dynamodb-admin から、ローカルの DynamoDB に値が insert されたか確認してみます。

insert されました。

API Gateway シュミレートで試す場合も同様にオプションを指定すれば動作します。

# ローカル環境で API Gateway と Lambda 関数を起動する
sam local start-api --docker-network dynamodb-local-network --env-vars local_env_vars.json

# エンドポイントにリクエスト
curl http://127.0.0.1:3000/hello

ここままで、ローカルに閉じた状態で、そして AWS リソースを cloud 上に作成することなく、API Gateway と Lambda function, そして DynamoDB を使った実装と動作確認までを行うことができました。

AWS へデプロイし動作確認

これまで構築したものを実際に AWS cloud 上でも構築して動作確認を行ってみます。

まずは sam build コマンドを実行しビルドします。.aws-sam ディレクトリが作成され、そこにアプリケーションの依存関係とファイルがデプロイ用に作成されます。

 % sam build
Starting Build use cache
Manifest is not changed for (HelloWorldFunction), running incremental build
Building codeuri: /path/to/project_root/hello-world-app/hello-world runtime: nodejs18.x metadata: {'BuildMethod': 'esbuild', 'BuildProperties': {'Minify':
True, 'Target': 'es2020', 'Sourcemap': True, 'EntryPoints': ['app.ts']}} architecture: x86_64 functions: HelloWorldFunction
Running NodejsNpmEsbuildBuilder:CopySource
Running NodejsNpmEsbuildBuilder:LinkSource
Running NodejsNpmEsbuildBuilder:EsbuildBundle

Sourcemap set without --enable-source-maps, adding --enable-source-maps to function HelloWorldFunction NODE_OPTIONS

You are using source maps, note that this comes with a performance hit! Set Sourcemap to false and remove NODE_OPTIONS: --enable-source-maps to disable source maps.

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided

次に、sam deploy --guided コマンドを使用してアプリケーションをデプロイします

--guided オプションを使用すると、デプロイに関する設定を対話的に進めていくことができます。

% sam deploy --guided

Configuring SAM deploy
======================

    Looking for config file [samconfig.toml] :  Found
    Reading default arguments  :  Success

    Setting default arguments for 'sam deploy'
    =========================================
    Stack Name [hello-world-app]:
    AWS Region [ap-northeast-1]:
    #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
    Confirm changes before deploy [Y/n]:
    #SAM needs permission to be able to create roles to connect to the resources in your template
    Allow SAM CLI IAM role creation [Y/n]:
    #Preserves the state of previously provisioned resources when an operation fails
    Disable rollback [y/N]:
    HelloWorldFunction has no authentication. Is this okay? [y/N]: y
    Save arguments to configuration file [Y/n]:
    SAM configuration file [samconfig.toml]:
    SAM configuration environment [default]:

.
.
(略)
.
.

Successfully created/updated stack - hello-world-app in ap-northeast-1

デプロイが完了すると、AWS 上に各リソースが作成されたことが確認できました。

実際にエンドポイントにリクエストしてみると、DynamoDB へ値が保存されることも確認できます。

AWS SAM を使って開発したアプリケーションが cloud 上でも動作することを確認できました。

AWS 上に作成したリソースを削除

デプロイを行って AWS 上に作成したものを削除します。

sam delete コマンドを実行すると、先程の sam deploy コマンドで cloud 上に作成されたリソースを削除できます。

% sam delete
    Are you sure you want to delete the stack hello-world-app in the region ap-northeast-1 ? [y/N]: y
    Are you sure you want to delete the folder hello-world-app in S3 which contains the artifacts? [y/N]: y
        - Deleting S3 object with key hello-world-app/34de905deffe7387dd11e9e1537e199
        - Deleting S3 object with key hello-world-app/d395b65c7264033b843198cf68b6e9b7.template
    - Deleting Cloudformation stack hello-world-app

Deleted successfully

AWS 上に作成したリソースが綺麗に削除されました。コマンド 1 つで関連リソース全て落とせるので、不要な課金も生まなくて安心です。

あとがき

サーバレスアプリケーションの開発をローカルに閉じた状態で進めていけるのはとても便利でした。

AWS SAM では他にも CI/CD デプロイパイプラインを設定したりもできるらしいので次の機会にやってみたいところ。

  1. ローカルで開発・動作確認
  2. sam deploy で cloud 上の dev 環境にリソース作成・更新して動作確認
  3. CI/CD プロセスにて stg 環境や prod 環境へデプロイ

こんな具合で開発していけたらスムーズだなと感じました。

実際にやってみると、Lambda function の実装部分で、環境変数まわりが原因でローカルでは動作したが cloud 上で動作しなかったこともあったので、実装段階で cloud 上でもどんどん試せるような仕組みがあると良いと思いました。

その上で、意図せず stg や prod に変更がかからないようにこれらの環境への反映は sam コマンドではなく別のプロセスを経てデプロイしていく。

こんな開発フローだと心理的安全性も高まりそうです。

2023 年 7 月時点ではプレビューリリースですが、terraform とも連携できるらしい(Terraform プロジェクトで AWS SAM CLI を使う)

AWS SAM CLI Terraform のサポート


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers https://herp.careers/v1/scouter/klIFYKELaF8Yherp.careers herp.careers

開発チームマネジメントについて 202306

この記事は 個人Qiita と同じ内容です

qiita.com/sekiyaeiji

開発チームをどのように運営すればよいか

開発組織を直接的に任される職能名、役割名は各企業の規模やフェーズによりさまざまだと思いますが、EM(エンジニアリングマネージャー)やPO(プロダクトオーナー)、TL(テクリード)やチームリーダー、SM(スクラムマスター)、開発責任者をはじめ、CTOやVPoEも状況に応じて、開発チームを直接的に任される機会はあるでしょう。

開発チームは組織された後、どのように運営されるべきなのでしょうか。

理想のチーム

チーム運営について考察するにあたって、チームが目指すべき状態、理想のチーム像について明らかにしておきます。

タックマンモデル

タックマンモデルとは、ブルース・タックマン(Bruce Wayne Tuckman、 1938–2016、心理学者)が組織の成長の段階を示したもので、組織は以下の4つの段階を経ることによって成長できる理想の組織になるというモデルです。

  1. フォーミング期 (Forming、形成期)
    • 様子見、表面的な関係
    • 否定が少ない
    • とりあえずやってみる
  2. ストーミング期 (Storming、混乱期)
    • ジレンマ、否定的な感情
    • 意見や価値の対立、議論
  3. ノーミング期 (Norming、統一期)
    • ジレンマの解消、規律の明確化、役割や手順の確立、共通理解、共通言語
    • 信頼感が増す
    • チームとして機能し始める
  4. パフォーミング期 (Performing、機能期)
    • チームへの自信
    • 臨機応変、柔軟対応、次の課題へ

チームは大小さまざまのタックマンモデルの機会を繰り返し得ています。
メンバーの増減や組織の方針変更、体制変更、組織目標の変更、プロダクトロードマップの変更、短期間なものではプロジェクトストーリーの1つ1つや1イテレーション、1スプリントに対しても、タックマンモデルの機会が発生します。

ここでマネジメントの採るべきスタンスは、タックマンモデルのプロセスを上手に活用することによりチーム成長の機会を最大化することです。

タックマンモデルでもっとも重要なステップは「ストーミング期」です。メンバー同士の価値観の衝突によりコンフリクトが発生します。これをチーム成長の機会と捉え、メンバー同士のコミュニケーションによる納得解の創造へと導くことが大切です。このときに必要なのは、メンバー同士が尊重し合うことと、前向きなディスカッションを心がけること、心の安全が担保されていることです。そのコンフリクトの中で、安易な妥協を選択せず、対立を超越した止揚アウフヘーベン、aufheben)による高次元の解を目指すことが、ストーミング期の目標になります。

ストーミング期で発散、収束を経た解を模範解答として、成功・失敗を繰り返した試行錯誤の上でメンバー個々に暗黙知を育てるのがノーミング期です。失敗を含めトライを称賛する風土が必要になります。

パフォーミング期では、個々の暗黙知をチーム共通の認識である形式知に変換し、発散したチームを創造的に再結合することによって、メンバー同士の1+1が3や4になっていることを目指します。安易な仲良し集団ではない真のチームワークの醸成が目標になります。

一方で、形式知は創出されると直ちに陳腐化しますので、パフォーミング期が形成されたときが、新たなフォーミング期へ向かうべきタイミングになります。

現在の不確実性が高く正解のない環境においては、改善のサイクルを継続的に実行することによってチームは成長することができます。これはつまり、停滞することはチームの退化と同義であるということです。

チーム改善の本当の解

現在のマネジメントの文脈で必ず言及される「傾聴」において感じることの一つに、対話のなかで加工コストが高い、あるいは採用するのが困難なアイデア、というものが一定数あります。

  • WILLやゴールが存在しないアイデア
  • HOWを決め打ちしたアイデア
  • 見聴きしたまま受け売りのアイデア
  • 幻影に惑わされたアイデア
  • 他人や外部要因にフォーカスしたアイデア

これら一つ一つへの詳細な言及はここでは割愛しますが、これらに共通して言えることは、いまのチームの現状の生の問題点から自力、あるいは自チームメンバーの力で考え抜いて導き出した課題になっていない、ということです。過去のどこかの価値観やバイアスを通して、それがいまのチームの問題であるかのように感じて持ってきてしまった課題であると感じることが少なくありません。

チームの継続的な改善においては、チームの現在の本質的な問題点に言及し、そこからチーム全員で改善案を導き出し解決に取り組むことによってストーミング期の効果を最大限に活かすことができます。過去や他者の成功事例や価値観は参考にしつつも、自チームの問題は自チームのメンバーにしか深掘り、解決することはできません。

リーン開発、アジャイル開発系の書籍において、解決方法・解決事例に言及しているが解は与えてくれないものがありますが、私はそういう書籍は信頼に足ると思っています。なぜなら、解はチームごとに異なるからです。

チーム運営の実践

笑いの重要性

私がもっとも大切にしている価値観があります。

  • 明るくほがらかに
  • 楽しむ気持ちとユーモアを大切にできる
  • 常にポジティブ

表現はさまざまですが、チームについて論じている情報に多く言及されている、チームコミュニケーションの基本であり極意と感じます。ほがらかな笑いが絶えないチームはさまざまな局面でキャパシティが大きく耐性が高いと感じます。オンラインの音声だけのコミュニケーションが増えた昨今ではなおさらです。先述の通り、メンバー同士のコンフリクトを意識的に創り出す必要がある現状において、常にポジティブであるということはもっとも価値がある行為であり、意識的に実践することが必要です。

メンバー成長への向き合い方

チーム成長を促進する要因の一つに、個々のメンバーの成長があります。

かつて「育成」と表現した行為を現在では「成長支援」と呼ぶそうです。成長は他者からは影響できず、「成長」できるのは本人自身のみだからだそうです。成長支援で実践できることは、環境づくりや課題提案などでしょう。

現在のマネジメントにおいて、メンバーに最も伸ばしてもらいたいスキルは「創造性」と「自律性」です。人は本来自律的に行動できる状態を好む性質があり、自ら主体的に達成した自己実現において強い満足感を得るためです。タックマンモデルによる成長を促進するためにも、開発チームにおいてはメンバー一人一人が楽しむ主体になり、主役になる必要があります。マネジメントの仕事として、チーム成長と同じくらい個々のメンバーの成長支援が重要になります。

チーム成長において目指すべき方向性

以上で述べた通り、チームは成長の過程で発散と結合を繰り返すのが理想の状態です。よって、チームの発散力と結合力が両方とも高い状態において、チームの成長量はより大きくなります。どちらかが低い状態では、チームは硬直しているか、バラバラであり、良好なコンディションとは言えません。これらは、チームメンバーのチームに対する満足度で測ることができそうです。以下にチームの現状を測る指標の例を挙げます。

  • チームのユーザー価値提供に対する満足度
  • チームのアウトプットに対する満足度
  • チーム自体に対する満足度

たとえばこれらの向上を目指すことによって、チーム成長の状態を把握することができるかもしれません。

まとめ

あらゆることに正解が見えない現在、プロダクトやチームが大切にすべき価値や解決したい問題について、外から何かを提示してくれることは期待できないと腹を括って、自らの脳をフル回転して指標を設定したり方針を決定すると考えると、タスクは増加するかもしれませんが気持ち的には楽になります。メンバーと自分を信じ、勇気をもって進んでいきましょう。

DynamoDB Toolbox v 1.0 beta がでたので触ってみた

この記事は個人ブログと同じ内容です

DynamoDB Toolbox v 1.0 beta がでたので触ってみた

はじめに

こんにちは、back checkで SWE をしているぐっきーです。 最近 back check ではプロダクトの一角で DynamoDB を使い始めました。 DynamoDB 周りの使用技術としては主に Marshaling 目的で DynamoDBDocumentClient を、 Entity の定義と API パラメータの作成に DynamoDB Toolbox を使っています。

DynamoDBDocumentClient: DynamoDB とやりとりするデータは DynamoDB JSON という、primitive 型を含めた独自の JSON 形式で行われるため、これと可読性の高い一般的なデータ構造の JSON フォーマットと変換する役割。

DynamoDB Toolbox: Entity を DynamoDB の(主にシングルテーブルを想定)テーブルにマッピングしたり、Entity のスキーマに沿った API パラメータを作成してくれる役割。ただし型のサポートがいまいち行き届いていない部分があり、便利だけど改善してほしい部分もあるという温度感のツール。

そんなタイムリーなタイミングで DynamoDB Toolbox の v1.0.0 beta がリリースされたという記事を発見したので今回はどのような変更があったのか触ってみます。

結論

このブログにも書いてあるが、まだtransaction系だったりscanなどのAPIがサポートされていないこともあり、プロダクトで使うにはこれらのAPIが実装されてからアップデートを行った方がよさそうに思った。 個人的にはMapなどの内部まで型が適用できることによるポリモーフィズム型推論が可能になったことなどポジティブな影響は大きく、latestとしてリリースされることが楽しみです。 今後の動向を要チェックといったところですね。

主な変更内容

  • 型の対応範囲が広がった。
  • betaで提供されるAPIはツリーシェイキングが効果的に行われるように書き直されているため、それぞれV2のAPIを利用することで軽量になる。
  • TableとEntityクラスのIFが大きく変更されたことにより、より型安全なコマンドやEntityを作成することができるようになった。
  • 既存のDynamoDBのデータ構造を保ちながらbeta版のAPIを利用することができる。
    • 0系から beta へアップデートの際に migration は必要

具体的にはこちら The DynamoDB-Toolbox v1 beta is here 🙌 All you need to know! の記事にて詳細な変更内容が掲載されているので割愛します。

dev.to

触ってみた

先にコードをみたいという方はこちら

github.com

今回大きな変更のあった箇所を中心に触ってみました。

TableV2 クラス

まずは TableV2 クラス。(旧Tableクラス)

主な変更点としては、partitionKey, sortKey が今まで文字列を直接指定するのみだったことに対して、v1.0.0 beta の変更では partitionKey, sortKey にプリミティブ型を指定することができるようになりました。

// v1.0.0 beta
const TableA = new TableV2({
  documentClient: DynamoDBDocumentClient.from(new DynamoDBClient({})),
  name: 'tableA',
  partitionKey: {
    name: 'pk',
    type: 'string',
  },
  sortKey:  {
    name: 'sk',
    type: 'string',
  },
})

// v0.8.5
const TableA = new Table({  
  indexes: {  
    GSI1: { partitionKey: 'gsi1pk', sortKey: 'gsi1sk' },  
  },  
  name: 'tableA',  
  partitionKey: 'pk',  
  sortKey: 'sk',  
})

懸念点として今まで Global Secondury Index の設定などをTable の indexes オプションで行っていたのですが、indexes がなくなったことでどのように GSI を指定するのかわからなくなりました。(もしかしたらそもそもの使い方が間違っていた可能性はありますが)

EntityV2 クラス

次に EntityV2 クラス。(旧Entityクラス) 大きな変更としては今まで attributes として定義していたスキーマが、schema メソッドに置き換わりました。

export const TableAEntity = new EntityV2({  
  name: "TableAEntity",  
  schema: schema({
    pk: string().key(),  
    sk: string().key(),
  }),  
  table: TableA,
})

また、timestamps オプションを設定することで作成、変更の日時を任意の名前で管理できるようになりました。

export const Entity = new EntityV2({
  ...schema,
  timestamps: {  
    created: {
      name: 'creationDate',
      savedAs: '__createdAt__',
    },
    modified: {
      name: 'lastModificationDate',
      savedAs: '__lastMod__',
    },
  }
})

Attributes の型

全部は紹介しませんが、Attributes の型の設定の仕方に大きく変更がありました。

number 型

schema: schema({
    // number
    age: number(),
})

string 型

schema: schema({
    // string
    email: string(),
})

PrimaryKey の指定 (string)

schema: schema({
    // PrimaryKey
    pk: string().key(),
})

enum

schema: schema({
    // type gender = 'male' as const | 'female' as const | 'other' as const
    gender: string().enum('male', 'female', 'other'),
})

list

schema: schema({
    /*  
    * skillsByList: string[]  
    */  
    skillsByList: list(string()),
})

map

schema: schema({
    /*  
    * skillsByMap: {  
    *   karate: '白帯' | '茶帯' | '黒帯',  
    *   kendo: '初段' | '二段' | '三段',  
    * }  
    */  
    skillsByMap: map({  
        karate: string().enum('白帯', '茶帯', '黒帯'),  
        kendo: string().enum('初段', '二段', '三段'),  
    }),
})

record<any, any>

schema: schema({
    // Record<string, string>  
    skillsByRecord: record(string(), string()),
})

set

schema: schema({
    // Set<string>  
    skillsBySet: set(string()),
})

anyOf

schema: schema({
    /*  
    * job: {  
    *   type: 'engineer',  
    * } | {  
    *   licenseStartDate: string,  
    *   type: 'doctor',  
    * }  
    */  
    job: anyOf([  
        map({  
            type: string().const('engineer'),  
        }),  
        map({  
            licenseStartDate: string().required(),  
            type: string().const('doctor'),  
        })  
    ]),
})

any

schema: schema({
    // any
    metadata: any(),
})

Commands

各コマンドにも専用のクラスが用意されました。 尚、前述しましたが現時点でサポートされているのは Put, Get, Delete のみでありその他のコマンドについては今後実装していく予定とのことです。

PutItemCommand

const dummyData = {  
    age: 30,  
    email: 'example@example.com',  
    gender: 'male' as const,  
    job: {  
        type: 'engineer' as const,  
    },  
    name: 'John Doe',  
    pk: 'user_123',  
    sk: 'profile',  
    skillsByList: ['JavaScript', 'Python', 'SQL'],  
    skillsByMap: {  
        karate: '黒帯' as const,  
        kendo: '初段' as const,  
    },  
    skillsByRecord: {  
        framework: 'React',  
        language: 'English',  
    },  
    skillsBySet: new Set<string>(['Guitar', 'Singing']),  
};  
  
export const putCommand = async (): Promise<void> => {  
    await TableAEntity.build(PutItemCommand).item(dummyData).options({  
        condition: {  
            attr: 'pk',  
            exists: false,  
        }  
    }).send();  
}

GetItemCommand

export const getCommand = async (primaryKey: PrimaryKey<typeof TableA>): Promise<string> => {  
    const { pk, sk } = primaryKey  
    const { Item } = await TableAEntity.build(GetItemCommand).key({ pk, sk }).send();  
      
    if (Item === undefined) {  
        throw new Error('Item is not found')  
    }  
      
    if (Item.job.type === 'doctor') {  
        return `${Item.name} is a doctor. License start date is ${Item.job.licenseStartDate}`  
    }  
      
    return `${Item.name} is a ${Item.job.type}`  
}

DeleteItemCommand

export const deleteCommand = async (primaryKey: PrimaryKey<typeof TableA>): Promise<void> => {  
    const { pk, sk } = primaryKey  
    await TableAEntity.build(DeleteItemCommand).key({ pk, sk }).send();  
}

Thanks

DynamoDB のサンプルコードのベースをお借りした mukaihajime さん、ありがとうございました!

参考資料

苦しいペアプロに苦しむ話

この記事は個人ブログと同じ内容です

note.com

こんにちは、株式会社ROXXの agent bankでスクラムマスターをやっている坂本です。
うちのチームはペアで作業をするというのを頻繁に行っています。
その中で、メンバーの様子を見ていたり話を聞いている中で感じた(または直接聞いていたかもしれない)ので、ペアプロ(もしくはペア作業)の苦しみを書いてみます。

その前に注意事項

  1. 具体的なやり方・メリットの解説はそんなに本気でやりません。
  2. ここで出てくる苦しみは、うちのチームで起きていて感じた事です。他の人からすると別に苦しくないことかもしれないです。だけど、「その苦しみわかる〜」と盛り上がってくれると嬉しいです。
  3. ここで取り上げる課題に対しては絶賛取り組み中でまだまだ試行錯誤してます。「まだ試してないけどね」なことも書きます。

まずはペアプロの良いところから

まずメリット書かないと暗い悲しい記事になっちゃいますね。

ペアプロのメリットはあります!
うちのチームで自覚できていることとしては以下があるのかなと思います。

  • 早い段階での検査を行えること

  • 知識を集合させて困難に立ち向かえる

  • 知見を共有しあえる

  • 楽しい

軽く補足していきます

早い段階での検査を行えること

特に、難しい課題やタスクや考える要素が多い時に、完了したと思って成果物を共有したりしてレビューをもらったら「あれ?全然見当違いのことやってない?」みたいなことが発生することありますよね。あれを複数人で対応することで、より早くに検知して軌道修正できています。

または、ハマってしまった際にペア内で助け合って解決したり、限界に早めに気づいてペアの外に助けを求めよう!という会話を生むみたいなこともおきています。

知識を集合させて困難に立ち向かっている

人によって知識の差があることは当たり前だと思います。業務知識であったり、技術的な知識だったり、他にもいろいろあるかと思うんですが、得意不得意、知ってる知らないをお互いに補うことで、一人では解決できないことを解決していくよ!みたいな感じですね。

知見を共有しあえる

若干ひとつ前のものと被ってるとこあるかもですが、人によって詳しい部分得意な部分を補ったり、複数の視点から見ることでわかる気づきをお互いに共有しあって、チーム内の知見の偏りを減らしていきます。
これにより、過度な属人化が減りチームとしてできることも増えていきますね。

楽しい

うまくいっている時はこれを感じます。やり取りの中で生まれる新しいアイデアや気づきが生まれるなどして、一人では体験できないような、動きができてい時に感じるんでしょうね。コラボレーション!!
毎回これを感じれるようになりたい。

こんな風にペアプロを苦しんでた

では逆に、苦しんでいる時って何が起きてるんでしょう?

様子を見ていたり、話を聞いた感じでは、一般的にペアプロと呼ばれるものとは違う何かをやっていたり、ペアプロアンチパターンと呼ばれるものを踏んでいたってことなのかなと思います。

  • ドライバー、ナビゲーターの交代がない

  • ペア同士のスキル差、知識差が大きすぎる

  • ペアの方針の合意がないままになんとなく進んでいる

ひとつづつ簡単に補足していきます。

ドライバー、ナビゲーターの交代がない

ペアプロってめっちゃ集中します。ペアプロに関する記事を見て回ったんですが、大体どこも「6時間が限界」とか「終わる頃にはヘトヘト」などと書かれています。実際にメンバーのからもそんな感じのことを聞きます。

ヘトヘトになるのは、当たり前だろうし悪いことではないと思います。しかし、実際に起きていた(又は、現在進行形で起きている)ことは、「長時間役割の交代をしないまま続けていることによる疲労」のようでした。

具体的にどういうことかというと、一般的には役割と定期的にスイッチしましょう(所によっては10分とか短いスパンでの交代)と言われているところを、NO交代で2-3時間以上進んでいることがたまにある。そして、集中しての疲労を超えた先の何か別の疲労が生まれている。

定期的なスイッチで何を期待しているのかというと、視点をかえるだとか、気分転換とか、リズムを作るとか色々ありますよね。どうしても役割によって思考やできることが固定されちゃうっていうのがあるので、それをリフレッシュするためにやるのかなと思います。それが抜けることで、結構つらい状態になっていました。

  • 視点の切り替えができないので、ハマっても抜け出せない。

  • そもそも、ハマっていることに気づけない。

  • 集中が途切れて、お互いに役割を果たせない。

  • なんだか、ただただ長時間監視されながらの作業になりプレッシャーが残るだけ。

原因としては、リモートでの作業のために、頻繁にスイッチすることへのハードルがあるとか、集中していたら交代することを忘れていたとか、もう少しで終わりそうなので継続をしていたら、結局1-2時間そのままとか…やばいなんだか色々ありそうですね。

なんにせよ、定期的にスイッチできていないこと自体に別の問題がありそうなのと、スイッチできていないことによりまた問題が生まれているっていうのはあるみたいです。

ペア同士のスキル差、知識差が大きすぎる

最初に断ると、この要素だけでは特に問題ではなかったかもしれないです。「ペア同士のスキル差、知識差が大きすぎるけど、ペア内に差があることをちゃんと認めれきれてなくて、ペアの動きに反映できてなかった」がここで言いたい苦しみかもしれないです。

最近、チームにメンバーが新しく入ってくる機会が何度かあったので特にチームからでていた問題です。
ペアプロする際に、同じぐらいのレベル感の人同士でやっていく分には今までそれほど問題はありませんでした。しかし、ギャップがあるペアを組んだ際に初心者の方がプレッシャーやできないことをとても感じてつらいと感じたり、熟練者側も教育・指導的な負担が大きくてつらいみたいなことがあっていました。

もう少し具体的にしてみます。ギャップが少なく同じくらいの場合は、漫才の掛け合いをするごとくお互いに発言しあって、作業を進めていき結果として学び合う高め合うような状態になることが多いです。逆にギャップが開いていると、初心者の方がなかなか意見が言えない、聞くばっかりになってしまう。また、熟練者から意見を求められても瞬時には出せないのでプレッシャーになってしまう。こんなふうに初心者すぐに答えを出すことが難しい話題に対して、息をつく間も無く「これはどう思う?」「これは?」「これは?」と出題が続き、つらい…ってなっちゃう。

実はこれ、今熟練者としてペアに入っている人も自身が入りたてのときに感じてたことだったらしい。さらにつらいことが、チームとして解決につなげることができずに、毎回気合いで乗り越えてしまったので、またこうして課題が出てきてしまっていることですね。

もしかすると、ギャップがあっても、なるべく速度を落とさず、熟練ペアのように進まなければいけないみたいな意識があったのかもしれないです。

あと、初心者が何もできないうちに熟練者が進めていって自信が持てないとか、個人としての達成感が持てないみたいな話もありました

speakerdeck.com

同じようなことを(モブプロの話ですが、)抱えているチームも他にもいるみたいですね。ここら辺はチームとして共通認識を作っていって「チームとしての成果」と感じれるようになっていけるといいなあ。

ペアの方針の合意がないままになんとなく進んでいる

前の二つにつも通じることかもしれないんですが、割とペアでの作業をはじめるときに何となく初めていることが多いです。

そのため、作業のゴールに向かうまでの方針がないままの二人で手探りをずっと続けていたりだとか、一方はどうすればいいか知っているけど一方は目隠しの状態みたいなつらい状態が起きることもありました。

ペアとしての振る舞いに関しても合意が取れてないので、スキル差、知識差のある状態の際に、初心者側がペアプロにどう参加したらいいのかわからず進むみたいなこともあったみたいです。

苦しみ考察

苦しみを並べてみて思ったのは、これって結構3つ全て紐付いて起きているのかもしれない?
ペアの中で失敗しても良い雰囲気作りができてなかったため、苦しみが生まれていたのかもしれない。スキルや知識の差があるけれど、お互い合意を取れてないので、自分が変なことやっちゃったらどうしよう。ペアの進捗を遅めちゃうなぁ、プレッシャーだ…。しかも(交代がないので)このまま失敗し続けたらどうしよう…しんどい…。タスケテ…

ああ、助かりたい!

苦しみから解放されたい

こうすればいいのかな?こんなこと試してみてるよ?を書いていきます。

ドライバー・ナビゲーターは交代しよう

「でも…」も「だって…」も許さず、一定時間経ったら絶対交代する!っていうのを徹底するしかなさそう!そのためにもペアを作ったときに、「じゃあ〇〇分経ったら交代しようぜ」みたいな合意をするとか、そもそもチームとして30とか15分経ったらスイッチしなければならないみたいなのを定めるとかやってもいいのかもしれない。

他社でアジャイルをやってる方にこの悩みを相談した時に、お聞きした実際にやってるプラクティスは、モブでやっている時の交代は銅鑼を鳴らして強制的に交代させるっていうものだった。徹底ですね。
有無を言わせず交代。無理矢理交代していくうちにありがたみが分かってくるぞ?ってことかな。

ちなみに、短い交代がうまくいってないことばっかりではなく、これまでも何だかペアうまく進めてるな!みたいな時は、自発的にそろそろ交代しようかとか交代させて!とかやり取りしつつ、時間や作業の区切りで数十分単位で交代を繰り返していました。今だってできてる時はできてる。ついつい忘れちゃって〜ってときがたまにあって、それがあると苦しみが生まれがちなのです。

スキル差があっても「あと数分経ったら交代するんだし思い切りやるぞ!」ってなると良いなあ。(ナビゲーターも、ドライバーもどちらもね)

あ、あと休憩もちゃんと取る。

ペアで進め方の方針を決めて進める

ペアプロではちょうど、役割の名前をナビゲーターとドライバーという運転に例えた表現をしているので、運転を始める前にペアの中で地図の共通認識を持ってどのゴールに向かうので、この道筋が良さそうだねみたいな会話ができるといいんでしょうね。(前任のスクラムマスターの受け売り)

また、役割ごとにどう振る舞うとか、初心者、熟練者としてどうペアプロに参加するみたいな認識も合わせると、より発言しやすくなるかもしれない。チームとしてペアプロのルール・約束的なものの認識がぼやけているような気もするので、ちょっと整理したい気持ちが出てきた….やりたい。

ペアでの決め事決めて進めるみたいなのでいうと、ふりかえりとかで定期的に上がってくるのが、実装開始前にmiro(オンラインホワイトボードツール)で、ざっくりの実装方針や、設計的なものを整理した上で始めると、めっちゃよかったみたいな事がある。これが習慣化してきたりすると最高だ。

(書いてみたら「ペアとしてどう振る舞う?」と「ペアがこのタスクをどう進んでいく?」の認識合わせるの2軸の話が混じってるような気もするけど、まあよし)

ソロを増やしてみる

とりあえずペアで進めるのを、一回やめてみるですね。

「大事なことは無くなってから初めて気づく」とか言ったりするし、うまくできてなくてつらい、糸口がわからないであれば、一旦全体としてペアプロ廃止にしてみるトライをやってみてもいいのかもしれない。(極端にやるとしたらね)

本当に必要ならまた再開しようぜ!という声が上がるだろうし、出てこないのならチームがそういう状態じゃないのかもしれない。(とはいえ、ペアプロはやってほしいので、うまく機能するようにサポートしたい)

極端に全部やめてみるっていうのはまだ試してみてはいないんですが、チームのアクションとして、ペアじゃなくても良さそうと判断できるものは、ソロでも進めても良いっていうのをやっています。実際にやるべきことがシンプルでかっちり固まっている系のタスクだったり、ちょっと時間をとって探索する系の調査タスクだったりソロで進める方が結果的にもよかったという声が出ています。あと印象的だったのが、「ちょっとこれはペアじゃないと不安」みたいな感じで、ペアの効果の再認識みたいなことも起きるようになっています。

他には、今はペアプロの選択肢の他にバディプログラミング的なものも実施してみている。レガシーコードからの脱却 ―ソフトウェアの寿命を延ばし価値を高める9つのプラクティス に載っているらしい。それを少しカスタマイズしたやり方で、バディ(二人組)でタスクの初めにお互いのやることを確認→分散してそれぞれ並行作業→一定時間(1hくらい)経ったら合流して報告し合うみたいな進め方を初めてみた。ギャップがある状態での、プレッシャーからは解放され、初心者は限られたタイムボックスの中で自分のペースで集中して探索を行うことができる。
(実際にやってどうだったかはもう少し試してみて記事に書けると良いなあ…)

最後に

今回書いた以外にもまだ認識していなかったり、書き漏れている苦しみもあるかもしれません。そこについても、日々の活動や定期的なふりかえりでチームとして認識して、良くしていけるといいなとおもいます。トライについても多分もっとやれること、やった方がいいことがあるでしょう!

取り組む話題や、人によって複数人でやる方が良いとか、一人で進める方が良いとか色々あるかとは思います。
ただ個人的には、チームで進めることによって一人ではできないこと達成できるみたいなことにとても喜びを感じるし、実際にそんな体験をしてきていると思うので、これからもチームがチームとして成果を出せるように精一杯働いていきたいなと思った。

あとは個人的にチームでモブプロやりたい欲が芽生えてきているのでここにもトライしたい。