はじめに
本記事では、UCI Machine Learning Repositoryで公開されている「Online Retail Dataset」を使用し、オンライン販売における返品・キャンセルの傾向を分析しました。
使用したデータはこちらです。
UCI Machine Learning Repository:Online Retail Dataset
このデータは、英国のオンライン小売企業で記録された、2010年12月から2011年12月までの取引明細データです。
全部で541,909行あり、1行が1回の注文全体ではなく、注文に含まれる1商品の明細を表しています。
主な列は次のとおりです。
| 列名 | 内容 |
|---|---|
| InvoiceNo | 注文番号。「C」で始まるものは返品・キャンセル |
| StockCode | 商品コード |
| Description | 商品名 |
| Quantity | 数量 |
| InvoiceDate | 注文日時 |
| UnitPrice | 商品単価 |
| CustomerID | 顧客番号 |
| Country | 顧客の国 |
このデータを使い、商品別、単価帯別、時間帯別に返品・キャンセルの傾向を確認しました。
さらに、キャンセル件数・キャンセル率・単価・売上を組み合わせて優先して確認したい商品を整理し、最後に決定木を使ったキャンセル候補の予測も試しました。
分析結果の概要
| 項目 | 結果 |
|---|---|
| 全取引明細 | 541,909件 |
| 返品・キャンセル | 9,288件 |
| 全体に占める割合 | 1.71% |
実行環境
- Google Colaboratory
- Python 3
- pandas
- matplotlib
- scikit-learn
1. データの読み込みと前処理
2010年12月1日から2011年12月9日までの英国オンライン小売データです。
InvoiceNoがCで始まる取引を、返品・キャンセルとして扱いました。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib
df = pd.read_excel("Online Retail.xlsx")
df["InvoiceNo"] = df["InvoiceNo"].astype(str)
df["InvoiceDate"] = pd.to_datetime(df["InvoiceDate"])
df["Description"] = df["Description"].fillna("商品名不明")
df["Country"] = df["Country"].fillna("Unknown")
df["返品キャンセル"] = (
df["InvoiceNo"].str.startswith("C").astype(int)
)
df["売上金額"] = df["Quantity"] * df["UnitPrice"]
df["月"] = df["InvoiceDate"].dt.month
df["曜日番号"] = df["InvoiceDate"].dt.dayofweek
df["時間"] = df["InvoiceDate"].dt.hour
total_count = len(df)
cancel_count = int(df["返品キャンセル"].sum())
cancel_rate = cancel_count / total_count * 100
2. 商品別に見てみた
商品別では、まずキャンセル件数が多い商品を確認し、次にキャンセル率が高い商品を確認しました。
この分析では、通常の商品同士を比較するため、Manual、POSTAGE、SAMPLESなどの通常の商品ではない項目は、商品ランキングから除外しました。
また、キャンセル率は取引が少ないと大きく変わりやすいため、キャンセル率の比較では総取引30件以上の商品に絞りました。
non_product_items = {
"Manual", "POSTAGE", "DOTCOM POSTAGE",
"AMAZON FEE", "Bank Charges", "Discount",
"CRUK Commission", "Adjust bad debt", "SAMPLES"
}
df_product = df[
(~df["Description"].isin(non_product_items))
& (df["Description"] != "商品名不明")
].copy()
product_summary = (
df_product.groupby("Description")
.agg(
総取引件数=("InvoiceNo", "count"),
キャンセル件数=("返品キャンセル", "sum"),
平均単価=("UnitPrice", "mean")
)
)
product_summary["キャンセル率"] = (
product_summary["キャンセル件数"]
/ product_summary["総取引件数"] * 100
)
sales_rows = df_product[
(df_product["返品キャンセル"] == 0)
& (df_product["Quantity"] > 0)
& (df_product["UnitPrice"] > 0)
]
product_summary["売上"] = (
sales_rows.groupby("Description")["売上金額"].sum()
)
product_summary["売上"] = product_summary["売上"].fillna(0)
count_top = (
product_summary
.nlargest(10, "キャンセル件数")
.sort_values("キャンセル件数")
)
ax = count_top["キャンセル件数"].plot(
kind="barh", figsize=(10, 6)
)
ax.bar_label(ax.containers[0], fmt="%.0f件")
plt.title("キャンセル件数が多い商品 TOP10")
plt.xlabel("キャンセル件数")
plt.tight_layout()
plt.show()
rate_top = (
product_summary[
product_summary["総取引件数"] >= 30
]
.nlargest(10, "キャンセル率")
.sort_values("キャンセル率")
)
ax = rate_top["キャンセル率"].plot(
kind="barh", figsize=(10, 6)
)
ax.bar_label(ax.containers[0], fmt="%.1f%%")
plt.title(
"キャンセル率が高い商品 TOP10"
"(総取引30件以上)"
)
plt.xlabel("キャンセル率(%)")
plt.tight_layout()
plt.show()
REGENCY CAKESTAND 3 TIERが181件で最も多く、ほかの商品との差も大きい結果でした。
次に、販売数が少ない商品の影響を抑えるため、総取引30件以上の商品に絞ってキャンセル率を比べました。
キャンセル件数とキャンセル率を分けて確認したことで、「よく売れているためキャンセル件数が多い商品」と「販売数に対してキャンセルが起きやすい商品」を分けて考えることができました。
3. 単価帯と時間帯を見てみた
単価帯別では、£1,000以上の120件を確認したところ、Manual、AMAZON FEE、DOTCOM POSTAGEなどの特殊処理のみで、通常商品は含まれていませんでした。そのため、通常商品の比較から除外しました。
df_price = df[
(df["UnitPrice"] >= 0)
& (df["UnitPrice"] < 1000)
].copy()
df_price["単価帯"] = pd.cut(
df_price["UnitPrice"],
bins=[0, 1, 5, 20, 100, 1000],
labels=[
"£1未満", "£1〜5未満", "£5〜20未満",
"£20〜100未満", "£100〜1,000未満"
],
right=False
)
price_summary = (
df_price.groupby("単価帯", observed=False)
["返品キャンセル"].mean() * 100
)
ax = price_summary.plot(
kind="bar", figsize=(10, 6), rot=20
)
ax.bar_label(ax.containers[0], fmt="%.1f%%")
plt.title(
"単価帯別の返品・キャンセル率"
"(£1,000以上を除く)"
)
plt.ylabel("返品・キャンセル率(%)")
plt.tight_layout()
plt.show()
hour_summary = (
df.groupby("時間")
.agg(
取引件数=("InvoiceNo", "count"),
キャンセル率=("返品キャンセル", "mean")
)
)
hour_summary["キャンセル率"] *= 100
hour_target = hour_summary[
hour_summary["取引件数"] >= 100
]
ax = hour_target["キャンセル率"].plot(
kind="bar", figsize=(10, 6), rot=0
)
ax.bar_label(
ax.containers[0], fmt="%.1f%%", fontsize=8
)
plt.title(
"時間帯別の返品・キャンセル率"
"(取引100件以上)"
)
plt.ylabel("キャンセル率(%)")
plt.tight_layout()
plt.show()
£1,000未満では、単価が高いほどキャンセル率も高くなる傾向が見られました。ただし、高単価帯は取引件数が少ないため、率だけでなく件数も一緒に見る必要があります。
時間帯別では、少数データの影響を抑えるため、取引100件以上の時間帯を対象にしました。なお、総取引30件・時間帯100件は、本分析で設定した基準であり、一般的な正解値ではありません。
19〜20時台は日中よりキャンセル率が高めでした。原因はこのデータだけでは分かりませんが、注文確認や在庫反映など、夜間帯の処理を確認する候補になりそうです。
また、6時台は41件中40件がキャンセルで、同じ日の短い時間に集中していました。通常の顧客行動というより、一括取消やシステム処理の可能性があると考えました。
4. 優先して確認したい商品を整理した
件数、率、単価、売上を別々に見るだけでは、どの商品を優先すべきか判断しにくいため、次の4条件を1点ずつ加点しました。
- キャンセル件数が多い
- キャンセル率が高い
- 平均単価が高い
- 売上が少ないのにキャンセルが複数ある
score_base = product_summary[
product_summary["総取引件数"] >= 30
]
count_border = score_base["キャンセル件数"].quantile(0.75)
rate_border = score_base["キャンセル率"].quantile(0.75)
price_border = product_summary["平均単価"].quantile(0.75)
sales_border = product_summary["売上"].median()
product_summary["件数上位"] = (
(product_summary["総取引件数"] >= 30)
& (product_summary["キャンセル件数"] >= count_border)
).astype(int)
product_summary["率上位"] = (
(product_summary["総取引件数"] >= 30)
& (product_summary["キャンセル率"] >= rate_border)
).astype(int)
product_summary["高単価"] = (
product_summary["平均単価"] >= price_border
).astype(int)
product_summary["低売上"] = (
(product_summary["売上"] <= sales_border)
& (product_summary["キャンセル件数"] >= 3)
).astype(int)
conditions = ["件数上位", "率上位", "高単価", "低売上"]
product_summary["優先確認スコア"] = (
product_summary[conditions].sum(axis=1)
)
priority = (
product_summary
.sort_values(
["優先確認スコア", "キャンセル件数"],
ascending=False
)
.head(8)
)
ax = (
priority["優先確認スコア"]
.sort_values()
.plot(kind="barh", figsize=(10, 6))
)
ax.bar_label(ax.containers[0], fmt="%.0f点")
plt.title("優先確認スコアによる商品ランキング")
plt.xlabel("優先確認スコア(0〜4点)")
plt.xlim(0, 4.3)
plt.tight_layout()
plt.show()
売上が少ないのにキャンセルが複数ある商品は、売上への貢献に対して、返品処理や返金対応の負担が相対的に大きい可能性があります。そのため、価格や在庫に加え、販売を続けるかも確認する商品としました。
| 商品名 | スコア | キャンセル件数 | キャンセル率 |
|---|---|---|---|
| SET/3 POT PLANT CANDLES | 4 | 4 | 11.11% |
| BEACH HUT SHELF W 3 DRAWERS | 4 | 3 | 8.82% |
| JARDIN ETCHED GLASS FRUITBOWL | 4 | 3 | 6.52% |
5. 決定木でキャンセル候補を予測してみた
最後に、単価・月・曜日・時間・国の5項目から、キャンセル候補を事前に予測できるかを決定木で試しました。
モデル作成時の補足
- 正解は
InvoiceNoがCで始まる取引ですが、予測ではC自体を使いません。 - 説明変数は単価・月・曜日・時間・国です。
-
Quantityは結果を直接示す可能性があるため除外しました。 -
CustomerIDは使わず欠損行を残しました。 - 国は上位5か国と
Otherにまとめ、One-Hot Encodingで数値化しました。 - 学習用80%、テスト用20%に分け、キャンセル比率を保つため
stratify=yを使用しました。 - キャンセルが全体の1.71%と少ないため、
class_weight="balanced"を設定しました。
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import classification_report, confusion_matrix
model_df = df[df["UnitPrice"] > 0].copy()
top_countries = model_df["Country"].value_counts().head(5).index
model_df["国分類"] = np.where(
model_df["Country"].isin(top_countries),
model_df["Country"],
"Other"
)
X = model_df[
["UnitPrice", "月", "曜日番号", "時間", "国分類"]
]
X = pd.get_dummies(
X, columns=["国分類"], drop_first=False
)
y = model_df["返品キャンセル"]
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.20,
random_state=0,
stratify=y
)
model = DecisionTreeClassifier(
max_depth=5,
class_weight="balanced",
random_state=0
)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(
classification_report(
y_test,
y_pred,
target_names=["通常取引", "返品・キャンセル"],
zero_division=0
)
)
cm = confusion_matrix(y_test, y_pred)
tn, fp, fn, tp = cm.ravel()
| 項目 | 結果 |
|---|---|
| 実際のキャンセル | 1,858件 |
| 候補にできた件数 | 1,110件 |
| 再現率 | 0.60 |
| 適合率 | 0.03 |
実際にキャンセルだった1,858件のうち、1,110件を候補として拾うことができました。再現率は0.60で、キャンセル10件のうち約6件を候補にできた計算です。
一方、適合率は0.03で、候補の中には通常取引も多く含まれました。評価にはclassification_reportを使い、正解率0.62、適合率0.03、再現率0.60、F1値0.05と混同行列を確認しています。
キャンセルが少ないため正解率だけで判断せず、再現率を中心に見ました。自動で注文を止める用途ではなく、担当者が確認する対象を広めに出す補助として使うのが現実的です。
6. どの項目が判定に使われたか
決定木がどの項目を多く使ったかを確認するため、特徴量重要度を見ました。特徴量重要度は、モデルが判定するときに各項目をどの程度使ったかを表す値で、すべての項目を合計すると1になります。
importance = (
pd.Series(
model.feature_importances_,
index=X.columns
)
.sort_values(ascending=False)
)
importance_top = (
importance[importance > 0]
.head(6)
)
name_map = {
"UnitPrice": "単価",
"曜日番号": "曜日",
"国分類_United Kingdom": "国_United Kingdom",
"国分類_Germany": "国_Germany"
}
importance_plot = (
importance_top
.rename(index=name_map)
.sort_values()
)
ax = importance_plot.plot(
kind="barh", figsize=(10, 5)
)
ax.bar_label(ax.containers[0], fmt="%.2f")
plt.title("決定木が判定に使った項目 上位6件")
plt.xlabel("特徴量重要度")
plt.tight_layout()
plt.show()
特徴量重要度では、単価と時間が判定に多く使われていました。たとえば単価の重要度0.42は、重要度全体を1としたとき約42%を占めたことを示します。
ただし、キャンセル原因の42%が単価という意味ではなく、このモデルが判定の手掛かりとして多く使ったという意味です。
7. 企業ならどこを確認するか
ここまでの分析結果をもとに、企業で実際に確認すべき内容を次の4つに整理しました。
| 確認すること | 代表例 | 判断の目安 | 対応例 |
|---|---|---|---|
| 商品改善 | REGENCY CAKESTAND 3 TIER JAM MAKING SET WITH JARS |
件数と率の両方が高い | 商品説明、画像、品質、在庫表示を確認 |
| 採算・販売継続 | SET/3 POT PLANT CANDLES BEACH HUT SHELF W 3 DRAWERS |
低売上でキャンセルが複数 | 価格、在庫、販売継続を検討 |
| 注文確認の強化 | REGENCY CAKESTAND 3 TIER RED RETROSPOT CAKE STAND |
高単価で他条件にも該当 | 価格、数量、在庫、配送条件を再確認 |
| 業務・システム | 6時台、19〜20時台 Manual・POSTAGE等 |
特殊な集中や商品以外の処理 | 一括取消、バッチ、注文確認、在庫反映を確認 |
分析結果だけでキャンセルの原因を断定することはできませんが、どの商品や業務処理を優先して確認するとよいかを整理できました。
8. 分析結果から考えたこと
商品別では、キャンセル件数とキャンセル率を分けて確認することで、販売数が多いためキャンセル件数が多い商品と、販売数に対してキャンセルが起きやすい商品を区別できました。
単価帯別では高単価帯ほどキャンセル率が高い傾向が見られ、時間帯別では6時台に特殊な処理と思われる集中がありました。また、19〜20時台は日中よりキャンセル率が高めだったため、夜間帯の注文確認や在庫反映などを追加で確認する対象として整理しました。
さらに、件数・率・単価・売上を組み合わせることで、優先して確認したい商品を整理できました。
決定木では、単価・月・曜日・時間・国の5項目から、実際のキャンセル10件のうち約6件を候補として拾うことができました。候補には通常取引も多く含まれるため自動判定には向きませんが、担当者が確認する一次候補を広めに挙げる補助として利用できる可能性があります。
このデータだけでは返品・キャンセルの原因までは断定できないため、在庫状況、配送状況、キャンセル理由などの情報を追加し、より具体的な原因分析や改善策の検討につなげることが今後の課題です。






