PHP 8.3 の新機能を試してみよう

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

www.ritolab.com


PHP 8.3 が 2024 年 11 月にリリースになります。PHP 8.3 の新しい機能をいくつかピックアップして試してみます。

クラス定数の型指定

クラスで定義する定数に型の指定が行えるようになりました。

Typed class constants
https://wiki.php.net/rfc/typed_class_constants

<?php

class Person {
    const string NAME = 'rito';
}

指定した型ではない値を代入するとエラーになります。

<?php

class Person {
    const string NAME = 777;
}

// Fatal error: Cannot use int as value for class constant Person::NAME of type string in...

クラス定数の動的参照

Dynamic class constant fetch
https://wiki.php.net/rfc/dynamic_class_constant_fetch

クラスで定義した定数を、変数で動的に参照できるようになりました。

<?php

class Person {
    const string NAME = 'rito';
}

$personNameConstant = 'NAME';

echo Person::{$personNameConstant};
// => rito

PHP 8.2 までは constant() 関数を使わないと同様のことはできませんでした。

<?php

echo constant("Person::$personNameConstant");
// => rito

8.3 でも constant() 関数は引き続き使えます。

Override attribute によるオーバーライド指定

Marking overridden methods (#[Override])
https://wiki.php.net/rfc/marking_overriden_methods

PHP は、実装されたメソッドのシグネチャが指定されたインターフェイスまたは親クラスからオーバーライドされたメソッドと互換性があることを検証しますが、メソッドが実際にインターフェイスのメソッドを実装することを目的としているのか、親メソッドをオーバーライドすることを目的としているかどうかの「意図」を確認することができません。

例として、以下の基底クラスと派生クラスがあったとします。派生クラス側で、基底クラスのメソッドをオーバーライドしています。

<?php

class Book {
    protected function setName(string $name): void { /* some processing... */}
}

class PhpReference extends Book {
    // setName() をオーバーライドしている
    public function setName(string $name): void { /* some processing... */}
}

このとき、基底クラス側のメソッド名が変更されたらどうなるでしょう。

<?php

class Book {
    // protected function setName(): void { /* some processing... */}
    // ↓ メソッド名を変更した
    protected function setTitle(string $name): void { /* some processing... */}
}

class PhpReference extends Book {
    // setName() をオーバーライドしていたはずが、基底クラス側のメソッド名が変わったために派生クラス独自のメソッドとなってしまった
    public function setName(string $name): void { /* some processing... */}
}

基底クラス側のメソッド変更によって、派生クラス側のオーバーライドが独自メソッドになってしまいます。つまり、PhpReference クラスは setTitle() 関数も、setName() 関数も動作することになります。

PHP はこういった「意図」の検出ができません。

PHP 8.3 では、Override アトリビュート(#[\Override]) をつけることによって、そのメソッドがオーバーライドであることを示せるようになりました。 これによって、基底クラス側のメソッド名変更があった時にエラーにすることができます。

<?php

class Book {
    protected function setName(): void { /* some processing... */}
}

class PhpReference extends Book {
    #[\Override]
    public function setName(): void { /* some processing... */}
}

基底クラス側でメソッドの変更が行われた場合はエラーが発生します。 

<?php

class Book {
    // protected function setName(): void { /* some processing... */}
    // ↓ メソッド名を変更した
    protected function setTitle(): void { /* some processing... */}
}

class PhpReference extends Book {
    #[\Override]
    public function setName(): void { /* some processing... */}
    // => Fatal error: PhpReference::setName() has #[\Override] attribute, but no matching parent method exists in...
}

INI ファイル環境変数設定におけるデフォルト値設定

php.ini での環境変数設定でデフォルト値が設定可能になりました。

INI ファイル内では環境変数が参照できるため、これと併用することでデフォルト値の運用が可能になります。

まずは PHP 8.2 までの挙動を見てみます。

post_max_size = ${POST_MAX_SIZE}
<?php

/*
 * 〜 PHP 8,2
 */ 

// php.ini
// post_max_size = 8M
echo ini_get('post_max_size');
// => 8M

// 環境変数 POST_MAX_SIZE が設定されている
echo ini_get('post_max_size');
// => 10M

// 環境変数 POST_MAX_SIZE が設定されていない
echo ini_get('post_max_size');
// => ''

PHP 8,2 までは、値が指定されていない場合は未設定となり、値を参照すると空文字が返ります。

PHP 8.3 では、デフォルト値を指定しておけば、環境変数の設定が無い場合もデフォルト値を適用してくれます。

post_max_size = "${POST_MAX_SIZE:-6M}"
<?php

/*
 * PHP 8,3 〜 
 */ 

// 環境変数 POST_MAX_SIZE が設定されている
echo ini_get('post_max_size');
// => 10M

// 環境変数 POST_MAX_SIZE が設定されていない
echo ini_get('post_max_size');
// => 6M

json_validate() 関数

json_validate() 関数が追加されました。

json_validate
https://wiki.php.net/rfc/json_validate

この関数は、文字列が有効な json 文字列であるかどうかを検証します。

これまでは、json_decode() 関数 の結果によってハンドリングすることが多かったはずです。

<?php

$json = '';

try {
    json_decode(json: $json, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    /* some processing... */
}

ただしこの場合、文字列の解析で ZVAL(object, arrayなど) を生成するため、メモリを消費する上、メモリ保存処理分のオーバーヘッドも発生します。

対して PHP 8.3 で追加された json_validate() 関数はこれらの処理を行わないため、リソース節約・速度向上に貢献します。

また、json_validate() 関数で使用する Parser は json_decode() 関数と共通のため、json_validate() が通れば json_decode() も成功する。ということが担保されます。

json_validate( string $json, int $Depth = 512, int $flags = 0 ) : bool
<?php

$json = '';
json_validate($json);
// => false

$json = '{"a":"aaa","b":"bbb"}';
json_validate($json);
// => true

まとめ

PHP 8.3では、クラス定数の型指定やオーバーライド指定、そして json_validate() 関数など、うれしいアップデートがありました。

今回取り上げたものは一部です。新機能は他にもあるのでチェックしてみてください。

https://wiki.php.net/rfc#php_83


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

国作りワークショップで見つけた、日常の小さな変化をシェアして安定的な成長環境を構築する心得

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

国作りワークショップで見つけた、日常の小さな変化をシェアして安定的な成長環境を構築する心得

※この記事のタイトルは XP 祭りのセッション「あなたも元高角三(げんこうかくぞう)になろう!- 文章力で世界を変革する技術」のワークショップで学んだコピーライティングを実践した結果できたタイトルです。少々大袈裟かもですが、優しい目でお気楽に読み流していただけると幸いです。

こんにちは、back checkの開発をしておりますぐっきーです。 今回はXP祭りのオフラインの国作りワークショップに参加してきたのでイベントレポートとして本記事を書きます。

confengine.com

内省と周りの人との関係構築を促すワークショップ

まず @ryo_endo さんが考案された「 国作りワークショップ」の概要を説明します。

・国づくり ・国際交流

上記の2つのメタファーを通して自分自身の価値観や自分とは異なる価値観の理解を深めていくことを目的としたワークショップと紹介されていました。

進行

参加人数:約10名

参加者はテーマとしてあげられたいくつかのお題(今回は「問題解決」「関係性」「自己成長」)に対して自分の価値観として最も大切にするものを選択します。 価値観を選択した参加者は、同じ価値観を選択した他の参加者とグループになって国作りをしていきます。

国作りの手順は以下となります。

  1. なぜ自分がその価値観を選択したのかグループメンバーと共有する。
  2. グループ内のメンバーで協力し、国の国旗とその国特有の挨拶を作成する。

その後、他の国(別の価値観を選択したグループ)と国際交流を行います。

国際交流の流れ

  1. 国に観光にきた人へのおもてなしの準備を行います。ここでは各グループの中で自国に残り、他国からの観光客から自分達の国について投げられた質問に回答する人を選出します。
  2. 自国に残らないメンバーは他の国に観光に行きます。ここでは自国に残らないメンバーが他国のテーブルへいきます。
  3. 他国へ観光にいったメンバーは、国旗とその国の挨拶をはじめその国に関するちょっと意地悪な質問を投げます。質問に対しておもてなしをするメンバーは自国への質問に対して観光大使として回答します。
  4. 他国へ観光にいくメンバーはタイムボックスの間自国以外を行き来し、それぞれの国を知るための質問を繰り返します。
  5. タイムボックス終了後、自国に残ったメンバーと他国へ観光にいったメンバーを入れ替え、全ロールを経験するまで繰り返します。

日常の服を脱ぐ

印象的だったポイントとして、最も大切にする価値観を選択する流れで、最初に価値観を選んだ後ファシリテーターから「仕事のことを考えて価値観を選んでいませんか?」という問いかけがありました。その上で服を脱ぐ所作をするワークを行い「ユニークな服を着ていた人はいませんか?」など服を脱ぐワークに集中するような場作りがありました。

このワークを行った後に再度「服を脱いだ後、あなたは改めてどの価値観を大切に考えますか?」という問いかけを受けて参加者は再度価値観を選択しました。

この全員で体を動かして服を脱ぐワークを挟んだことによって、参加者間で自己主張に対する心理的安全性が高まり、その後の自分が選択した価値観をワークに参加した周囲のメンバーに共有することに対して抵抗がなくなったように感じました。 また、自分が最初に選択した価値観から1度考えをそらして再度選択肢に上がった価値観について見つめ直すことにより、ふりかえり手法の「5つのなぜ」のように自分の価値観に対して一段深く考えられたように思います。

ちなみに私は参加者として、最初の選択では「関係性」を選びました。これは自分自身が素敵だと思う人が集まるコミュニティとふれあうことが自分の潜在的な欲求だと考えたからです。しかし、服を脱いだ後に再度選択した価値観は「自己成長」でした。この大切に思う価値観の変更に至った理由は、自分が時間を共有したいと考えるコミュニティと出会うまでには必ず私の中で未知の領域の探索によって新しいコミュニティと出会ってきたからです。この未知の探索が「自己成長」のプロセスではないかと考えたため、最初の選択とは異なる価値観を選択しました。

自分達による価値観の可視化

次に国作りのワークで行った国旗づくりと挨拶づくりで感じたことを紹介します。

国旗づくりと挨拶づくりでは、事前にグループ内でなぜ自分がその価値観を選択したのかを共有し、その後グループメンバーの中で「自己成長」のイメージに対するキーワードを探しました。 私たちのグループでは、自己成長のイメージとして「頑張る」「周囲の人へのポジティブな影響」「手放す(周りの人を信じて託す。自分ができるようになることで周りの人達もできるようになっていく。育てて放流し、また次の世代に繋げていく。これらを自己の成長によって実現するようなイメージ)」が上げられました。これらのキーワードを元にインクリメンタルなサイクルが中心から生まれどんどん派生し増えていくようなデザインの国旗を作成しました。

また挨拶作りでは、サイクルがインクリメントしていく様子を表すジャスチャーを取り入れ、ヘリコプターのプロペラのように手を回す動作を取り入れました。 ちなみに私のニックネームを口に出した際の響きの良さがウケたことで、挨拶は「グッキー」に決定しました。 ※会場で参加者皆が「グッキー」を連呼するのはめちゃくちゃ恥ずかしかったです。その後懇親会まで引っ張られる程度には浸透しました。

国旗づくりと挨拶づくりのワークによって、なぜ自分自身が「自己成長」が重要だと感じたのか。また、その感覚の芯の部分にはどのような考えがあるのかが整理できたように思います。

周りの人からの問いによる価値観の深掘り

国際交流のワークでは、他国のテーブルに観光に行き、その国の国旗と挨拶をはじめ、その国に関するちょっと意地悪な質問を投げました。 ちょっと意地悪な質問とは、無茶振りのように「あなたの国ではどんな遊びが流行っていますか?」など国旗と挨拶に関する単純な質問よりもイメージを膨らませないと回答できないような質問を指します。

価値観をぶらさずに場面毎に適用するならなにが必要か?を即興で考えるのが難しかったですが、価値観を促進するアクションの具体がイメージできました。

以下に印象的だった質問とその回答を紹介します。

  1. 「自己成長国ではどんな遊びが流行っていますか?」

  2. いつもとちょっとだけなにかを変えてみる。例えばいつもの自分と違う服の着方をしてみる。新しい価値の発見を皆が楽しんでいる文化がある。

  3. 「自己成長国のおすすめのお店を教えてください」

  4. シェア屋さん。お客さんの興味をそそるような様々な領域の変化をパッケージ化して提供してくれる。(それって情報商材じゃないですか?というツッコミもあったり)

  5. 「自己成長国ではなにをしたら逮捕されますか?」

  6. 個人の成長、変化を強制しない。押しつけない。過去自己成長国が独裁政権だった時代は横行していたが、今の国では禁止し厳罰の対象としている。現在は自己成長国では変化は自由である。他人と変化量を比較することは犯罪ではないが、マナー違反でありこれをする人は紳士的でないと考えられている。

※自己成長国: 自己成長の価値観を選んだグループの国名

小さな変化をシェアして安定的な成長を実現する

ここまで一連の流れを終えた後、自分の価値観に対する発見をまとめ共有する時間がありました。

このワークショップ全体を通しての発見を私は下記のようにまとめました。

心得

まずは個人の範囲内で自己成長を楽しもう。望まない人には押し付けない。

成功も失敗も「変化」があることで得られた学びとしてポジティブに捉えよう。

変化を積極的にシェアしよう。様々な領域の変化掛け合わせで誰か(自分も含め)の役に立つパッケージを作ろう。

変化に「気づく」ようになろう。変化に気づいたら、相手に変化したねと伝え賞賛しよう。

これらを総じて振り返った中で自分達の考えていた「成長」とはなにか?という問いに対して、自分達の考えていた成長とは「変化」であるという結論に至りました。 行動によって生じるちょっとした変化。be agile を志すものとして、日常の小さな変化を捉え、言語化し、シェアすることで安定的な成長(変化)の環境を実現できるのではないでしょうか。

公開ほやほや Laravel Eloquent 用の日時フィルタリングパッケージ Laravel Date Filtering をお試し

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

www.ritolab.com


Laravel Date Filtering という、Eloquent でのデータ取得における日時フィルタリングを提供するパッケージを試してみます。

Laravel Date Filtering

Laravel Date Filtering は、Laravel Eloquent モデル向けの、日付に基づく高度なフィルタリングと操作を簡素化するパッケージです。さまざまな日付と時間の間隔に基づいてレコードをフィルタリングするための便利なメソッドセットを提供します。

https://github.com/omarelnaghy/lara-date-filter

インストールと初期設定

パッケージをインストールします。

composer require omar-elnaghy/laradate-filters

設定ファイルを publish します。

php artisan vendor:publish --provider="OmarElnaghy\LaraDateFilters\ServiceProvider"

config/lara_date_filter.php が生成されます。

<?php
return [
    'custom_date_filter_convention' => [
        'get{duration}{unit}Records',
        'get{duration}{unit}Records',
        'get{duration}{unit}Records',
    ],
];

日付をフィルタリングするメソッド FilterByDateRange, FilterByDateHoursRange, FilterByDateMinutesRange などを使用可能にするため、対象のモデルの newEloquentBuilder() メソッドをオーバーライドしますします。

今回は User モデルで実装していきます。

use Illuminate\Database\Eloquent\Model;
use OmarElnaghy\LaraDateFilters\Traits\Builder\PackageBuilder;

class User extends Model
{
    .
    . (略)
    .
    
    
    /**
     * @param QueryBuilder $query
     * 
     * @return PackageBuilder
     */
    public function newEloquentBuilder($query): PackageBuilder
    {
        return new PackageBuilder($query);
    }
}

オーバーライドはしたものの、結果として Builder を extends し本パッケージ用のメソッドを Traits で付与したクラスを返しているため、ベースである Builder クラスに変更はありません。

これでこのパッケージを使い始める準備が整いました。

User データ

これからフィルタリングを行っていきますが、 今回使用しているデータは、 created_at が 2023-09-01 〜 2023-09-30 までの 30 日の範囲で 1 名ずつ User を作成した全 30 件のデータで行っています。

created_at は datetime で収録していますが、時間は全て 00:00:00 です。

フィルタリング:filterByDateRange

ここからは、実際のフィルタリングを見ていきたいと思います。

Laravel Date Filtering の基本メソッドである filterByDateRange() メソッドを使ってみます。

$startDate = Carbon::parse('2023-09-03');
$duration  = 2;
$dateUnit  = 'day';
$range     = DateRange::INCLUSIVE;
$direction = 'after';

// User をフィルタリング
$users = User::filterByDateRange($duration, $dateUnit, $startDate, $direction, $range)->get();

各項目を解説する前に、上記条件のフィルタリングによって得られた結果を先に共有しておきます。

// $users(配列に変換し必要なフィールドのみに限定しています。実際には\Illuminate\Database\Eloquent\Collection<int, \App\Models\User> が返ります。)
Array
(
    [0] => Array
        (
            [id] => 3
            [created_at] => 2023-09-03 00:00:00
        )

    [1] => Array
        (
            [id] => 4
            [created_at] => 2023-09-04 00:00:00
        )

    [2] => Array
        (
            [id] => 5
            [created_at] => 2023-09-05 00:00:00
        )

)

filterByDateRange メソッドの各引数は以下の意味とバリエーションになります。

/**
 *  @param int $duration
 *  @param string $dateUnit
 *  @param Carbon $date
 *  @param string $direction
 *  @param DateRange $range
 */
public function filterByDateRange(int $duration, string $dateUnit, Carbon $date, string $direction = 'after', DateRange $range = DateRange::INCLUSIVE)
  • $duration
    • 範囲の数値
  • $dateUnit
    • $duration の単位
      • 'second'
      • 'minute'
      • 'hour'
      • 'day'
      • 'week'
      • 'month'
      • 'year'
  • $date
    • 基点日
  • $direction
    • 基点日から過去・未来どちらの方向へ範囲を取るか
      • 'before'(過去)
      • 'after'(未来)
  • $range
    • 範囲指定における、起点、及び終点を含めるか
      • INCLUSIVE(含まれる)
      • EXCLUSIVE(含まれない)

$duration = 1, $dateUnit = 'day' なら、 1 日間ということになります。

この時、$duration = 0 なら、起点日のみという扱いになります。

また、$range が少しややこしいので説明しておくと、

$startDate = 9/3, $duration = 2, $dateUnit = 'day', $direction = 'after'

とした場合、DateRange::INCLUSIVE なら

9/3 <= targets <= 9/5

となり、9/3, 9/4, 9/5 のデータが返されます。

DateRange::EXCLUSIVE の場合は

9/3 < targets < 9/5

となり、9/4 のデータが返されます。

https://github.com/omarelnaghy/lara-date-filter/blob/6b69c9fe1695ecba77786c0c7c698269fb23d274/src/Traits/BuilderTrait.php#L21

クエリビルダとの違い

ここで、Laravel Date Filtering を用いた最初の実装を再度見てみます。

$startDate = Carbon::parse('2023-09-03');
$duration  = 2;
$dateUnit  = 'day';
$range     = DateRange::INCLUSIVE;
$direction = 'after';

// User をフィルタリング
$users = User::filterByDateRange($duration, $dateUnit, $startDate, $direction, $range)->get();

このときに、同じ実装をクエリビルダで行うとどうなるか見てみます。

日付範囲を内包する場合

Laravel Date Filtering でいうところの $rangeDateRange::INCLUSIVE の場合です。

$startDate = CarbonImmutable::parse('2023-09-03');
$duration  = 2;
$endDate   = $startDate->addDays($duration);

$users = User::whereBetween('created_at', [$startDate, $endDate])->get();

クエリビルダでも割合シンプルに書けそうです。では日付範囲を内包しない場合はどうでしょうか。

日付範囲を内包しない場合

Laravel Date Filtering でいうところの $rangeDateRange::EXCLUSIVE の場合です。

$startDate = CarbonImmutable::parse('2023-09-03');
$duration  = 2;
$endDate   = $startDate->addDays($duration);

$users = User::where(function (Builder $query) use ($startDate, $endDate) {
    $query->whereDate('created_at', '>', $startDate)
          ->whereDate('created_at', '<', $endDate);
})->get();

若干記述するコード量は増えましたが、まあ書けなくはなさそうです。

Laravel Date Filtering とクエリビルダでの日付フィルタリング

2 つを比べてみると、記述量にさほど違いはないものの、クエリビルダの場合は日付範囲の条件によって Builder の組み立て自体が変化します。

対して Laravel Date Filtering パッケージの場合は、そこを引数で吸収しているため、日付範囲の条件によって式を変えなくても良いという利点がありそうです。

その他のフィルタリングメソッド

filterByDateRange メソッドの他にも、以下のメソッドが提供されています。

これらは filterByDateRange メソッドにおける $dateUnit の省略版になっています。

Custom Eloquent Builder

Laravel Date Filtering ではカスタム Eloquent ビルダーを提供しており、メソッドの名前を "filterByDateXRange" パターンに従って命名すると、動的にメソッドを生成し、実行します。

// [Duration] は検索したい [日付単位(Date Unit)] の数を示す数値
return Post::filterByDate(Duration)(Date Unit)Range($startDate, $direction, $range)->get();

例えば、以下のようなメソッドが任意で組み立て可能です。

Post::filterByDate3DayRange($startDate, $direction, $range)->get();

Post::filterByDate3WeekRange($startDate, $direction, $range)->get();

Post::filterByDate6MonthRange($startDate, $direction, $range)->get();

created_at 以外のカラムでフィルタリングするには

(あくまでも 2023 年 9 月 22 日時点です)

Laravel Date Filtering のフィルタリングは、基本的に created_at を見るようになっています。

created_at 以外のカラムでフィルタリングする、 ないし動的にカラムを指定する方法は提供されていませんでした。

モデルに public $dateColumn = 'updated_at'; と指定すればフィルタリングのカラム変更はできるものの、あまり実用的ではないため、このあたりの開発はまだこれからのようです。

今後に期待の楽しみなパッケージ

本記事執筆が 2023 年 9 月 22 日ですが、Laravel Date Filtering は first commit が 2023 年 9 月 13 日と、まだ公開されて間もないパッケージです。

日付での複雑なフィルタリングが頻発するアプリケーションでは重宝しそうなパッケージなので、今後の機能拡充に期待しています。

https://github.com/omarelnaghy/lara-date-filter


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

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