0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHubコントリビューションで時系列データ異常検知入門

Last updated at Posted at 2025-12-03

この記事は Qiita Advent Calendar 2025 - 時系列データ の4日目の記事です。

はじめに

Day 2の記事ではGitHubコントリビューションデータを取得・可視化しました。今回はそのデータを使って 時系列データの異常検知 をやっています。

おことわり

今回紹介する Z-score や EWMA は、データが「正規分布」していることを前提とした手法(パラメトリック)です。IQRは分布を仮定しませんが(ノンパラメトリック)、0が多いデータでは閾値が極端に低くなります。

GitHub コントリビューション数も「0(活動なし)」が多く、分布が偏るデータです。教科書的には正しくない分析を適用していますが、「やってみた」記録としてご笑覧ください。

異常検知とは

時系列データにおける「異常」とは、通常のパターンから大きく外れた値やイベントのことです。

  • スパイク(急上昇): 突然値が跳ね上がる
  • ドロップ(急降下): 突然値が下がる
  • 非活動期間: 本来あるべき活動がない期間

GitHubコントリビューションで言えば、「普段の数倍のコミット」や「長期間の非活動」が異常に相当します。

今回のデータ

Day 2の記事で紹介した方法で取得したデータを使います。

項目
期間 2024-12-01 〜 2025-12-04
総日数 369日
総コントリビューション 1,977
平均 5.36
標準偏差 13.13
最大値 101 (2025-06-09)

平均5.36に対して標準偏差13.13と、標準偏差が平均の2倍以上あることからだけでも「ばらつきが大きい」データであることがわかります。多くの日が0(活動なし)で、たまに大きな値を含んでいます。

検出したい「異常」

以下の「異常」イベントが検出できるか試してみます。

  1. 2025年2月下旬〜3月: 入院期間 - 長期間の非活動
  2. 2025-06-09: 101コントリビューション - 最も活動が多かった日

手法1: Z-score(標準化スコア)

最もシンプルな方法です。「平均からどれだけ離れているか」を標準偏差で測ります。

$$
Z = \frac{|x - \mu|}{\sigma}
$$

平均値をゼロ地点としたとき、そこから「標準偏差($\sigma$)」で何本分離れているかを表すスコアです。

一般的に $Z > 3$(3シグマ)を異常とします。身長や体重のような綺麗な釣り鐘型の分布(正規分布)なら、物差し3本分外側には全体の0.3%しかデータが存在しません。

def detect_zscore(series: pd.Series, threshold: float = 3.0) -> dict:
    mean = series.mean()
    std = series.std()
    scores = (series - mean).abs() / std
    is_anomaly = scores > threshold
    return {'anomalies': series[is_anomaly]}

結果: 7件検出

主な検出:

  • 2024-12-31: 99
  • 2025-06-09: 101 (1年で最多の日)
  • 2025-06-10: 96
  • 2025-06-26: 95

zscore_detection.png

Z-scoreの注意点

Z-scoreは「正規分布」を仮定しています。しかしGitHubのコミット数は、0が多く右に長い裾を持つ分布です。コミットのような「稀に発生するカウントデータ」は、一般的に ポアソン分布 に従う性質があります(1時間の来客数、1日の交通事故数なども同様)。そのため厳密には統計的に妥当ではありません。

手法2: IQR(四分位範囲)

IQR (Interquartile Range) は、外れ値を含むデータに対してより頑健(ロバスト)な方法です。

$$
\text{異常} = x < Q_1 - 1.5 \times IQR \quad \text{or} \quad x > Q_3 + 1.5 \times IQR
$$

データの真ん中50%(箱ひげ図の「箱」の部分)の長さを基準にします。Z-scoreでは極端に大きすぎる値や小さすぎる値があるとそれ以外の異常が検出されにくくなりますが、IQRでは影響を受けにくくなり、外れ値があっても安定して計算できるのが特徴です。

boxplot_explanation.jpeg

※ NanoBananaPro で作った図です。最大値や最小値にふりがながついていますね…

係数 1.5 は一般的に使われる値で、これより外側を「外れ値」とみなします。

def detect_iqr(series: pd.Series, multiplier: float = 1.5) -> dict:
    Q1 = series.quantile(0.25)
    Q3 = series.quantile(0.75)
    IQR = Q3 - Q1

    lower_bound = Q1 - multiplier * IQR
    upper_bound = Q3 + multiplier * IQR
    is_anomaly = (series < lower_bound) | (series > upper_bound)
    return {'anomalies': series[is_anomaly]}

結果: 33件検出

iqr_detection.png

IQRはZ-scoreより多くの異常を検出しました。これはIQRが「中央50%の範囲」を基準にするため、0が多いデータでは閾値が低くなるためです。

IQRの注意点

GitHubデータのように0が多いと、Q1やQ3も0に近くなり、IQR自体が非常に小さくなります。結果として「少しコミットしただけで異常」と判定されるリスクがあります。

手法3: EWMA(指数加重移動平均)

過去のトレンドを考慮した方法です。直近のデータに重みを置いた移動平均からの乖離を見ます。

EWMA (Exponentially Weighted Moving Average) は、古いデータほど重みを小さくする移動平均です。span=7 は「おおよそ直近7日分のデータを重視する」という意味になります。

def detect_ewma(series: pd.Series, span: int = 7, threshold: float = 1.5) -> dict:
    ewm_mean = series.ewm(span=span).mean()
    ewm_std = series.ewm(span=span).std()
    scores = (series - ewm_mean).abs() / ewm_std
    is_anomaly = scores > threshold
    return {'anomalies': series[is_anomaly]}

pandasの ewm() は Exponentially Weighted Moving の略で、指数加重移動統計量を計算するメソッドです。mean() で移動平均、std() で移動標準偏差を取得できます。

結果: 13件検出

ewma_detection.png

EWMAの特徴は 適応的 な点です。活動が少ない期間の後に活動すると「その人にとっての異常」として検出されます。

閾値を1.5にした理由

閾値は通常は3.0を使いますが、分散が大きいデータだと検出されにくくなります。今回のデータでは3.0では何も検出できなかったため、1.5を使用しています。

手法4: 非活動期間の検出

上記3手法は「高い値」を検出しますが、「入院期間」のような非活動は検出できません。

実務でも機械のダウンタイム検出などで同様の課題があり、以下のような手法が使われることがあります。

  • Isolation Forest: ランダムにデータを分割していき、「少ない分割回数で孤立するデータ」を異常とみなす機械学習手法。正常データが密集している前提で、疎な領域のデータを検出します。
  • Change Point Detection(変化点検出): 時系列の統計的性質(平均、分散など)が変化した時点を特定する手法。「いつから非活動期間が始まったか」を検出できます。

今回は入門なので高度な手法は用いず、シンプルに「ローリングウィンドウでゼロをカウント」してみます。

ローリングウィンドウとは、データを一定の幅(ウィンドウ)で区切りながら、そのウィンドウを1つずつずらして計算を繰り返す手法です。例えば「7日間のウィンドウ」なら、1日目〜7日目、2日目〜8日目、3日目〜9日目...と順に計算していきます

def detect_inactivity(series: pd.Series, window: int = 7, threshold: int = 6) -> dict:
    # 7日間で6日以上ゼロなら異常
    zero_count = (series == 0).rolling(window=window).sum()
    is_anomaly = zero_count >= threshold
    return {'anomalies': series[is_anomaly]}

結果: 72件検出(複数の非活動期間)

inactivity_detection.png

2025年2月〜3月の入院期間も検出されました。

手法5: 非活動期間のZ-score検出

手法4では「7日中6日以上ゼロ」という固定閾値を使いましたが、少し工夫して「過去14日間のゼロの日数」という特徴量を新たに設定し、Z-scoreを適用してみました。

ある期間の合計値は正規分布に近づきやすい性質があるので(中心極限定理)、Z-scoreが適用しやすい分布になるという利点もあります。

def detect_inactivity_zscore(series: pd.Series, window: int = 14, threshold: float = 1.5) -> dict:
    # Step 1: ローリングウィンドウでゼロの日数をカウント
    zero_count = (series == 0).rolling(window=window).sum()

    # Step 2: zero_countのZ-scoreを計算
    mean = zero_count.mean()
    std = zero_count.std()
    scores = (zero_count - mean) / std

    # Step 3: 閾値を超えたものを異常とする
    is_anomaly = scores > threshold
    return {'anomalies': series[is_anomaly]}

結果: 50日検出(5つの非活動期間)

inactivity_zscore_detection.png

主な検出期間:

  • 2025-03-01 〜 03-16: 入院期間(16日間)
  • 2025-05-08 〜 05-13: GW明け(6日間)
  • 2025-09-20 〜 10-02: 秋の非活動期間(13日間)

手法4との違い = 特徴量エンジニアリング

手法4(固定閾値)は「7日中6日以上ゼロ」という 絶対基準 でした。手法5は「その人の平均的な非活動パターンからどれだけ外れているか」という 相対基準 になります。

活動量が少ない人と多い人で、同じ「非活動」でも意味が異なります。Z-scoreを使うことで、個人の活動パターンに適応した 検出が可能になります。

まとめ

1. データの分布を確認する

統計的手法は「正規分布」を仮定することが多いですが、実データはそうではないことがほとんどです。GitHubコミット数のような「0が多く、たまに大きい」データでは、閾値の調整が必要です。

2. 「異常」には種類がある

  • High anomaly: 値が異常に高い
  • Low anomaly: 値が異常に低い(または欠損)

目的に応じて手法を選ぶ、または組み合わせる必要があります。

3. 閾値はヒューリスティック

ヒューリスティックとは、理論的に最適な解を求めるのではなく、経験則や試行錯誤に基づいて「実用上うまくいく」値を見つけるアプローチです。

「3シグマ」で検出できれば理想的ですが、実務では「データを見ながら調整」が現実的です。今回もEWMAでは1.5まで下げました。

まとめ

GitHubコントリビューションデータを使って、5つの異常検知手法を試しました。

  • Z-score: シンプルだが正規分布を仮定
  • IQR: 外れ値に頑健だが、0が多いデータでは閾値が低くなりすぎる
  • EWMA: 適応的だが、閾値調整が必要
  • Inactivity(固定閾値): シンプルだが実用性は乏しい
  • Inactivity(Z-score): データのパターンに適応した検出が可能

実際のデータに異常検知を適用する際は、データの特性を理解し、目的に合った手法を選ぶことが重要ですね。


明日の Qiita Advent Calendar 2025 - 時系列データ 5日目も何か書く予定です

参考文献

下記を参考にさせていただきました

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?