この記事は Qiita Advent Calendar 2025 - 時系列データ の4日目の記事です。
はじめに
Day 2の記事ではGitHubコントリビューションデータを取得・可視化しました。今回はそのデータを使って 時系列データの異常検知 をやっています。
異常検知とは
時系列データにおける「異常」とは、通常のパターンから大きく外れた値やイベントのことです。
- スパイク(急上昇): 突然値が跳ね上がる
- ドロップ(急降下): 突然値が下がる
- 非活動期間: 本来あるべき活動がない期間
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(活動なし)で、たまに大きな値を含んでいます。
検出したい「異常」
以下の「異常」イベントが検出できるか試してみます。
- 2025年2月下旬〜3月: 入院期間 - 長期間の非活動
- 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
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では影響を受けにくくなり、外れ値があっても安定して計算できるのが特徴です。
※ 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は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の特徴は 適応的 な点です。活動が少ない期間の後に活動すると「その人にとっての異常」として検出されます。
閾値を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件検出(複数の非活動期間)
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つの非活動期間)
主な検出期間:
- 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日目も何か書く予定です
参考文献
下記を参考にさせていただきました





