AIを導入して、投資対効果がどれくらいあったのか、気になりますよね?
サイクルタイム分析を用いて、
PRのオープン→マージ時間の外れ値を四分位範囲(IQR)で異常値を除外、
AI導入前後の中央値・平均を算出し、t検定で統計的有意性を確認してみました。
分析を始める前に決めること
先に白状すると、私はここを決めずに手を動かし始めて、後半でかなり遠回りしました。だからこそ最初に書いておきます。数値を出す前に、最低限この2つは決めておくべきでした。
-
誰に報告するための数値か?
経営層のROI判断用なら、金額換算まで持っていく代わりに精度は粗くてよい。チームの改善用なら、金額よりも「どこが・なぜ遅いのか」の内訳が要る。報告先が違えば、後述する除外条件の引き方も変わります。 -
どの精度で十分か?(=どの精度を捨てるか)
完璧なクレンジングは存在しません。「この用途ならこの誤差は許容する」と先に線を引かないと、私のように条件を変え続ける旅に出ることになります。
もう1つ大事なのが、除外条件は結果を見る前に決めておくこと。結果を見てから条件をいじるのは、「望む数値が出るまで条件を探す」行為(いわゆるp-hacking)と紙一重です。幸い今回は数値が控えめになる方向へ直していったので健全でしたが、逆方向だったら危険でした。この記事の後半はまさにその試行錯誤の記録なので、反面教師も兼ねてご覧ください。
まずはGitHubCLIをセットアップし、プルリクエストのデータをダウンロードします。
もちろんすでに設定済みの方は不要です。
セットアップ
$ gh -v
zsh: command not found: gh
# インストール
$ brew install gh
# ブラウザ等でログインし、認証
$ gh auth login
PRデータダウンロード
CSVでも良いですが、扱いやすいJSONもおすすめです。
$ gh pr list --state merged --limit 5000 \
--json createdAt,mergedAt,title,additions,deletions,author > pr_data.json
Pythonで分析
弊社では昨年10月ごろに導入を実施しターニングポイントとなっているはずと仮定し、まずはその前後で比べてみます。
データ分析では仮定が大事です。
分析コード V1
import pandas as pd
from scipy import stats
from datetime import datetime
# 1. JSONデータの読み込み
df = pd.read_json('pr_data.json')
# カラム名を統一(GitHub CLIのキャメルケースをスネークケースに変換)
df = df.rename(columns={'createdAt': 'created_at', 'mergedAt': 'merged_at'})
# データの変換
df['created_at'] = pd.to_datetime(df['created_at'])
df['merged_at'] = pd.to_datetime(df['merged_at'])
# サイクルタイム(時間)とPRサイズの合計を計算
df['cycle_time'] = (df['merged_at'] - df['created_at']).dt.total_seconds() / 3600
df['pr_size'] = df['additions'] + df['deletions']
# 2. 期間の分割
# タイムゾーン情報を取得して境界線を作成
tz = df['merged_at'].dt.tz
start_date = datetime(2025, 1, 1, tzinfo=tz)
border_date = datetime(2025, 10, 2, tzinfo=tz)
before_raw = df[(df['merged_at'] >= start_date) & (df['merged_at'] < border_date)].copy()
after_raw = df[df['merged_at'] >= border_date].copy()
# 3. IQRを用いた外れ値除去関数
def clean_iqr(data, col):
if len(data) == 0: return data
q1 = data[col].quantile(0.25)
q3 = data[col].quantile(0.75)
iqr = q3 - q1
return data[(data[col] >= q1 - 1.5 * iqr) & (data[col] <= q3 + 1.5 * iqr)]
# サイクルタイムに基づいて外れ値を除去
before = clean_iqr(before_raw, 'cycle_time')
after = clean_iqr(after_raw, 'cycle_time')
# 4. 統計出力
def print_stats(name, data):
if len(data) == 0:
print(f"--- {name} --- データがありません")
return
print(f"--- {name} (n={len(data)}) ---")
print(f" サイクルタイム 平均: {data['cycle_time'].mean():.2f}h / 中央値: {data['cycle_time'].median():.2f}h")
print(f" サイクルタイム 標準偏差: {data['cycle_time'].std():.2f}")
print(f" 平均PRサイズ(行数): {data['pr_size'].mean():.1f}行")
print_stats("Before (導入前)", before)
print_stats("After (導入後)", after)
# t検定
if len(before) > 1 and len(after) > 1:
t_stat, p_val = stats.ttest_ind(before['cycle_time'], after['cycle_time'], equal_var=False)
print(f"\nサイクルタイムのP値: {p_val:.4f}")
# 考察用:1時間あたりのコード生産量
before_speed = before['pr_size'].sum() / before['cycle_time'].sum()
after_speed = after['pr_size'].sum() / after['cycle_time'].sum()
print(f"\n参考:1時間あたりマージされた行数")
print(f" Before: {before_speed:.2f} lines/h")
print(f" After : {after_speed:.2f} lines/h")
結果
.
.
.
なんと導入以前と比較すると悪化していました。これはなぜでしょうか?
--- Before (導入前) (n=2166) ---
サイクルタイム 平均: 24.88h / 中央値: 2.25h
サイクルタイム 標準偏差: 42.24
平均PRサイズ(行数): 1852.4行
--- After (導入後) (n=2247) ---
サイクルタイム 平均: 29.30h / 中央値: 6.82h
サイクルタイム 標準偏差: 42.23
平均PRサイズ(行数): 1476.8行
サイクルタイムのP値: 0.0005
参考:1時間あたりマージされた行数
Before: 74.47 lines/h
After : 50.40 lines/h
外れ値の除外設定を追加
除外したもの
・パートナーさんやレビュー/デプロイ対応のみをメインで行う方(純粋に製造してPRを出すメンバーレベルの方の分析のみにフォーカス)
・5000行を超える大規模リファクタ(大規模修正でのAI活用度は、ここでは別の問いとして切り分け)
・300時間を超えるサイクルタイムのPR(放置PRは開発速度ではなく運用ルールの問題)
・wip|docs|refactor|chore|test|releaseといったタイトルのPRを除外(機能開発のPRに絞る)
ここで意識したいのは、これらが単なるノイズ除去ではなく、「誰の・どの作業の速度を測るか」という測定対象の定義変更だということです。パートナーさんを除いた時点で、この数値は「製造メンバーの生産性」であって「組織全体の効果」ではなくなります。経営層への報告に使うなら、この但し書きごとセットで伝える必要があります。
--- 期間比較サマリー (IQRによる外れ値除外済み) ---
期間 PR件数 中央値(h) 平均(h) 平均サイズ
Before (〜2025/09) 532 3.644028 16.584101 189.939850
After (2025/10〜) 443 1.541667 12.176780 124.027088
統計的P値 (p-value): 0.0001
【判定】有意な差が認められます (p < 0.05)
中央値ベースで 57.7% の改善が見られます。
結論:生産性は向上したと言えます。
データクレンジングを施すと、対象のPRが半減してしまいましたが、上記のような結果を得られました。
$p < 0.001$ ということは、この改善が偶然起こる確率は 0.1%未満 ということになります。
10月に導入したGitHubCopilotを含め、確実に成果として現れていると解釈できます。
平均値の差は小さいため、規模が大きい修正に対しては、活用度が低いとも考えられます。
といってもAIを導入しただけで50%も中央値が下がるのでしょうか?
平均値も25%下がっており、効果として大きすぎる気がします。
分析コード V2
import pandas as pd
import re
import numpy as np
from scipy import stats
# --- 1. 初期設定 & ホワイトリスト ---
raw_text = """
xxx yyy zzz
"""
WHITE_LIST = re.findall(r'[a-zA-Z0-9\-_]+', raw_text)
EXCLUDE_KEYWORDS = r'wip|docs|refactor|chore|test|release'
MAX_PR_SIZE = 5000
MAX_CYCLE_TIME = 300
# --- 2. データの読み込みと基本クレンジング ---
df = pd.read_json('pr_data.json')
df = df.rename(columns={'createdAt': 'created_at', 'mergedAt': 'merged_at'})
df['author'] = df['author'].apply(lambda x: x['login'] if isinstance(x, dict) else 'unknown')
df['created_at'] = pd.to_datetime(df['created_at'])
df['merged_at'] = pd.to_datetime(df['merged_at'])
df['cycle_time'] = (df['merged_at'] - df['created_at']).dt.total_seconds() / 3600
df['pr_size'] = df['additions'] + df['deletions']
# 基本フィルタリング
df = df[df['author'].isin(WHITE_LIST)]
df = df[df['pr_size'] <= MAX_PR_SIZE]
df = df[~df['title'].str.contains(EXCLUDE_KEYWORDS, case=False, na=False)]
df = df[df['cycle_time'] <= MAX_CYCLE_TIME]
# --- 3. データの分割とIQRによる外れ値除外 ---
split_date = pd.Timestamp('2025-10-01').tz_localize('UTC')
def filter_outliers_iqr(data, column):
if data.empty:
return data
Q1 = data[column].quantile(0.25)
Q3 = data[column].quantile(0.75)
IQR = Q3 - Q1
# 一般的な1.5倍設定(より厳しくしたい場合は1.2倍などに調整可能)
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
return data[(data[column] >= lower_bound) & (data[column] <= upper_bound)]
# 期間ごとにIQRフィルタを適用
df_before_raw = df[df['merged_at'] < split_date].copy()
df_after_raw = df[df['merged_at'] >= split_date].copy()
df_before = filter_outliers_iqr(df_before_raw, 'cycle_time')
df_after = filter_outliers_iqr(df_after_raw, 'cycle_time')
# --- 4. 統計的分析 ---
def summarize(data, name):
if data.empty:
return {'期間': name, 'PR件数': 0, '中央値(h)': np.nan, '平均(h)': np.nan, '平均サイズ': np.nan}
return {
'期間': name,
'PR件数': len(data),
'中央値(h)': data['cycle_time'].median(),
'平均(h)': data['cycle_time'].mean(),
'平均サイズ': data['pr_size'].mean()
}
summary_df = pd.DataFrame([
summarize(df_before, 'Before (〜2025/09)'),
summarize(df_after, 'After (2025/10〜)')
])
# Mann-Whitney U検定
u_stat, p_value = stats.mannwhitneyu(
df_before['cycle_time'].dropna(),
df_after['cycle_time'].dropna(),
alternative='greater'
)
# --- 5. 結果の表示 ---
print(f"--- 期間比較サマリー (IQRによる外れ値除外済み) ---")
print(summary_df.to_string(index=False))
print(f"\n統計的P値 (p-value): {p_value:.4f}")
if p_value < 0.05:
diff_percent = (1 - (summary_df.loc[1, '中央値(h)'] / summary_df.loc[0, '中央値(h)'])) * 100
print(f"【判定】有意な差が認められます (p < 0.05)")
print(f"中央値ベースで {diff_percent:.1f}% の改善が見られます。")
else:
print("【判定】有意な差は認められません (p >= 0.05)")
チームやPJごとに分析
WHITE_LISTを変更すると、全く違う数値にもなり得ます。
例えば1月から1人デプロイ対応のみを行う方が増えていたので、除いたところ下記のような結果になりました。
中でも若干特殊な対応をしていただけに、たった1名除いただけで中央値が4時間増え、Beforeは変わらずですが、Afterの数値がかなり悪化しました。
たった1名で「有意差あり」が「有意差なし」まで反転するということは、報告に使う際は母集団の定義(誰を含めた数値か)を必ずセットで添えないと、数値だけが一人歩きするということです。
このようにサイクルタイムが安定した指標ではなく、PRのルールが厳守されていることが前提になってしまうことも再度確認できました。
良い数値に振り回されず、常に客観的にデータと向き合いたいですね。
--- 期間比較サマリー (IQRによる外れ値除外済み) ---
期間 PR件数 中央値(h) 平均(h) 平均サイズ
Before (〜2025/09) 504 3.465417 15.804091 191.515873
After (2025/10〜) 380 5.610694 21.833768 139.400000
統計的P値 (p-value): 0.9969
【判定】有意な差は認められません (p >= 0.05)
期間も変更してみる
結果部分が自動的反映されず恐縮ですが、正直組織に馴染んでいって活用度が上がるには時間がかかります。そこで、2025-12-01を境界とすると下記のようになりました。
有意な差は認められないとのことですが、中央値が50%減少しております。
この境界日の置き方が影響するのは、「いつ効果を判定するか」という投資判断のタイミングです。導入直後に測れば定着前の数値で過小評価になり、早すぎる「効果なし」判定は撤退や追加投資の判断を誤らせます。
--- 期間比較サマリー (IQRによる外れ値除外済み) ---
期間 PR件数 中央値(h) 平均(h) 平均サイズ
Before (〜2025/11) 662 4.97375 18.195661 179.691843
After (2025/12〜) 221 2.54750 18.284291 139.429864
統計的P値 (p-value): 0.2775
【判定】有意な差は認められません (p >= 0.05)
評価: サイクルタイムのような「極端に長い方向に裾を引くデータ」には、平均値の差を見るt検定よりも、順位に基づくU検定の方が適切です。
サイクルタイムは正規分布しないことが多いため、より外れ値に強いノンパラメトリック検定(U検定)に切り替えました。
グラフの作成
下記のコードを付け足して、グラフを出力してみました。
改めて視覚的に外れ値の影響などを確認できるため、初期の段階でグラフ化することも大事ですね。
グラフ作成コード
import matplotlib.pyplot as plt
import seaborn as sns
# --- 6. 月次推移の可視化 ---
# 1. データの準備
df_plot = df.copy()
df_plot['month'] = df_plot['merged_at'].dt.to_period('M').astype(str)
df_plot = df_plot.sort_values('month')
# 2. グラフのスタイル設定
sns.set_theme(style="whitegrid")
plt.figure(figsize=(12, 6))
# 3. 箱ひげ図の描画(月ごとの分布)
ax = sns.boxplot(
data=df_plot, x='month', y='cycle_time',
showfliers=False, # 外れ値を非表示にしてメインの分布を強調
palette="Blues"
)
# 4. 中央値の推移を折れ線グラフで重ねる
monthly_median = df_plot.groupby('month')['cycle_time'].median()
sns.lineplot(
x=range(len(monthly_median)), y=monthly_median.values,
marker='o', color='red', label='Median Cycle Time'
)
# 5. 導入時期に縦線を入れる
plt.axvline(x=len(monthly_median)-4.5, color='orange', linestyle='--', linewidth=2, label='AI Introduction')
# 6. ラベルとタイトルの設定
plt.title('Monthly Cycle Time Trend (After Data Cleansing)', fontsize=16)
plt.xlabel('Month', fontsize=12)
plt.ylabel('Cycle Time (hours)', fontsize=12)
plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
最終調整
1度RVしたものなど、PRをマージにだけ使う場合も多く、1時間以内にマージしているものはAI導入の効果測定外とすべきという意見があったので反映してみると、下記のようになりました。
これによって、そもそものサイクルタイムが数時間と短すぎるのも解決しました。
この「1時間以内を除外」も測定対象の定義変更で、「マージ作業だけのPRを開発速度として数えない」という判断です。効果を小さく見せる方向の条件ですが、だからこそ入れる価値があります。
--- 期間比較サマリー (IQRによる外れ値除外済み) ---
期間 PR件数 中央値(h) 平均(h) 平均サイズ
Before (〜2025/11) 512 20.253194 39.347340 210.910156
After (2025/12〜) 154 19.255972 32.191999 179.214286
統計的P値 (p-value): 0.0494
【判定】有意な差が認められます (p < 0.05)
中央値ベースで 4.9% の改善が見られます。
改善幅は最初の57.7%から4.9%まで縮み、p値も0.0494とギリギリです。それでも経営層への報告の土台に載せるなら、盛れる57.7%ではなく、除外理由をすべて説明できるこの4.9%の方だと考えています。「どの数値を意思決定に使うか」を決めるところまでが分析者の仕事です。
分析コード V3
import pandas as pd
import re
import numpy as np
from scipy import stats
# --- 1. 初期設定 & ホワイトリスト ---
raw_text = """
xxx yyy zzz
"""
WHITE_LIST = re.findall(r'[a-zA-Z0-9\-_]+', raw_text)
EXCLUDE_KEYWORDS = r'wip|docs|refactor|chore|test|release'
MAX_PR_SIZE = 5000
MAX_CYCLE_TIME = 500
MIN_CYCLE_TIME = 1
# --- 2. データの読み込みと基本クレンジング ---
df = pd.read_json('pr_data.json')
df = df.rename(columns={'createdAt': 'created_at', 'mergedAt': 'merged_at'})
df['author'] = df['author'].apply(lambda x: x['login'] if isinstance(x, dict) else 'unknown')
df['created_at'] = pd.to_datetime(df['created_at'])
df['merged_at'] = pd.to_datetime(df['merged_at'])
df['cycle_time'] = (df['merged_at'] - df['created_at']).dt.total_seconds() / 3600
df['pr_size'] = df['additions'] + df['deletions']
# 基本フィルタリング
df = df[df['author'].isin(WHITE_LIST)]
df = df[df['pr_size'] <= MAX_PR_SIZE]
df = df[~df['title'].str.contains(EXCLUDE_KEYWORDS, case=False, na=False)]
df = df[(df['cycle_time'] <= MAX_CYCLE_TIME) & (df['cycle_time'] >= MIN_CYCLE_TIME)]
# --- 3. データの分割とIQRによる外れ値除外 ---
split_date = pd.Timestamp('2025-12-01').tz_localize('UTC')
def filter_outliers_iqr(data, column):
if data.empty:
return data
Q1 = data[column].quantile(0.25)
Q3 = data[column].quantile(0.75)
IQR = Q3 - Q1
# 一般的な1.5倍設定(より厳しくしたい場合は1.2倍などに調整可能)
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
return data[(data[column] >= lower_bound) & (data[column] <= upper_bound)]
# 期間ごとにIQRフィルタを適用
df_before_raw = df[df['merged_at'] < split_date].copy()
df_after_raw = df[df['merged_at'] >= split_date].copy()
df_before = filter_outliers_iqr(df_before_raw, 'cycle_time')
df_after = filter_outliers_iqr(df_after_raw, 'cycle_time')
# --- 4. 統計的分析 ---
def summarize(data, name):
if data.empty:
return {'期間': name, 'PR件数': 0, '中央値(h)': np.nan, '平均(h)': np.nan, '平均サイズ': np.nan}
return {
'期間': name,
'PR件数': len(data),
'中央値(h)': data['cycle_time'].median(),
'平均(h)': data['cycle_time'].mean(),
'平均サイズ': data['pr_size'].mean()
}
summary_df = pd.DataFrame([
summarize(df_before, 'Before (〜2025/11)'),
summarize(df_after, 'After (2025/12〜)')
])
# Mann-Whitney U検定
u_stat, p_value = stats.mannwhitneyu(
df_before['cycle_time'].dropna(),
df_after['cycle_time'].dropna(),
alternative='greater'
)
# --- 5. 結果の表示 ---
print(f"--- 期間比較サマリー (IQRによる外れ値除外済み) ---")
print(summary_df.to_string(index=False))
print(f"\n統計的P値 (p-value): {p_value:.4f}")
if p_value < 0.05:
diff_percent = (1 - (summary_df.loc[1, '中央値(h)'] / summary_df.loc[0, '中央値(h)'])) * 100
print(f"【判定】有意な差が認められます (p < 0.05)")
print(f"中央値ベースで {diff_percent:.1f}% の改善が見られます。")
else:
print("【判定】有意な差は認められません (p >= 0.05)")
import matplotlib.pyplot as plt
import seaborn as sns
# --- 6. 月次推移の可視化 ---
# 1. データの準備
df_plot = df.copy()
df_plot['month'] = df_plot['merged_at'].dt.to_period('M').astype(str)
df_plot = df_plot.sort_values('month')
# 2. グラフのスタイル設定
sns.set_theme(style="whitegrid")
plt.figure(figsize=(12, 6))
# 3. 箱ひげ図の描画(月ごとの分布)
ax = sns.boxplot(
data=df_plot, x='month', y='cycle_time',
showfliers=False, # 外れ値を非表示にしてメインの分布を強調
palette="Blues"
)
# 4. 中央値の推移を折れ線グラフで重ねる
monthly_median = df_plot.groupby('month')['cycle_time'].median()
sns.lineplot(
x=range(len(monthly_median)), y=monthly_median.values,
marker='o', color='red', label='Median Cycle Time'
)
# 5. 導入時期に縦線を入れる
plt.axvline(x=len(monthly_median)-4.5, color='orange', linestyle='--', linewidth=2, label='AI Introduction')
# 6. ラベルとタイトルの設定
plt.title('Monthly Cycle Time Trend (After Data Cleansing)', fontsize=16)
plt.xlabel('Month', fontsize=12)
plt.ylabel('Cycle Time (hours)', fontsize=12)
plt.legend()
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
ROI計算
数値が出てしまえば、残りの計算は単純です。
中央値が5%改善し、1つのPRあたり約1時間の短縮の効果が見られた
→「月間100PRあるチームなら、合計100時間の工数削減効果が見込まれる。エンジニアの時給を5,000円と仮定すると、月50万円分のリードタイム短縮効果」といった金額換算になります。
いかに数値を出すのが難しい、ということがわかります。
条件をひとつ動かすたびに、この金額換算の結論まで揺れるわけです。難しいからこそ、冒頭に書いた「誰のための数値か」「どの精度を捨てるか」を手を動かす前に決めておく。これが今回一番の学びでした。
分析して終わりにしない——運用のループを閉じる
実は今回一番堅い発見は、AIの効果そのものより「サイクルタイムが指標として機能する前提が、まだ揃っていない」ことでした。300時間放置されるPR、マージ作業にだけ使われるPR、規約から外れたタイトルのPR——クレンジングコードの行数は、そのまま運用ルールの乱れの量です。
なのでこの結果は「測って終わり」にせず、運用に返します。
- PR運用ルールの見直し: マージ専用PRやWIPの扱いをルール化できれば、次回はV3のような除外条件の多くが不要になります。クレンジングが楽になる=指標が信頼できるようになる、です
- 前提を保つ仕組み: ルールは作っても風化するので、PRタイトル規約のCIチェックなど、機械的に守られる形に落とすところまでがセットです
- 同じ条件で定点観測: 期間境界の試行で見た通り、定着にはラグがあります。今回のV3スクリプトと除外条件をそのまま残し、次の四半期に同条件で再計測するところから始めます
サイクルタイムは「測る→運用を直す→また測る」のループを回して初めて意味を持つ指標になる、というのが現時点の結論です。
参考記事
リードタイムは 45%短縮
スループット(1時間あたり処理PR数)は 82%向上
とのことで、弊社も負けてられないですね!

