論文実装:CRMにおけるリスク分析
論文実装:CRMにおけるリスク分析
今回の論文
まず、論文の内容ですが、ざっくり次のような感じです。(英語があまり得意ではないので、違っていたらご指摘ください。)
概要
本論文は、顧客関係管理(CRM)におけるリスク分析を対象とし、Quantile Region Convolutional Neural Network(QRCNN)とLong Short-Term Memory(LSTM)、さらにCross-Attention Mechanismを組み合わせた新しいモデルを提案している。提案手法は、従来モデルの限界である非線形性や時系列依存性、特徴間の相関をより精緻に捉えることを目的としている。
目的・背景
CRMにおいては、顧客行動や信用リスクの予測が重要である。しかし、従来の回帰分析やシンプルなニューラルネットでは、データの不確実性・異質性を十分に扱うことができず、予測の頑健性に課題があった。本研究は、金融リスク評価や顧客離反予測など、顧客データにおける高次元かつノイズの多い特徴量を効果的に処理するために設計されている。
先行研究
従来の研究は以下のようなアプローチをとってきた:
-
回帰モデルや統計的手法による信用リスク分析
-
LSTMなどを用いた時系列データ解析
-
CNNを利用した局所特徴の抽出
しかし、これらの手法では予測分布の分位点を扱うことや、異なる特徴間の依存関係を統合的に考慮することが難しかった。
調査・実験・検証内容
-
提案モデル構造
-
QRCNN:入力データから局所的特徴を抽出し、分位点ごとの不確実性を捉える。
-
LSTM:時間依存関係をモデル化し、時系列的な顧客行動を捕捉。
-
Cross-Attention Mechanism:異なる特徴間の重要性を動的に評価し、情報の統合を強化。
-
-
実験設定
-
実データとして顧客関連のCRMデータセットを使用。
-
提案手法を既存手法(標準的なLSTM、CNN、Attentionベースモデル)と比較。
-
評価指標は予測精度、リスク推定の分位点精度、ロバスト性。
-
-
結果
-
提案モデルは既存手法を上回る精度を達成。
-
特に分位点予測において不確実性を的確に捉え、外れ値や極端値に対して頑健な性能を示した。
-
Cross-Attentionの導入により、複雑な顧客行動の相互関係がより明確にモデル化された。
-
実施内容
以下では、ノートブック構成をかいつまんで紹介し、重要なコードを抜粋して説明します。
データは、Kaggleにあったcredit_card_defaultを用いています。
データ取り込みと前処理
import os, io, math, random, urllib.request, zipfile
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow.keras import layers, Model
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import kagglehub
# -------------------------------------------------------
# 1) データ取得
# ソース: https://www.kaggle.com/datasets/pratjain/credit-card-default?select=credit_card_default_TRAIN.csv
# -------------------------------------------------------
path = kagglehub.dataset_download("pratjain/credit-card-default")
df = pd.read_csv(path + "/credit_card_default_TRAIN.csv")
# カラム名を手動で設定
new_columns = [
'ID', 'LIMIT_BAL', 'SEX', 'EDUCATION', 'MARRIAGE', 'AGE', 'PAY_0', 'PAY_2',
'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6', 'BILL_AMT1', 'BILL_AMT2', 'BILL_AMT3',
'BILL_AMT4', 'BILL_AMT5', 'BILL_AMT6', 'PAY_AMT1', 'PAY_AMT2', 'PAY_AMT3',
'PAY_AMT4', 'PAY_AMT5', 'PAY_AMT6', 'default payment next month'
]
df.columns = new_columns
df = df.drop(0).reset_index(drop=True)
# 数値データをINT型へ
columns_to_convert = [
'AGE', 'PAY_0', 'PAY_2', 'PAY_3', 'PAY_4', 'PAY_5', 'PAY_6',
'BILL_AMT1', 'BILL_AMT2', 'BILL_AMT3', 'BILL_AMT4', 'BILL_AMT5', 'BILL_AMT6',
'PAY_AMT1', 'PAY_AMT2', 'PAY_AMT3', 'PAY_AMT4', 'PAY_AMT5', 'PAY_AMT6',
'LIMIT_BAL', 'SEX', 'EDUCATION', 'MARRIAGE' # Include these columns as they are likely causing issues as well
]
for col in columns_to_convert:
df[col] = pd.to_numeric(df[col], errors='coerce')
display(df.head())
モデル(QRCNN + LSTM + Cross-Attention)
# -------------------------------------------------------
# 6) QRCNN + LSTM + Cross-Attention モデル
# - Conv1Dで時系列の局所パターン抽出(QRCNNの近似)
# - LSTMで長期依存
# - 静的特徴をMLPで埋め込み
# - Cross-Attention: Query=静的埋め込み(1トークン化), Key/Value=時系列埋め込み
# - 出力で3分位を同時に予測
# -------------------------------------------------------
def build_qrcnn_lstm_crossattn(
T, C, static_dim, conv_filters=64, kernel_size=2,
lstm_units=64, static_hidden=64, attn_heads=4, attn_key_dim=32, ff_hidden=128, Q=3
):
# 時系列入力
in_seq = layers.Input(shape=(T, C), name="seq")
x = layers.Conv1D(conv_filters, kernel_size=kernel_size, padding="causal", activation="relu")(in_seq)
x = layers.BatchNormalization()(x)
x = layers.Conv1D(conv_filters, kernel_size=kernel_size, padding="causal", activation="relu")(x)
x = layers.BatchNormalization()(x)
x = layers.LSTM(lstm_units, return_sequences=True)(x) # (B, T, lstm_units)
# 静的入力
in_static = layers.Input(shape=(static_dim,), name="static")
s = layers.Dense(static_hidden, activation="relu")(in_static)
s = layers.BatchNormalization()(s)
# 1トークンにしてAttentionのQueryに使う
s_token = layers.Reshape((1, static_hidden))(s)
# Cross-Attention
attn = layers.MultiHeadAttention(num_heads=attn_heads, key_dim=attn_key_dim)
# Query = 静的(1, d), Key/Value = 時系列(T, d_k)
# Key/Value次元合わせのために線形変換
kv = layers.Dense(attn_heads * attn_key_dim, activation=None)(x) # (B,T,H*D)
kv = layers.Reshape((T, attn_heads * attn_key_dim))(kv)
# MultiHeadAttention expects [B,T,d]; it will project internally; pass x directly
cross = attn(query=s_token, value=x, key=x) # (B, 1, d_v=lstm_units)
# skip-connection: s_token と cross を結合
fuse = layers.Concatenate(axis=-1)([s_token, cross]) # (B,1, static_hidden + lstm_units)
fuse = layers.Flatten()(fuse)
fuse = layers.Dense(ff_hidden, activation="relu")(fuse)
fuse = layers.Dropout(0.2)(fuse)
out = layers.Dense(Q, activation=None, name="quantiles")(fuse)
model = Model(inputs=[in_seq, in_static], outputs=out, name="QRCNN_LSTM_CrossAttn")
model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss=multi_quantile_pinball)
return model
model = build_qrcnn_lstm_crossattn(
T=T, C=SEQ_CHANNELS, static_dim=X_static_train.shape[1],
conv_filters=64, kernel_size=2, lstm_units=64,
static_hidden=64, attn_heads=4, attn_key_dim=32, ff_hidden=128, Q=Q
)
model.summary()
学習ループ(Pinball Loss最小化)
# -------------------------------------------------------
# 5) カスタム Pinball (Quantile) Loss
# 複数分位同時学習: q_list = [0.1,0.5,0.9]
# 出力は次元=3、損失は平均
# -------------------------------------------------------
q_list = [0.1, 0.5, 0.9]
Q = len(q_list)
def multi_quantile_pinball(y_true, y_pred):
# y_pred: (batch, Q)
y_true = tf.expand_dims(y_true, axis=-1) # (batch, 1)
e = y_true - y_pred # (batch, Q)
losses = []
for i, q in enumerate(q_list):
ei = e[:, i]
loss_q = tf.maximum(q * ei, (q - 1) * ei) # pinball
losses.append(loss_q)
return tf.reduce_mean(tf.add_n(losses) / Q)
# -------------------------------------------------------
# 6) QRCNN + LSTM + Cross-Attention モデル
# - Conv1Dで時系列の局所パターン抽出(QRCNNの近似)
# - LSTMで長期依存
# - 静的特徴をMLPで埋め込み
# - Cross-Attention: Query=静的埋め込み(1トークン化), Key/Value=時系列埋め込み
# - 出力で3分位を同時に予測
# -------------------------------------------------------
def build_qrcnn_lstm_crossattn(
T, C, static_dim, conv_filters=64, kernel_size=2,
lstm_units=64, static_hidden=64, attn_heads=4, attn_key_dim=32, ff_hidden=128, Q=3
):
# 時系列入力
in_seq = layers.Input(shape=(T, C), name="seq")
x = layers.Conv1D(conv_filters, kernel_size=kernel_size, padding="causal", activation="relu")(in_seq)
x = layers.BatchNormalization()(x)
x = layers.Conv1D(conv_filters, kernel_size=kernel_size, padding="causal", activation="relu")(x)
x = layers.BatchNormalization()(x)
x = layers.LSTM(lstm_units, return_sequences=True)(x) # (B, T, lstm_units)
# 静的入力
in_static = layers.Input(shape=(static_dim,), name="static")
s = layers.Dense(static_hidden, activation="relu")(in_static)
s = layers.BatchNormalization()(s)
# 1トークンにしてAttentionのQueryに使う
s_token = layers.Reshape((1, static_hidden))(s)
# Cross-Attention
attn = layers.MultiHeadAttention(num_heads=attn_heads, key_dim=attn_key_dim)
# Query = 静的(1, d), Key/Value = 時系列(T, d_k)
# Key/Value次元合わせのために線形変換
kv = layers.Dense(attn_heads * attn_key_dim, activation=None)(x) # (B,T,H*D)
kv = layers.Reshape((T, attn_heads * attn_key_dim))(kv)
# MultiHeadAttention expects [B,T,d]; it will project internally; pass x directly
cross = attn(query=s_token, value=x, key=x) # (B, 1, d_v=lstm_units)
# skip-connection: s_token と cross を結合
fuse = layers.Concatenate(axis=-1)([s_token, cross]) # (B,1, static_hidden + lstm_units)
fuse = layers.Flatten()(fuse)
fuse = layers.Dense(ff_hidden, activation="relu")(fuse)
fuse = layers.Dropout(0.2)(fuse)
out = layers.Dense(Q, activation=None, name="quantiles")(fuse)
model = Model(inputs=[in_seq, in_static], outputs=out, name="QRCNN_LSTM_CrossAttn")
model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss=multi_quantile_pinball)
return model
model = build_qrcnn_lstm_crossattn(
T=T, C=SEQ_CHANNELS, static_dim=X_static_train.shape[1],
conv_filters=64, kernel_size=2, lstm_units=64,
static_hidden=64, attn_heads=4, attn_key_dim=32, ff_hidden=128, Q=Q
)
model.summary()
評価(PICP・Sharpness・Pinball)
学習後に以下の指標を算出しています:
PICP(q10-q90): {picp:.4
Sharpness(q90-q10 width): {sharpness:.6
PICP(q10-q90): {picp_l:.4
Sharpness: {sharpness_l:.6
PICP(q10-q90): {picp_n:.4
Sharpness: {sharpness_n:.6
PICP = 0.8536
Sharpness = 0.229
PICP = 0.7861
Sharpness = 0.185
PICP = 0.9043
Sharpness = 0.939
# -------------------------------------------------------
# 5) カスタム Pinball (Quantile) Loss
# 複数分位同時学習: q_list = [0.1,0.5,0.9]
# 出力は次元=3、損失は平均
# -------------------------------------------------------
q_list = [0.1, 0.5, 0.9]
Q = len(q_list)
def multi_quantile_pinball(y_true, y_pred):
# y_pred: (batch, Q)
y_true = tf.expand_dims(y_true, axis=-1) # (batch, 1)
e = y_true - y_pred # (batch, Q)
losses = []
for i, q in enumerate(q_list):
ei = e[:, i]
loss_q = tf.maximum(q * ei, (q - 1) * ei) # pinball
losses.append(loss_q)
return tf.reduce_mean(tf.add_n(losses) / Q)
# -------------------------------------------------------
# 6) QRCNN + LSTM + Cross-Attention モデル
# - Conv1Dで時系列の局所パターン抽出(QRCNNの近似)
# - LSTMで長期依存
# - 静的特徴をMLPで埋め込み
# - Cross-Attention: Query=静的埋め込み(1トークン化), Key/Value=時系列埋め込み
# - 出力で3分位を同時に予測
# -------------------------------------------------------
def build_qrcnn_lstm_crossattn(
T, C, static_dim, conv_filters=64, kernel_size=2,
lstm_units=64, static_hidden=64, attn_heads=4, attn_key_dim=32, ff_hidden=128, Q=3
):
# 時系列入力
in_seq = layers.Input(shape=(T, C), name="seq")
x = layers.Conv1D(conv_filters, kernel_size=kernel_size, padding="causal", activation="relu")(in_seq)
x = layers.BatchNormalization()(x)
x = layers.Conv1D(conv_filters, kernel_size=kernel_size, padding="causal", activation="relu")(x)
x = layers.BatchNormalization()(x)
x = layers.LSTM(lstm_units, return_sequences=True)(x) # (B, T, lstm_units)
# 静的入力
in_static = layers.Input(shape=(static_dim,), name="static")
s = layers.Dense(static_hidden, activation="relu")(in_static)
s = layers.BatchNormalization()(s)
# 1トークンにしてAttentionのQueryに使う
s_token = layers.Reshape((1, static_hidden))(s)
# Cross-Attention
attn = layers.MultiHeadAttention(num_heads=attn_heads, key_dim=attn_key_dim)
# Query = 静的(1, d), Key/Value = 時系列(T, d_k)
# Key/Value次元合わせのために線形変換
kv = layers.Dense(attn_heads * attn_key_dim, activation=None)(x) # (B,T,H*D)
kv = layers.Reshape((T, attn_heads * attn_key_dim))(kv)
# MultiHeadAttention expects [B,T,d]; it will project internally; pass x directly
cross = attn(query=s_token, value=x, key=x) # (B, 1, d_v=lstm_units)
# skip-connection: s_token と cross を結合
fuse = layers.Concatenate(axis=-1)([s_token, cross]) # (B,1, static_hidden + lstm_units)
fuse = layers.Flatten()(fuse)
fuse = layers.Dense(ff_hidden, activation="relu")(fuse)
fuse = layers.Dropout(0.2)(fuse)
out = layers.Dense(Q, activation=None, name="quantiles")(fuse)
model = Model(inputs=[in_seq, in_static], outputs=out, name="QRCNN_LSTM_CrossAttn")
model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss=multi_quantile_pinball)
return model
model = build_qrcnn_lstm_crossattn(
T=T, C=SEQ_CHANNELS, static_dim=X_static_train.shape[1],
conv_filters=64, kernel_size=2, lstm_units=64,
static_hidden=64, attn_heads=4, attn_key_dim=32, ff_hidden=128, Q=Q
)
model.summary()
解釈の目安:
- PICP(q10–q90の被覆率)は、理想は0.8–0.9前後(目標に合わせ調整)。
- Sharpness(区間幅)は小さいほど良いが、PICPが著しく低下するほど抑えない。
- Pinball Lossは各分位で小さいほど良く、特に意思決定で重視する分位(例:損失回避→q90)に注目。
実施結果
QRCNN + LSTM + Cross-Attention モデル
Pinball Loss:全体的に低い(q0.1=0.0168, q0.5=0.0335, q0.9=0.0240) → 精度◎
PICP = 0.8536 → ちょっと高め(理想は0.8付近)。区間はやや広め。
Sharpness = 0.229 → そこそこ広い。
→精度が高く、カバーも余裕あり。 少し保守的(安全寄り)だが、業務利用では安心感あり。
LSTM only
Pinball Loss:Aと同等かやや良い(中央値q0.5=0.0332と最小)。
PICP = 0.7861 → 理想の0.8に近い。
Sharpness = 0.185 → Aよりシャープ(区間が狭い)。
→最もバランスが良い。 適度にシャープで、カバー率もちょうど良い。過大でも過小でもない。
Naive median (学習データ中央値を常に出す)
Pinball Loss:大きい(特にq0.5=0.1716 →中央値予測がズレている)。
PICP = 0.9043 → カバーしすぎ(ほぼ全員入る)。
Sharpness = 0.939 → 幅が広すぎる。
→区間が広すぎて「誰でも入っちゃう」状態。予測としては精度不足。 これは実務で使いにくい。
サマリ
実際に作った結果では、今回提案している「QRCNN + LSTM + Cross-Attention モデル」よりも、「LSTM only」のモデルで、よりPICPが0.8に近いという結果になりました。
データにもよるでしょうし、もちろんプログラムが論文通りでなかった点があるかと思います。
とはいえ、こうやって論文をもとにモデルを比較していくというのは、とても面白いものでした。
Discussion