콘텐츠로 이동

jussam.my_diag

jussam.my_diag

overfit

overfit(
    estimator,
    x_train,
    y_train,
    x_test,
    y_test,
    metrics=None,
    average="auto",
    threshold=0.2,
    underfit_threshold=None,
    cv=5,
    fit_params=None,
    learning_curve=True,
    width=1280,
    height=640,
    grid=True,
    save_path=None,
    verbose=True,
    return_result=False,
)

Train / CV / Test 성능을 한 표로 보여주고 과적합 여부를 판정한다 (회귀·분류 공용).

Parameters:

Name Type Description Default
estimator

학습된 회귀·분류 모델·파이프라인 또는 GridSearchCV 등 탐색 객체.

required
x_train, y_train, x_test, y_test

훈련·검증 데이터셋.

required
metrics list

표시·판정할 지표 (기본값: None → 회귀 ['RMSE','MAE','R2'], 분류 ['F1','ROC_AUC','Accuracy']). 분류의 DOR 은 사이킷런 scorer 가 없어 쓸 수 없다.

None
average str

분류에서 다중클래스의 평균 방식 (기본값: 'auto').

'auto'
threshold float

과대적합으로 볼 Gap% (기본값: 0.20).

0.2
underfit_threshold float

과소적합으로 볼 훈련 성능 하한 (기본값: None → 회귀 0.05, 분류 0.6).

None
cv int

교차검증 폴드 수 (기본값: 5).

5
fit_params dict

CV 재학습에 넘길 인자 (예: {'model__cat_features': [...]}) (기본값: None).

None
learning_curve bool

학습곡선 출력 여부 (기본값: True).

True
width int

학습곡선 가로 크기(픽셀) (기본값: 1280).

1280
height int

학습곡선 세로 크기(픽셀) (기본값: 640).

640
grid bool

학습곡선 격자 표시 여부 (기본값: True).

True
save_path str

학습곡선 이미지 저장 경로 (기본값: None).

None
verbose bool

판정 과정 출력 여부 (기본값: True).

True
return_result bool

True 면 결과표(DataFrame)를 반환한다. 표의 attrs['diagnosis'] 에 모델 수준 최종 진단('일반화' | '과대적합' | '과소적합') 이 담겨 있어 여러 모델을 순서대로 판정해 고르는 코드에서 쓴다. False(기본값) 면 표를 화면에만 출력하고 반환하지 않는다 — 셀 마지막 줄에서 호출할 때 표가 두 번 출력되는 것을 막기 위함 (2026-09-23 추가).

False

Raises:

Type Description
ValueError

metrics 에 지원하지 않는 지표명을 준 경우.

Source code in jussam/my_diag.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
def overfit(estimator, x_train, y_train, x_test, y_test,
            metrics=None, average='auto', threshold=0.20, underfit_threshold=None,
            cv=5, fit_params=None, learning_curve=True,
            width=1280, height=640, grid=True, save_path=None, verbose=True,
            return_result=False):
    """Train / CV / Test 성능을 한 표로 보여주고 과적합 여부를 판정한다 (회귀·분류 공용).

    Args:
        estimator: 학습된 회귀·분류 모델·파이프라인 또는 GridSearchCV 등 탐색 객체.
        x_train, y_train, x_test, y_test: 훈련·검증 데이터셋.
        metrics (list): 표시·판정할 지표 (기본값: None → 회귀 ['RMSE','MAE','R2'], 
                                                    분류 ['F1','ROC_AUC','Accuracy']).
            분류의 DOR 은 사이킷런 scorer 가 없어 쓸 수 없다.
        average (str): 분류에서 다중클래스의 평균 방식 (기본값: 'auto').
        threshold (float): 과대적합으로 볼 Gap% (기본값: 0.20).
        underfit_threshold (float): 과소적합으로 볼 훈련 성능 하한 (기본값: None → 회귀 0.05, 분류 0.6).
        cv (int): 교차검증 폴드 수 (기본값: 5).
        fit_params (dict): CV 재학습에 넘길 인자 (예: {'model__cat_features': [...]}) (기본값: None).
        learning_curve (bool): 학습곡선 출력 여부 (기본값: True).
        width (int): 학습곡선 가로 크기(픽셀) (기본값: 1280).
        height (int): 학습곡선 세로 크기(픽셀) (기본값: 640).
        grid (bool): 학습곡선 격자 표시 여부 (기본값: True).
        save_path (str): 학습곡선 이미지 저장 경로 (기본값: None).
        verbose (bool): 판정 과정 출력 여부 (기본값: True).
        return_result (bool): True 면 결과표(DataFrame)를 반환한다. 표의 `attrs['diagnosis']` 에
            모델 수준 최종 진단('일반화' | '과대적합' | '과소적합') 이 담겨 있어 여러 모델을
            순서대로 판정해 고르는 코드에서 쓴다. False(기본값) 면 표를 화면에만 출력하고
            반환하지 않는다 — 셀 마지막 줄에서 호출할 때 표가 두 번 출력되는 것을 막기 위함 (2026-09-23 추가).

    Raises:
        ValueError: metrics 에 지원하지 않는 지표명을 준 경우.
    """
    # --- 1) 탐색 객체에서 최적 모델 꺼내기 ---
    # 탐색 객체(GridSearchCV 등)를 그대로 교차검증에 넣으면 폴드마다 하이퍼파라미터
    # 탐색을 다시 도는 중첩 교차검증이 되어 버리므로, 진단 전에 최적 모델을 꺼낸다.
    model, _, base_est, _ = _unwrap_estimator(estimator)
    classname = type(model).__name__        # 모델 클래스명 (표·그래프 제목에 쓴다)

    if classname.startswith("CatBoost"):    # CatBoost 계열인 경우
        # 교차검증은 폴드마다 모델을 새로 학습하므로 어떤 컬럼이 범주형인지 다시 알려줘야 한다.
        # 데이터 타입으로 추측하면 object 컬럼이나 nominal_cols 로 직접 지정한 컬럼을 놓치므로,
        # 학습을 마친 모델이 실제로 범주형으로 쓴 컬럼을 꺼내 쓴다.
        # (수정 전) 위치(인덱스)를 그대로 넘겼다 — 앞 단계에 VIFSelector 가 있으면 폴드마다 제거되는
        #   컬럼 수가 달라져 인덱스가 어긋나 CatBoostError(index must be < n) 가 났다 (2026-09-23)
        # cat_features = [int(i) for i in model.get_cat_feature_indices()]
        # 전처리 출력이 DataFrame(set_output='pandas') 이므로 컬럼 "이름" 으로 넘기면 폴드마다 위치가 달라져도 안전하다
        cat_idx = [int(i) for i in model.get_cat_feature_indices()]
        names = getattr(model, 'feature_names_', None)
        if names is not None and len(names) > max(cat_idx, default=-1):
            cat_features = [names[i] for i in cat_idx]
        else:                                   # 이름을 알 수 없으면 종전대로 인덱스 사용
            cat_features = cat_idx

        # 파이프라인이면 '마지막 단계명__cat_features', 단독 모델이면 'cat_features' 로 넘긴다
        if isinstance(base_est, Pipeline):
            key = f'{base_est.steps[-1][0]}__cat_features'
        else:
            key = 'cat_features'

        # 넘겨받은 fit_params 원본은 건드리지 않고, 이미 지정한 값이 있으면 그 값을 따른다
        fit_params = dict(fit_params or {})
        fit_params.setdefault(key, cat_features)

    # --- 2) 모델 유형 판별 ---
    # is_classifier 는 모델이 분류기인지 여부를 돌려준다. 이 한 줄이 아래 모든 분기의 기준이다.
    if is_classifier(model):        # 분류 모형인 경우
        task = 'classification'
    else:                           # 예측 모형인 경우
        task = 'regression'

    # --- 3) 과제별 지표표 정의 ---
    # 지표표는 {지표: (최적 방향, scoring, 폴드 점수 보정 코드)} 형태다. 
    # 보정 코드는 사이킷런이 돌려준 폴드 점수를 reg_score·cls_score 와 같은 단위로 되돌리는 방법이다.
    #   as_is    : 그대로 사용
    #   neg      : 부호만 뒤집기 (neg_* scorer 는 '클수록 좋게' 뒤집혀 있다)
    #   neg_sqrt : 부호를 되돌린 뒤 제곱근 (MSLE → RMSLE)
    if task == 'regression':        # 예측 모형인 경우
        # 회귀는 사이킷런이 이름만 보고 알아듣는 scoring 문자열을 그대로 쓴다.
        # MPE 는 0 에 가까울수록 좋은 지표라 '훈련보다 얼마나 나쁜가' 를 정의할 수 없어 제외한다
        metric_specs = {
            'R2':    ('higher', 'r2',                                 'as_is'),
            'MAE':   ('lower',  'neg_mean_absolute_error',            'neg'),
            'MSE':   ('lower',  'neg_mean_squared_error',             'neg'),
            'RMSE':  ('lower',  'neg_root_mean_squared_error',        'neg'),
            'RMSLE': ('lower',  'neg_mean_squared_log_error',         'neg_sqrt'),
            'MAPE':  ('lower',  'neg_mean_absolute_percentage_error', 'neg'),
        }
    else:                           # 분류 모형인 경우
        # 분류도 회귀와 같이 사이킷런 scoring 문자열을 그대로 쓴다 (cls_tunes 와 같은 규칙).
        # 이진이면 양성 클래스(1) 기준 scorer 를, 다중분류면 평균 방식(average)이 붙은 scorer 를 쓴다.
        #   --> cls_baseline·cls_tunes 가 종속변수를 0 부터 시작하는 정수로 맞춰 두므로 양성 클래스는 1 이다
        # 분류 지표는 모두 클수록 좋고 부호가 뒤집히지 않으므로 보정 코드는 전부 as_is 다.
        # DOR 은 사이킷런 scorer 가 없어 지표표에서 제외한다 (회귀에서 MPE 를 제외한 것과 같은 처리).
        y_labels = np.unique(np.asarray(y_train).ravel())
        binary = len(y_labels) == 2

        if binary and average in ('auto', 'binary'):    # 이진분류 — 양성 클래스 기준
            metric_specs = {
                'Accuracy':  ('higher', 'accuracy',          'as_is'),
                'Precision': ('higher', 'precision',         'as_is'),
                'Recall':    ('higher', 'recall',            'as_is'),
                'F1':        ('higher', 'f1',                'as_is'),
                'ROC_AUC':   ('higher', 'roc_auc',           'as_is'),
                'PR_AUC':    ('higher', 'average_precision', 'as_is'),
            }
        else:                                           # 다중분류 — 클래스 평균
            # ROC_AUC 는 일대다(OvR) 평균만 지원한다 (micro 는 사이킷런에 없어 macro 로 계산).
            # PR_AUC 는 다중분류용 문자열 scorer 가 없어 제외한다
            avg = 'macro' if average in ('auto', 'binary') else average
            roc_auc = 'roc_auc_ovr_weighted' if avg == 'weighted' else 'roc_auc_ovr'
            metric_specs = {
                'Accuracy':  ('higher', 'accuracy',         'as_is'),
                'Precision': ('higher', f'precision_{avg}', 'as_is'),
                'Recall':    ('higher', f'recall_{avg}',    'as_is'),
                'F1':        ('higher', f'f1_{avg}',        'as_is'),
                'ROC_AUC':   ('higher', roc_auc,            'as_is'),
            }

    # --- 4) 판정 기준값 설정 ---
    # metrics            : 표에 실을 지표
    # underfit_metric    : 과소적합 판정 지표. 스케일에 좌우되지 않아 임계값을 고정할 수
    #                      있는 지표로 고른다 (회귀 R2 · 분류 ROC_AUC).
    # underfit_threshold : 훈련 성능이 이보다 낮으면 과소적합
    # threshold          : 훈련↔CV 격차(Gap%)가 이 값 이상이면 과대적합 (인자로 받는다)
    if task == 'regression':        # 예측 모형인 경우
        underfit_metric = 'R2'      # 과소적합 판정 지표 (스케일에 좌우되지 않는다)

        if metrics is None:    # 지정하지 않았으면 기본 지표를 쓴다
            metrics = ['RMSE', 'MAE', 'R2']     # 회귀 기본 지표

        if underfit_threshold is None:    # 지정하지 않았으면 기본 임계값을 쓴다
            underfit_threshold = 0.05           # train R2 가 이보다 낮으면 과소적합
    else:                           # 분류 모형인 경우
        underfit_metric = 'ROC_AUC'     # 과소적합 판정 지표 (0.5 = 동전 던지기)

        if metrics is None:    # 지정하지 않았으면 기본 지표를 쓴다
            metrics = ['F1', 'ROC_AUC', 'Accuracy']     # 분류 기본 지표

        if underfit_threshold is None:    # 지정하지 않았으면 기본 임계값을 쓴다
            underfit_threshold = 0.6                    # train ROC_AUC 가 이보다 낮으면 과소적합

    # --- 5) 파라미터 검증 ---
    if isinstance(metrics, str):    # 지표를 문자열 하나로 준 경우도 허용
        metrics = [metrics]

    for m in metrics:    # 요청한 지표를 하나씩 확인
        if m not in metric_specs:       # 계산할 수 없는 지표명
            raise ValueError(f"지원하지 않는 지표입니다: '{m}' "
                             f"(사용 가능: {sorted(metric_specs)})")

    # --- 6) 훈련(Train), 검증(Test) 점수 계산 ---
    # 기출문제에 대한 성적 — 학습에 쓴 데이터에서의 성능이다.
    if task == 'regression':    # 예측 모형인 경우
        train_scores = reg_score(base_est, x_train, y_train)
    else:                      # 분류 모형인 경우
        train_scores = cls_score(base_est, x_train, y_train, average=average)

    # 학습에 쓰지 않은 데이터에서의 성능. 판정에는 쓰지 않고 참고용으로만 표에 싣는다.
    if task == 'regression':    # 예측 모형인 경우
        test_scores = reg_score(base_est, x_test, y_test)
    else:                      # 분류 모형인 경우
        test_scores = cls_score(base_est, x_test, y_test, average=average)

    # --- 7) 교차검증 준비 (1) — 종속변수 형태 통일과 지표 목록 ---
    # 과적합 판정은 Train ↔ CV 격차로 한다.
    if isinstance(y_train, DataFrame):      # 종속변수가 DataFrame 인 경우
        y_cv = y_train.values.ravel()       # 1차원 배열로 통일
    else:                                   # Series·ndarray 인 경우
        y_cv = np.asarray(y_train).ravel()

    wanted = list(metrics)                  # 표에 실을 지표

    if underfit_metric not in wanted:       # 과소적합 판정 지표가 빠져 있으면
        wanted.append(underfit_metric)      # 판정을 위해 계산 목록에만 넣는다

    # --- 8) 교차검증 준비 (2) — scoring 딕셔너리 구성 ---
    # cross_validate 는 {이름: scoring} 딕셔너리를 받으면 
    # 폴드마다 여러 지표를 한 번에 계산해 test_<이름> 키로 돌려준다. 
    scoring = {}

    for m in wanted:    # 계산할 지표를 하나씩
        scoring[m] = metric_specs[m][1]         # 지표명 → scoring 문자열·scorer

    # --- 9) 교차검증 수행 ---
    # sklearn 의 cross_validate는 CV 점수만 리턴한다.
    # 훈련 점수는 앞에서 미리 계산해 두었다.
    out = cross_validate(base_est, x_train, y_cv, cv=cv, scoring=scoring,
                         n_jobs=-1, params=fit_params)

    # --- 10) 폴드 점수 보정 ---
    cv_scores = {}

    for m in wanted:    # 지표마다 폴드 점수를 보정한다
        fold = out[f'test_{m}']     # 폴드별 점수
        code = metric_specs[m][2]   # 부호·단위 보정 코드

        if code == 'neg':           # 부호만 뒤집는 지표
            fold = -fold
        elif code == 'neg_sqrt':    # 부호를 되돌린 뒤 제곱근을 취하는 지표 (MSLE → RMSLE)
            fold = np.sqrt(-fold)

        cv_scores[m] = fold

    # --- 11) 과소적합 판정 ---
    # 과소적합은 지표별로 따지지 않고 모델 수준에서 한 번만 판정하므로, 
    # 그 기준으로 사용할 지표(회귀 R2 · 분류 ROC_AUC)의 훈련 점수와 CV 평균을 먼저 꺼내 둔다.
    train_base = train_scores[underfit_metric].iloc[0]      # 판정 지표의 훈련 점수
    cv_base = float(np.mean(cv_scores[underfit_metric]))    # 판정 지표의 CV 점수

    # 과소적합 여부
    # 훈련 점수가 임계값보다 낮으면 과소적합으로 본다. CV 점수는 판정에 쓰지 않는다.
    # (수정 전) 판정 지표가 NaN 일 때 판정을 보류하는 분기. cls_score 가 NaN 을 내지 않게 되어 불필요 (2026-09-17)
    # underfit_available = not np.isnan(train_base)
    # underfit = bool(underfit_available and train_base < underfit_threshold)
    underfit = bool(train_base < underfit_threshold)

    # --- 12) 지표별 격차 계산과 판정 ---
    rows = {}

    for m in metrics:    # 표에 실을 지표를 하나씩
        # --- 12-1) 훈련·CV·검증 점수와 격차 계산 ---
        direction = metric_specs[m][0]              # 'higher' = 클수록 좋은 지표
        tr = train_scores[m].iloc[0]                # 훈련 점수
        te = test_scores[m].iloc[0]                 # 검증 점수
        cv_mean = float(np.mean(cv_scores[m]))      # CV 평균
        cv_std = float(np.std(cv_scores[m]))        # CV 표준편차

        # 'CV 가 훈련보다 나쁜 정도' 가 항상 양수가 되도록 방향을 맞춘다
        # 클수록 좋은 지표 (R2·F1·ROC_AUC)와 그렇지 않은 지표 구분
        gap = tr - cv_mean if direction == 'higher' else cv_mean - tr

        # Gap% : 격차를 스케일 보정.
        # 상한이 1 인 지표(R2·분류 지표)는 점수차를 그대로 사용하고, 상한이 없는 회귀 오차 지표는 크기로 상대화한다.
        # (수정 전) DOR 이 지표표에 있을 때의 예외 처리. 문자열 scorer 로 바꾸며 DOR 이 빠져 불필요해짐 (2026-09-16)
        # unbounded = direction != 'higher' or m == 'DOR'
        # denom = max(abs(tr), abs(cv_mean)) if unbounded else 1.0
        denom = 1.0 if direction == 'higher' else max(abs(tr), abs(cv_mean))

        # # 분모가 0 이 아닌 경우 비율을 계산하고 0이면 NaN 으로 둔다. (0으로 나누면 inf 가 되므로)
        gap_pct = gap / denom if denom else np.nan

        # --- 12-2) 지표별 판정 ---
        if underfit:                  # 모델 수준 판정이 지표별 판정에 우선한다
            label = '과소적합'
        # (수정 전) ROC_AUC 가 NaN 이던 모델을 위한 'N/A' 라벨. 위 수정으로 도달하지 않음 (2026-09-17)
        # elif np.isnan(gap_pct):       # 격차를 계산하지 못한 경우
        #     label = 'N/A'
        elif gap_pct >= threshold:    # 격차가 임계값 이상
            label = '과대적합'
        else:                         # 격차가 임계값 미만
            label = '일반화'

        rows[m] = {'Train': tr, 'CV': cv_mean, 'CV_Std': cv_std, 'Test': te,
                   'Gap': gap, 'Gap%': round(gap_pct * 100, 2), 'Overfit': label}

    # --- 13) 결과표 조립 ---
    result = DataFrame.from_dict(rows, orient='index')
    result.index.name = 'Metric'

    # --- 14) 지표별 판정 취합 ---
    # 지표마다 스케일과 민감도가 달라, 한 지표에서만 드러나는 격차도 실제 신호일 수 있다.
    has_overfit = False

    for m in metrics:    # 지표별 판정을 훑는다
        # 한 지표라도 과대적합이면 모델도 과대적합으로 본다
        if rows[m]['Overfit'] == '과대적합':
            has_overfit = True

    # --- 15) 모델 수준 최종 진단 ---
    # 과소적합(高편향) > 과대적합(高분산) > 일반화 순으로 우선한다. 
    if underfit:         # 高편향 — 가장 우선
        diagnosis = '과소적합'
    elif has_overfit:    # 高분산
        diagnosis = '과대적합'
    else:                # 둘 다 아니면 일반화
        diagnosis = '일반화'

    result.attrs['diagnosis'] = diagnosis   # 모델 수준 최종 진단
    result.attrs['task'] = task             # 'regression' | 'classification'

    # --- 16) 학습곡선 ---
    # 수치 판정보다 먼저 그려서 '곡선을 보고 표로 확인' 하는 흐름을 만든다.
    if learning_curve:    # 학습곡선을 그리는 경우
        # --- 16-1) 표본 수를 늘려 가며 점수 산출 ---
        _, lc_scoring, lc_code = metric_specs[metrics[0]]

        sizes, train_curve, cv_curve = sk_learning_curve(
            base_est, x_train, y_cv, train_sizes=np.linspace(0.1, 1.0, 10),
            cv=cv, scoring=lc_scoring, n_jobs=-1, params=fit_params,
        )

        # 점수 보정
        if lc_code == 'neg':           # 부호만 뒤집는 지표
            train_curve = -train_curve
            cv_curve = -cv_curve
        elif lc_code == 'neg_sqrt':    # 부호를 되돌린 뒤 제곱근을 취하는 지표
            train_curve = np.sqrt(-train_curve)
            cv_curve = np.sqrt(-cv_curve)

        # --- 16-2) 긴 형식(long format) 표로 변환 ---
        curve_rows = []

        for i in range(len(sizes)):    # 훈련 표본 수마다
            for j in range(train_curve.shape[1]):    # 폴드마다
                curve_rows.append({'훈련 표본 수': int(sizes[i]),
                                   '점수': float(train_curve[i, j]),
                                   '구분': 'Train'})
                curve_rows.append({'훈련 표본 수': int(sizes[i]),
                                   '점수': float(cv_curve[i, j]),
                                   '구분': 'Validation (CV)'})

        curve_df = DataFrame(curve_rows)

        # --- 16-3) 시각화 ---
        my_plot.lineplot(data=curve_df, x='훈련 표본 수', y='점수', hue='구분',
                         marker='o', errorbar='sd',
                         title=f'Learning Curve: {classname}',
                         xlabel='훈련 표본 수', ylabel=f'{metrics[0]} 점수',
                         width=width, height=height, save_path=save_path)

    # --- 17) 최종 판정 결과 출력 ---
    display(result) # 결과표 출력

    if verbose:    # 판정 과정을 출력하는 경우
        print('\n' + '=' * 78)
        print(f'◆ Fit Diagnosis: {classname}  '
              f'(threshold={threshold:.0%}, 기준=Train↔CV {cv}-Fold)')
        print('   ※ threshold 는 학술 표준이 아닌 경험칙입니다. '
              '임계값보다 학습곡선 추세를 함께 보세요.')
        print('=' * 78)

        for m in metrics:    # 지표마다 한 줄씩 출력
            r = rows[m]

            if r['Overfit'] == '과대적합':
                tag = ' [주의]'
            elif r['Overfit'] == '과소적합':
                tag = ' [경고]'
            else:
                tag = ' [정상]'

            # 지표명 폭을 6 → 8 로 넓힌다. 분류 지표명(ROC_AUC·Accuracy)이 6자를 넘어
            # 회귀(RMSE·MAE·R2)에서는 맞던 열이 분류에서는 어긋났다 (2026-09-16, LAB-04 06 실습)
            # print(f"   - {m:<6}  Train={r['Train']:>11.4f}  "
            #       f"CV={r['CV']:>11.4f} (±{r['CV_Std']:.4f})  Test={r['Test']:>11.4f}  "
            #       f"Gap%={r['Gap%']:>8.2f}%  [{r['Overfit']}]{tag}")
            print(f"   - {m:<8}  Train={r['Train']:>11.4f}  "
                  f"CV={r['CV']:>11.4f} (±{r['CV_Std']:.4f})  Test={r['Test']:>11.4f}  "
                  f"Gap%={r['Gap%']:>8.2f}%  [{r['Overfit']}]{tag}")

        if diagnosis == '과대적합':      # 高분산
            message = '⚠ 과대적합 (Overfit · 高분산) — 훈련↔CV 격차가 큼'
        elif diagnosis == '과소적합':    # 高편향
            message = '⚑ 과소적합 (Underfit · 高편향) — 훈련 성능 자체가 낮음'
        else:                        # 정상
            message = '✔ 일반화 (Good fit) — 격차가 작고 훈련 성능도 양호'

        print(f'\n   ▶ 진단: {message}')

        # (수정 전) 판정을 보류한 경우의 안내 분기. underfit_available 제거에 따라 함께 정리 (2026-09-17)
        # if underfit_available:    # 판정 지표를 계산할 수 있었던 경우
        #     print(f'     · train {underfit_metric}={train_base:.4f} '
        #           f'(과소적합 기준 < {underfit_threshold}) · '
        #           f'CV {underfit_metric}={cv_base:.4f}')
        # else:                     # 계산할 수 없어 판정을 보류한 경우
        #     print(f'     · {underfit_metric} 를 계산할 수 없어 과소적합 판정은 보류했습니다 '
        #           f'(확률을 내지 못하는 모델).')
        print(f'     · train {underfit_metric}={train_base:.4f} '
              f'(과소적합 기준 < {underfit_threshold}) · '
              f'CV {underfit_metric}={cv_base:.4f}')

        print('=' * 78 + '\n')

    # --- 18) 결과 반환 (요청한 경우에만) ---
    # attrs['diagnosis'] 로 모델 수준 진단을 코드에서 읽을 수 있다 (2026-09-23 추가)
    if return_result:
        return result

feature_importance

feature_importance(
    estimator,
    cum_ratio=0.95,
    plot=True,
    title=None,
    xlabel=None,
    ylabel=None,
    palette="tab10",
)

학습된 회귀·분류 모델에서 변수 중요도를 도출해 상위 변수만 추려서 반환한다.

Parameters:

Name Type Description Default
estimator

학습된 회귀·분류 모델·파이프라인 또는 GridSearchCV 등 탐색 객체.

required
cum_ratio float

채택할 누적 중요도 비율 (기본값: 0.95).

0.95
plot bool

결과를 시각화할지 여부 (기본값: True).

True
title str

그래프 제목 (기본값: None).

None
xlabel str

x축 라벨 (기본값: None).

None
ylabel str

y축 라벨 (기본값: None).

None

Returns:

Name Type Description
DataFrame

중요도 내림차순 전체 변수표.

Raises:

Type Description
ValueError

cum_ratio 가 (0, 1] 밖이거나 중요도 총합이 0 인 경우.

TypeError

변수 중요도를 도출할 수 없거나 변수명을 얻을 수 없는 모델인 경우.

Source code in jussam/my_diag.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
def feature_importance(estimator, cum_ratio=0.95, 
                       plot=True, title=None, xlabel=None, ylabel=None, palette='tab10'):
    """학습된 회귀·분류 모델에서 변수 중요도를 도출해 상위 변수만 추려서 반환한다.

    Args:
        estimator: 학습된 회귀·분류 모델·파이프라인 또는 GridSearchCV 등 탐색 객체.
        cum_ratio (float): 채택할 누적 중요도 비율 (기본값: 0.95).
        plot (bool): 결과를 시각화할지 여부 (기본값: True).
        title (str): 그래프 제목 (기본값: None).
        xlabel (str): x축 라벨 (기본값: None).
        ylabel (str): y축 라벨 (기본값: None).

    Returns:
        DataFrame: 중요도 내림차순 전체 변수표.

    Raises:
        ValueError: cum_ratio 가 (0, 1] 밖이거나 중요도 총합이 0 인 경우.
        TypeError: 변수 중요도를 도출할 수 없거나 변수명을 얻을 수 없는 모델인 경우.
    """
    # --- 1) 임계값(cum_ratio) 검증 ---
    if not 0.0 < cum_ratio <= 1.0:    # (0, 1] 범위를 벗어난 경우
        raise ValueError(f"cum_ratio 는 (0.0, 1.0] 범위여야 합니다: {cum_ratio}")

    # --- 2) 최종 모델 추출 ---
    # 모델 객체, 전처리 객체, 기본 추정기로 분리한다 (탐색 결과는 쓰지 않는다).
    model, pre, base_est, _ = _unwrap_estimator(estimator)
    model_class = type(model).__name__      # 모델 클래스명

    # --- 3) 변수명 추출 ---
    if pre is not None:                          # 파이프라인인 경우
        feature_names = np.asarray(pre.get_feature_names_out())     # 변환 후 변수명 (원핫·PCA 뒤)
    elif hasattr(model, 'feature_names_in_'):    # 단독 모델인데 컬럼명을 기억하는 경우
        feature_names = np.asarray(model.feature_names_in_)
    else:                                        # 이름을 알 수 없는 경우
        raise TypeError(
            f"'{model_class}' 모델에서 변수명을 얻을 수 없습니다. "
            f"DataFrame 으로 학습했거나 fit_pipeline 으로 만든 파이프라인이어야 "
            f"변수 중요도를 변수명과 짝지을 수 있습니다."
        )

    # --- 4) 변수 중요도 도출 (모델 유형 분기) ---
    if hasattr(model, 'feature_importances_'):                      # 트리·부스팅 계열
        if model_class in ('XGBRegressor', 'XGBClassifier'):        # XGBoost
            # 학습된 booster 에서 gain 을 직접 추출한다 (모델 속성은 건드리지 않는다).
            # get_score 는 분할에 쓰인 변수만 dict 로 주므로 booster 의 변수 순서대로 0 을 채운다
            booster = model.get_booster()
            score = booster.get_score(importance_type='gain')
            names = booster.feature_names           # booster 가 아는 변수 순서

            imp = []
            for f in names:    # booster 의 변수 순서대로
                imp.append(score.get(f, 0.0))       # 분할에 쓰이지 않은 변수는 0

            importances = np.array(imp, dtype=float)
            importance_metric = 'gain'

        elif model_class in ('LGBMRegressor', 'LGBMClassifier'):    # LightGBM
            imp = model.booster_.feature_importance(importance_type='gain')
            importances = np.asarray(imp, dtype=float).ravel()
            importance_metric = 'gain'

        else:                                                       # 그 외 트리 계열
            # 모델이 제공하는 feature_importances_ 를 그대로 사용
            importances = np.asarray(model.feature_importances_, dtype=float).ravel()

            labels = {
                'DecisionTreeRegressor':  'MDI',
                'RandomForestRegressor':  'MDI',
                'DecisionTreeClassifier': 'MDI',
                'RandomForestClassifier': 'MDI',
                'CatBoostRegressor':      'PredictionValuesChange',
                'CatBoostClassifier':     'PredictionValuesChange',
            }
            importance_metric = labels.get(model_class, 'feature_importances_')

    elif hasattr(model, 'coef_'):                                   # 선형 계열
        coef = np.abs(np.asarray(model.coef_, dtype=float))     # 부호는 무관하므로 절대값

        if coef.ndim == 2 and coef.shape[0] > 1:    # 다중클래스 (클래스 × 변수) 인 경우 → 클래스축 평균
            importances = coef.mean(axis=0)
        else:                                       # 단일 출력 (회귀·이진분류) 인 경우
            importances = coef.ravel()

        importance_metric = '|coef_|'

    else:    # 중요도를 정의할 수 없는 모델
        raise TypeError(f"'{model_class}' 모델은 변수 중요도를 도출할 수 없습니다.")

    # --- 5) 원핫 더미와 원본 컬럼의 매핑 만들기 ---
    # OneHotEncoder 의 get_feature_names_out 출력은 입력 컬럼 순서대로 묶여 있고, 
    # 입력 컬럼별 더미 개수는 카테고리 수에서 drop 된 개수를 뺀 값이다. 
    # 이 개수만큼 출력명을 끊어 원본 컬럼에 귀속시킨다.
    name_map = {}       # {더미 컬럼명: 원본 컬럼명}

    if isinstance(base_est, Pipeline):    # 파이프라인이어야 인코더를 들여다볼 수 있다
        pre_step = base_est.named_steps.get('preprocessor')

        if pre_step is not None:    # 전처리 단계가 있는 경우
            for _name, trans, _cols in pre_step.transformers_:  # 연속형·명목형 분기별로
                # --- 원핫 인코더를 찾아서 더미 컬럼명을 원본 컬럼명으로 매핑 ---
                if isinstance(trans, Pipeline):                 # 변환기가 파이프라인인 경우
                    ohe = trans.named_steps.get('onehot')
                elif isinstance(trans, OneHotEncoder):          # 인코더가 바로 붙은 경우
                    ohe = trans
                else:                                           # 원핫이 아닌 변환기
                    ohe = None

                # 원핫이 아니거나 categories_ 속성이 없다면 건너뛴다
                if ohe is None or not hasattr(ohe, 'categories_'):
                    continue

                # --- 원핫 인코딩 전후 컬럼명을 추출하여 더미 컬럼을 원본 컬럼에 귀속 ---
                in_cols = list(ohe.feature_names_in_)                   # 인코딩 전 원본 컬럼
                out_names = list(ohe.get_feature_names_out(in_cols))    # 인코딩 후 더미 컬럼

                drop_idx = getattr(ohe, 'drop_idx_', None)  # drop='first' 등으로 제거된 카테고리
                pos = 0                                     # 더미 컬럼 순서 커서

                for i, col in enumerate(in_cols):    # 원본 컬럼마다
                    n_out = len(ohe.categories_[i])         # 이 컬럼이 만든 더미 개수

                    if drop_idx is not None and drop_idx[i] is not None:    # 카테고리 하나가 제거된 경우
                        n_out = n_out - 1

                    for _ in range(n_out):    # 그 컬럼이 만든 더미 개수만큼
                        name_map[out_names[pos]] = col      # 더미를 원본 컬럼에 귀속
                        pos = pos + 1

    # --- 6) 더미 중요도를 원본 컬럼 단위로 합산 ---
    if name_map:    # 합산할 더미가 있는 경우
        agg = {}

        for fname, imp in zip(feature_names, importances):      # 변수마다 원본 컬럼에 더한다
            origin = name_map.get(fname, fname)                 # 매핑이 없으면 자기 자신
            agg[origin] = agg.get(origin, 0.0) + imp

        feature_names = np.array(list(agg.keys()))              # 합산 후 변수명
        importances = np.array(list(agg.values()), dtype=float) # 합산 후 변수 중요도

    # --- 7) 중요도 총합 검사 ---
    # 중요도의 절대 크기는 라이브러리마다 기준이 달라 비율로 바꿔서 읽는데,
    # 총합이 0 이면 비율을 만들 수 없으므로 먼저 걸러낸다.
    total = importances.sum()

    if total <= 0:    # 모든 중요도가 0 인 경우
        raise ValueError(
            f"중요도 총합이 0 입니다 ({model_class}). 모델이 학습되지 않았거나 "
            f"(Lasso·ElasticNet 등) 모든 계수가 0 으로 규제되었을 수 있습니다."
        )

    # --- 8) 합이 1 이 되도록 정규화 ---
    ratio = importances / total     # 각 변수가 전체 중요도에서 차지하는 비율

    # --- 9) 결과표 구성 ---
    result = DataFrame({
        'Importance': importances,
        'Ratio': ratio,
    }, index=feature_names)
    result.index.name = 'Feature'

    # 중요도 기준 내림차순 정렬
    result.sort_values(by='Importance', ascending=False, inplace=True)

    # --- 10) 누적 비율 계산 ---
    # 위에서부터 비율을 차례로 더한 값 = 상위 몇 개까지 쓰면 몇 % 를 설명하는가
    result['CumRatio'] = result['Ratio'].cumsum()

    # --- 11) 채택 여부 판정 ---
    # 누적 비율이 cum_ratio 에 처음 도달하는 변수까지 채택한다 (경계 변수 포함)
    reached = np.flatnonzero(result['CumRatio'].to_numpy() >= cum_ratio)

    if len(reached) > 0:    # 기준에 도달한 경우
        k = int(reached[0]) + 1     # 도달 지점보다 하나 큰 값이 채택 개수
    else:                   # 부동소수점 오차 등으로 도달하지 못한 경우
        k = len(result)             # 전체를 채택

    result['채택여부'] = np.where(np.arange(len(result)) < k, '채택', '탈락')

    # --- 12) 결과 시각화 및 리턴 ---
    if plot:    # 그래프를 그리는 경우
        # 제목, x축 라벨, y축 라벨이 주어지지 않으면 기본값을 설정한다
        if title is None:   title = f'Feature Importance ({importance_metric})'
        if xlabel is None:  xlabel = 'Ratio'
        if ylabel is None:  ylabel = 'Feature'

        # 변수 하나당 높이를 확보한다 (변수 수 × 65px + 제목 공간 50px)
        h = 65 * len(result) + 50
        fig, ax = my_plot.init(title=title, xlabel=xlabel, ylabel=ylabel, height=h)

        # 채택·탈락을 색으로 구분한 가로 막대그래프
        my_plot.barplot(data=result, x='Ratio', y=result.index, hue='채택여부',
                        palette=palette, ax=ax)

        for i, v in enumerate(result['Ratio']):    # 막대 오른쪽에 비율 값을 표시
            ax.text(v + 0.001, i, f"{v:.3f}", color='black', va='center')

        my_plot.show()

    return result

shap_analysis

shap_analysis(
    project_name,
    estimator,
    x,
    max_samples="auto",
    background_clusters=50,
    workdir="shap",
)

학습된 회귀·분류 모델을 SHAP 으로 분석해 변수 기여도 요약표를 반환한다.

Parameters:

Name Type Description Default
project_name str

작업 폴더의 이름이 될 프로젝트명.

required
estimator

학습된 파이프라인 또는 그것을 감싼 GridSearchCV 등 탐색 객체.

required
x DataFrame

설명에 사용할 원본 입력 (보통 x_train 또는 x_test).

required
max_samples str or int

설명 대상 행 수 (기본값: 'auto' → Kernel 만 200행, 나머지는 전체).

'auto'
background_clusters int

KernelExplainer 의 배경을 kmeans 로 압축할 대표점 수 (기본값: 50).

50
workdir str

분석 결과 pkl 을 저장할 폴더명 (기본값: "shap").

'shap'

Returns:

Name Type Description
DataFrame

mean_abs_shap 내림차순 요약표. attrs 에 shap_values(n×f)·data·expected_value·feature_names· explainer_type·model_class·task·class_index·class_names·output_space 가 담기고, 같은 객체가 {project_name}/{workdir}/{모델이름}_shap.pkl 로 저장된다. 분류는 마지막 클래스(이진의 양성)를 설명한다.

Source code in jussam/my_diag.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
def shap_analysis(project_name, estimator, x, max_samples='auto',
                  background_clusters=50, workdir='shap'):
    """학습된 회귀·분류 모델을 SHAP 으로 분석해 변수 기여도 요약표를 반환한다.

    Args:
        project_name (str): 작업 폴더의 이름이 될 프로젝트명.
        estimator: 학습된 파이프라인 또는 그것을 감싼 GridSearchCV 등 탐색 객체.
        x (DataFrame): 설명에 사용할 원본 입력 (보통 x_train 또는 x_test).
        max_samples (str or int): 설명 대상 행 수 (기본값: 'auto' → Kernel 만 200행, 나머지는 전체).
        background_clusters (int): KernelExplainer 의 배경을 kmeans 로 압축할 대표점 수 (기본값: 50).
        workdir (str): 분석 결과 pkl 을 저장할 폴더명 (기본값: "shap").

    Returns:
        DataFrame: mean_abs_shap 내림차순 요약표. attrs 에 shap_values(n×f)·data·expected_value·feature_names·
            explainer_type·model_class·task·class_index·class_names·output_space 가 담기고,
            같은 객체가 `{project_name}/{workdir}/{모델이름}_shap.pkl` 로 저장된다. 분류는 마지막 클래스(이진의 양성)를 설명한다.
    """
    import shap    # 별도 설치가 필요한 무거운 패키지라 함수 안에서 import 한다

    # --- 1) 최종 모델 추출 ---
    model, pre, _, _ = _unwrap_estimator(estimator)
    model_class = type(model).__name__

    if pre is None:    # 전처리 단계가 없는 단독 모델인 경우
        raise TypeError(f"'{model_class}' 단독 모델은 받지 않습니다. 전처리를 포함한 파이프라인을 넘기세요.")

    # 분류는 마지막 클래스(이진에서 양성)를 설명한다
    task = 'classification' if is_classifier(model) else 'regression'
    class_names = list(model.classes_) if task == 'classification' else []

    # --- 2) 전처리 재현하기 ---
    x_df = pre.transform(x).copy()

    # --- 3) explainer 생성 ---
    # 선형은 계수로 기여도를 바로 풀고, 트리는 정확·고속이다. 둘 다 아니면 느린 Kernel 로 넘어간다.
    explainer = None
    explainer_type = None

    if hasattr(model, 'coef_'):    # 선형 — kmeans 요약을 못 받으므로 원시 데이터를 넘긴다
        explainer = shap.LinearExplainer(model, x_df)
        explainer_type = 'LinearExplainer'
    else:
        try:
            explainer = shap.TreeExplainer(model)
            explainer_type = 'TreeExplainer'
        except Exception:    # shap 이 거부한 경우 (KNN·SVM 등)
            explainer = None

    if explainer is None:    # 폴백 — 배경이 클수록 느려지므로 kmeans 로 압축한다
        bg = shap.kmeans(x_df, min(background_clusters, len(x_df)))

        # Kernel 은 '숫자를 돌려주는 함수' 를 받는다 — 분류는 확률(없으면 결정함수), 회귀는 예측값
        if task == 'regression':
            f = model.predict
        elif hasattr(model, 'predict_proba'):
            f = model.predict_proba
        else:    # 확률이 없는 분류 모형 (SVC·RidgeClassifier)
            f = model.decision_function

        explainer = shap.KernelExplainer(f, bg)
        explainer_type = 'KernelExplainer'

    # --- 4) 행 샘플링 ---
    # 선형·트리는 정확 계산이라 전체를 써도 몇 초면 끝나지만, Kernel 은 행 하나에 수 초가 걸린다.
    if max_samples == 'auto':
        n_target = 200 if explainer_type == 'KernelExplainer' else None
    else:
        n_target = max_samples

    if n_target is not None and len(x_df) > n_target:    # 재현 가능한 샘플링으로 계산량을 줄인다
        pick = np.sort(np.random.RandomState(RANDOM_STATE).choice(len(x_df), n_target, replace=False))
        x_explain = x_df.iloc[pick]
    else:
        x_explain = x_df

    # --- 5) SHAP 값 계산 ---
    if explainer_type == 'KernelExplainer':    # Kernel 은 진행 막대를 끈다
        raw = explainer.shap_values(x_explain, silent=True)
    else:
        raw = explainer.shap_values(x_explain)

    # --- 6) SHAP 값 배열 정리 ---
    # 회귀는 (n, f) 단일 출력이다. 분류는 클래스축이 붙은 (n, f, c) 로 오기도 하고
    # (RandomForest·Kernel+predict_proba), 양성 클래스만 담은 (n, f) 로 오기도 한다 (XGBoost·로지스틱).
    arr = np.asarray(raw)
    ev = np.ravel(np.asarray(explainer.expected_value, dtype=float))    # base value 를 1차원으로 통일

    if arr.ndim == 3:    # 클래스축이 있으면 마지막 클래스만 잘라 (n, f) 로 맞춘다
        idx = arr.shape[2] - 1
        shap_2d = arr[:, :, idx].astype(float)
        base_value = float(ev[idx])
    else:                # 단일 출력 — 분류면 양성 클래스로 본다
        idx = len(class_names) - 1
        shap_2d = arr.astype(float)
        base_value = float(ev[0])

    used_class = idx if task == 'classification' else None    # 회귀는 클래스 개념이 없다

    # --- 7) 가산성으로 모델 출력 복원 ---
    # SHAP 은 행마다 (base value + 그 행의 SHAP 합) 이 모델 출력과 같아진다.
    # 이 합을 실제 출력과 맞춰보면 SHAP 값의 단위를 역으로 알 수 있다.
    preds = shap_2d.sum(axis=1) + base_value

    # --- 8) 예측값과 대조 ---
    # 회귀는 후보가 predict 하나뿐이다. 분류는 모델·explainer 조합에 따라 단위가 갈리므로
    # 확률·로그오즈·마진 후보를 모두 세워 가장 잘 맞는 것을 고른다.
    #   - RandomForest·DecisionTree + Tree → 확률 / XGBoost·LightGBM·로지스틱 → 로그오즈
    #   - Kernel → predict_proba 면 확률, decision_function 이면 마진
    if task == 'regression':    # y 단위 예측값과 맞아떨어지는지만 본다
        y_hat = np.asarray(model.predict(x_explain), dtype=float).ravel()
        scale = float(np.abs(y_hat).mean()) + 1e-9    # 오차 허용치의 기준 (0 으로 나누기 방지)
        output_space = '예측값(y 단위)' if np.abs(preds - y_hat).mean() < scale * 1e-3 else '알 수 없음'
    else:                       # 후보별로 실제 출력과의 평균 오차를 잰다
        candidates = {}    # {단위 이름: 실제 출력과의 평균 오차}

        if hasattr(model, 'predict_proba'):
            proba = np.asarray(model.predict_proba(x_explain), dtype=float)[:, used_class]

            if arr.ndim == 3:    # 클래스축이 있으면 전체 클래스의 복원 출력을 softmax 로 확률화한다
                full = arr.sum(axis=1) + ev[None, :arr.shape[2]]
                exp = np.exp(full - full.max(axis=1, keepdims=True))    # 오버플로 방지
                converted = (exp / exp.sum(axis=1, keepdims=True))[:, used_class]
            else:                # 단일 출력이면 시그모이드로 확률화한다
                converted = 1.0 / (1.0 + np.exp(-np.clip(preds, -500, 500)))

            candidates['확률'] = float(np.abs(preds - proba).mean())          # 그대로 확률이면 오차 0
            candidates['로그오즈'] = float(np.abs(converted - proba).mean())    # 확률화한 뒤 맞으면 로그오즈

        if hasattr(model, 'decision_function'):    # SVC·RidgeClassifier 등
            margin = np.asarray(model.decision_function(x_explain), dtype=float)
            margin = margin if margin.ndim == 1 else margin[:, used_class]    # 다중은 설명 클래스 열
            candidates['마진'] = float(np.abs(preds - margin).mean())

        best = min(candidates, key=candidates.get)    # 오차가 가장 작은 후보
        output_space = best if candidates[best] <= 0.05 else '알 수 없음'

    # --- 9) 변수별 통계량 ---
    # shap_2d 는 행이 관측치, 열이 변수다. 한 칸이 '이 행에서 이 변수가 예측을 얼마나 밀었나' 이다.
    feat_names = list(x_explain.columns)
    mean_abs = np.abs(shap_2d).mean(axis=0)     # 영향력 크기 (부호 무관)
    mean_s = shap_2d.mean(axis=0)               # 평균 기여 (부호 = 방향)
    std_s = shap_2d.std(axis=0, ddof=1)         # 기여의 흔들림

    # --- 10) 요약표로 조립 ---
    summary = DataFrame({
        'mean_abs_shap': mean_abs,
        'mean_shap': mean_s,
        'std_shap': std_s,
        # 평균 기여의 부호를 방향 라벨로 바꾼다
        'direction': np.where(mean_s > 0, '증가', np.where(mean_s < 0, '감소', '중립')),
        # 변동계수 — 평균 기여보다 흔들림이 크면 비선형·상호작용을 의심한다
        'cv': std_s / (mean_abs + 1e-9),
    }, index=feat_names)

    summary['stability'] = np.where(summary['cv'] < 1, '안정적', '비선형/불안정')
    summary.index.name = 'Feature'
    summary = summary.sort_values('mean_abs_shap', ascending=False)    # 영향력 내림차순

    # --- 11) 비율과 누적 비율 ---
    total_abs = float(summary['mean_abs_shap'].sum())
    summary['ratio'] = summary['mean_abs_shap'] / total_abs if total_abs > 0 else 0.0
    summary['cum_ratio'] = summary['ratio'].cumsum()

    # 컬럼 순서 정리 — 영향력 → 비율 → 누적 비율 → 해석용 컬럼
    summary = summary[['mean_abs_shap', 'ratio', 'cum_ratio',
                       'mean_shap', 'std_shap', 'direction', 'cv', 'stability']]

    # --- 12) 원시 데이터를 attrs 에 담기 ---
    # 후속 Dependence·Waterfall 이 재계산 없이 쓴다.
    # explainer 객체 자체는 담지 않는다 — 복사가 안 되는 객체라 슬라이스할 때 깨진다.
    summary.attrs['shap_values'] = shap_2d              # (n, f) 기여도 배열
    summary.attrs['data'] = x_explain                   # 모델공간 입력
    summary.attrs['expected_value'] = base_value        # base value (평균 예측)
    summary.attrs['feature_names'] = feat_names         # 변환 후 변수명
    summary.attrs['explainer_type'] = explainer_type    # 사용한 explainer 종류
    summary.attrs['model_class'] = model_class          # 모델 클래스명
    summary.attrs['task'] = task                        # 'regression' | 'classification'
    summary.attrs['class_index'] = used_class           # 설명 대상 클래스 위치 (회귀는 None)
    summary.attrs['class_names'] = class_names          # 클래스 목록 (회귀는 빈 리스트)
    summary.attrs['output_space'] = output_space        # SHAP 값의 단위

    # --- 13) 분석 결과 저장 ---
    # 계산은 무겁고 시각화는 가볍다. 파일로 남겨 두면 그래프 함수들이 이 파일만으로 다시 그린다.
    workdir = Path(project_name) / workdir
    workdir.mkdir(parents=True, exist_ok=True)

    # reg_tunes 가 붙여 둔 이름('xgb_tuned' 등)이 있으면 이어 쓰고, 없으면 모델 클래스명을 쓴다
    save_path = workdir / f'{getattr(estimator, "name_", model_class.lower())}_shap.pkl'
    save_model(summary, save_path)

    # --- 14) 분석 개요 출력 ---
    # 그래프를 읽기 전에 explainer·단위·기준점·설명 클래스를 확인한다
    print(f'모델: {model_class} / explainer: {explainer_type}')
    print(f'설명 대상: {shap_2d.shape[0]}행 × {shap_2d.shape[1]}변수 / base value: {base_value:.4f} / SHAP 값의 단위: {output_space}')

    if task == 'classification':
        print(f'설명 대상 클래스: {class_names[used_class]} (index={used_class})')

    print(f'SHAP 분석 결과 저장 완료 → {save_path}')

    return summary

shap_bar_plot

shap_bar_plot(
    summary,
    cum_ratio=0.95,
    palette=None,
    column_means=None,
    title=None,
    xlabel=None,
    ylabel=None,
    width=1280,
    height=None,
    save_path=None,
)

SHAP 분석 결과로 mean|SHAP| 순위 막대그래프를 그린다.

Parameters:

Name Type Description Default
summary DataFrame

shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.

required
cum_ratio float

채택·탈락을 가르는 누적 비율 (기본값: 0.95).

0.95
palette str or list

색상 팔레트 (기본값: None).

None
column_means dict

{변수명: 실제 의미} 사전 (기본값: None → 변수명을 그대로 쓴다).

None
title str

그래프 제목 (기본값: None).

None
xlabel str

x축 라벨 (기본값: None).

None
ylabel str

y축 라벨 (기본값: None).

None
width int

그래프 너비 (기본값: 1280).

1280
height int

그래프 높이 (기본값: None → 변수 개수에 맞춰 자동 계산).

None
save_path str

그래프 저장 경로 (기본값: None).

None

Raises:

Type Description
ValueError

cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.

Source code in jussam/my_diag.py
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
def shap_bar_plot(summary, cum_ratio=0.95, palette=None, column_means=None,
                  title=None, xlabel=None, ylabel=None,
                  width=1280, height=None, save_path=None):
    """SHAP 분석 결과로 mean|SHAP| 순위 막대그래프를 그린다.

    Args:
        summary (DataFrame): shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.
        cum_ratio (float): 채택·탈락을 가르는 누적 비율 (기본값: 0.95).
        palette (str or list): 색상 팔레트 (기본값: None).
        column_means (dict): `{변수명: 실제 의미}` 사전 (기본값: None → 변수명을 그대로 쓴다).
        title (str): 그래프 제목 (기본값: None).
        xlabel (str): x축 라벨 (기본값: None).
        ylabel (str): y축 라벨 (기본값: None).
        width (int): 그래프 너비 (기본값: 1280).
        height (int): 그래프 높이 (기본값: None → 변수 개수에 맞춰 자동 계산).
        save_path (str): 그래프 저장 경로 (기본값: None).

    Raises:
        ValueError: cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.
    """
    # --- 1) 파라미터 검증 ---
    if not 0.0 < cum_ratio <= 1.0:    # (0, 1] 범위를 벗어난 경우
        raise ValueError(f"cum_ratio 는 (0.0, 1.0] 범위여야 합니다: {cum_ratio}")

    if 'shap_values' not in summary.attrs:    # attrs 가 비어 있는 경우
        raise ValueError("shap_analysis 가 반환한 요약표가 아닙니다")

    # --- 2) 메타정보 꺼내기 ---
    # 계산부가 attrs 에 얹어 둔 값들이다. 저장·로드를 거쳐도 그대로 살아 있다.
    model_class = summary.attrs.get('model_class', '')          # 모델 클래스명
    # 분류용 꼬리표(클래스·단위) 복원 — shap_analysis 가 분류를 다시 지원한다 (2026-09-18, LAB-04 08 SHAP)
    # (2026-09-17 에 주석 처리했던 코드를 되살림. 회귀 결과는 task 가 없거나 'regression' 이라 꼬리표가 빈 문자열이다)
    task = summary.attrs.get('task', 'regression')              # 회귀 | 분류
    class_names = summary.attrs.get('class_names', [])          # 클래스 목록
    used_class = summary.attrs.get('class_index', None)         # 설명 대상 클래스 위치
    output_space = summary.attrs.get('output_space', '알 수 없음')  # SHAP 값의 단위

    # --- 3) 그래프에 붙일 꼬리표 ---
    cls_tag = ''
    unit_tag = ''

    if task == 'classification':    # 분류인 경우 — 어느 클래스를 어떤 단위로 설명했는가
        cls_tag = f' · class={class_names[used_class]}'
        unit_tag = f' [{output_space}]'

    # --- 4) 채택 개수(k) 결정 ---
    # 누적 비율이 cum_ratio 에 처음 도달하는 위치를 찾고 1을 더해, 경계에 걸친 변수까지 채택한다.

    # 전체 변수 개수
    n_all = len(summary)

    # 채택 개수
    k = int(np.searchsorted(summary['cum_ratio'].values, cum_ratio)) + 1

    # k 가 전체 변수 수를 넘으면 전체를 채택한다
    k = max(1, min(k, n_all))

    # 변수 하나가 막대 한 줄을 차지하므로 높이를 고정하면 막대가 서로 붙는다.
    # 여백·축(140) + 제목(80) + 변수당 40 으로 잡는다.
    if height is None:    # 높이를 주지 않은 경우
        plot_height = 220 + 40 * n_all
    else:                 # 사용자가 준 높이
        plot_height = height

    # --- 5) 그릴 데이터 만들기 ---
    plot_df = summary.reset_index()

    # 막대의 채택 여부를 문자열로 만들어 색 구분에 쓴다
    plot_df['채택여부'] = np.where(np.arange(n_all) < k, '채택', '탈락')

    # y축에 적을 이름 — 사전에 있으면 실제 의미로, 없으면 변수명 그대로
    if column_means is None:    # 의미 사전을 주지 않은 경우
        column_means = {}

    plot_df['의미'] = [column_means.get(f, f) for f in summary.index]

    # --- 6) 제목·축 라벨 ---
    if title is None:    # 제목을 주지 않은 경우
        # (수정 전) plot_title = f'SHAP Bar (mean|SHAP| · 누적 {cum_ratio:.0%} 채택): {model_class}'  (2026-09-17 → 2026-09-18 꼬리표 복원)
        plot_title = f'SHAP Bar (mean|SHAP| · 누적 {cum_ratio:.0%} 채택): {model_class}{cls_tag}'
    else:                # 사용자가 준 제목
        plot_title = title

    if xlabel is None:    # x축 라벨을 주지 않은 경우
        # (수정 전) plot_xlabel = 'mean|SHAP|'  (2026-09-17 → 2026-09-18 꼬리표 복원)
        plot_xlabel = f'mean|SHAP|{unit_tag}'
    else:                 # 사용자가 준 라벨
        plot_xlabel = xlabel

    if ylabel is None:    # y축 라벨을 주지 않은 경우
        plot_ylabel = '변수'
    else:                 # 사용자가 준 라벨
        plot_ylabel = ylabel

    # --- 7) 시각화 ---
    # 막대 오른쪽에 누적 비율을 적어야 해서 ax 를 직접 받는다.
    # 캔버스 생성·막대 그리기·표시는 모두 my_plot 에 맡긴다.
    fig, ax = my_plot.init(title=plot_title, width=width, height=plot_height,
                           xlabel=plot_xlabel, ylabel=plot_ylabel)
    my_plot.barplot(data=plot_df, x='mean_abs_shap', y='의미', hue='채택여부',
                    palette=palette, errorbar=None, ax=ax)

    vmax = float(plot_df['mean_abs_shap'].max())
    ax.set_xlim(0, vmax * 1.20)         # 누적 비율 글자가 들어갈 여백

    for i in range(n_all):              # '상위 k개가 영향력의 N% 를 설명' 을 읽게 한다
        value = float(plot_df['mean_abs_shap'].iloc[i])
        cum_text = f"{plot_df['cum_ratio'].iloc[i]:.0%}"
        ax.text(value + vmax * 0.01, i, cum_text,
                va='center', ha='left', fontsize=10, color='tab:red')

    my_plot.show(save_path=save_path)

shap_summary_plot

shap_summary_plot(
    summary,
    cum_ratio=0.95,
    title=None,
    xlabel=None,
    column_means=None,
    width=1280,
    height=None,
    save_path=None,
)

SHAP 분석 결과로 Beeswarm 그래프를 그린다.

Parameters:

Name Type Description Default
summary DataFrame

shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.

required
cum_ratio float

그릴 변수를 고르는 누적 비율 (기본값: 0.95).

0.95
title str

그래프 제목 (기본값: None).

None
xlabel str

x축 라벨 (기본값: None).

None
column_means dict

{변수명: 실제 의미} 사전 (기본값: None → 변수명을 그대로 쓴다).

None
width int

그래프 너비 (기본값: 1280).

1280
height int

그래프 높이 (기본값: None → 채택 변수 개수에 맞춰 자동 계산).

None
save_path str

그래프 저장 경로 (기본값: None).

None

Raises:

Type Description
ValueError

cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.

Source code in jussam/my_diag.py
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
def shap_summary_plot(summary, cum_ratio=0.95, title=None, xlabel=None,
                       column_means=None, width=1280, height=None, save_path=None):
    """SHAP 분석 결과로 Beeswarm 그래프를 그린다.

    Args:
        summary (DataFrame): shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.
        cum_ratio (float): 그릴 변수를 고르는 누적 비율 (기본값: 0.95).
        title (str): 그래프 제목 (기본값: None).
        xlabel (str): x축 라벨 (기본값: None).
        column_means (dict): `{변수명: 실제 의미}` 사전 (기본값: None → 변수명을 그대로 쓴다).
        width (int): 그래프 너비 (기본값: 1280).
        height (int): 그래프 높이 (기본값: None → 채택 변수 개수에 맞춰 자동 계산).
        save_path (str): 그래프 저장 경로 (기본값: None).

    Raises:
        ValueError: cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.
    """
    # shap 은 별도 설치가 필요한 무거운 패키지라 함수 안에서 import 한다
    import shap

    # --- 1) 파라미터 검증 ---
    if not 0.0 < cum_ratio <= 1.0:    # (0, 1] 범위를 벗어난 경우
        raise ValueError(f"cum_ratio 는 (0.0, 1.0] 범위여야 합니다: {cum_ratio}")

    if 'shap_values' not in summary.attrs:    # attrs 가 비어 있는 경우
        raise ValueError("shap_analysis 가 반환한 요약표가 아닙니다 (attrs['shap_values'] 없음)")

    # --- 2) 원시 데이터·메타정보 꺼내기 ---
    shap_2d = summary.attrs['shap_values']                      # (n, f) 기여도 배열
    x_explain = summary.attrs['data']                           # 모델공간 입력 (점의 색)
    model_class = summary.attrs.get('model_class', '')          # 모델 클래스명
    # 분류용 꼬리표(클래스·단위) 복원 — shap_analysis 가 분류를 다시 지원한다 (2026-09-18, LAB-04 08 SHAP)
    # (2026-09-17 에 주석 처리했던 코드를 되살림. 회귀 결과는 task 가 없거나 'regression' 이라 꼬리표가 빈 문자열이다)
    task = summary.attrs.get('task', 'regression')              # 회귀 | 분류
    class_names = summary.attrs.get('class_names', [])          # 클래스 목록
    used_class = summary.attrs.get('class_index', None)         # 설명 대상 클래스 위치
    output_space = summary.attrs.get('output_space', '알 수 없음')  # SHAP 값의 단위

    # --- 3) 그래프에 붙일 꼬리표 ---
    cls_tag = ''
    unit_tag = ''

    if task == 'classification':    # 분류인 경우 — 어느 클래스를 어떤 단위로 설명했는가
        cls_tag = f' · class={class_names[used_class]}'
        unit_tag = f' [{output_space}]'

    # --- 4) 시각화 할 변수 결정 ---
    # 요약표는 이미 mean_abs_shap 내림차순이고 누적 비율까지 들어 있다.
    # 표에서 바로 읽어 채택 개수를 정하므로 막대그래프의 '채택' 변수와 언제나 일치한다.
    n_all = len(summary)                                                     # 전체 변수 개수
    k = int(np.searchsorted(summary['cum_ratio'].values, cum_ratio)) + 1     # 채택 개수
    k = max(1, min(k, n_all))

    keep = list(summary.index[:k])    # 시각화 할 변수 결정 (영향력 내림차순)

    # shap_2d 의 열 순서는 x_explain 의 열 순서다. 변수명을 열 위치로 바꿔 같이 잘라낸다.
    pos = [x_explain.columns.get_loc(f) for f in keep]
    shap_2d = shap_2d[:, pos]
    x_explain = x_explain[keep]

    # shap 은 열 이름을 그대로 줄 이름으로 쓴다 — 사전이 있으면 실제 의미로 바꿔 준다
    if column_means:    # 의미 사전을 준 경우
        x_explain = x_explain.rename(columns=column_means)

    # --- 5) 그래프 높이와 제목 ---
    # 변수 하나가 점 한 줄을 차지하므로 높이를 고정하면 줄이 서로 붙는다.
    # 여백·축(140) + 제목(80) + 변수당 40 으로 잡는다.
    if height is None:    # 높이를 주지 않은 경우
        plot_height = 220 + 40 * k
    else:                 # 사용자가 준 높이
        plot_height = height

    if title is None:    # 제목을 주지 않은 경우
        # (수정 전) plot_title = f'SHAP Summary (Beeswarm · 누적 {cum_ratio:.0%} 채택): {model_class}'  (2026-09-17 → 2026-09-18 꼬리표 복원)
        plot_title = f'SHAP Summary (Beeswarm · 누적 {cum_ratio:.0%} 채택): {model_class}{cls_tag}'
    else:                # 사용자가 준 제목
        plot_title = title

    # --- 6) 시각화 ---
    my_plot.init(title=plot_title, width=width, height=plot_height)

    # shap 은 max_display 를 주지 않으면 상위 20개만 그리므로 채택 개수를 직접 넘긴다.
    # plot_size=None 을 줘야 shap 이 캔버스 크기를 제 기본값으로 되돌리지 않는다.
    shap.summary_plot(shap_2d, x_explain, plot_type='dot',
                      max_display=k, show=False, plot_size=None)

    if xlabel is None:    # x축 라벨을 주지 않은 경우
        # (수정 전) plt.xlabel('SHAP value')  (2026-09-17 → 2026-09-18 꼬리표 복원)
        plt.xlabel(f'SHAP value{unit_tag}')     # shap 이 붙인 라벨을 덮어쓴다
    else:                 # 사용자가 준 라벨
        plt.xlabel(xlabel)

    my_plot.show(save_path=save_path)

shap_dependence_plot

shap_dependence_plot(
    summary,
    cum_ratio=0.95,
    title=None,
    column_means=None,
    width=1280,
    height=640,
    save_path=None,
)

SHAP 분석 결과로 Dependence Plot 을 그린다 (변수값과 기여의 관계).

Parameters:

Name Type Description Default
summary DataFrame

shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.

required
cum_ratio float

주변수로 채택할 누적 비율 (기본값: 0.95).

0.95
title str

그래프 제목 (기본값: None). 주면 뒤에 변수쌍이 붙는다.

None
column_means dict

{변수명: 실제 의미} 사전 (기본값: None → 변수명을 그대로 쓴다).

None
width int

그래프 너비 (기본값: 1280).

1280
height int

그래프 높이 (기본값: 640).

640
save_path str

그래프 저장 경로 (기본값: None). 파일명 뒤에 변수명이 붙는다.

None

Raises:

Type Description
ValueError

cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.

Source code in jussam/my_diag.py
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
def shap_dependence_plot(summary, cum_ratio=0.95, title=None, column_means=None,
                         width=1280, height=640, save_path=None):
    """SHAP 분석 결과로 Dependence Plot 을 그린다 (변수값과 기여의 관계).

    Args:
        summary (DataFrame): shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.
        cum_ratio (float): 주변수로 채택할 누적 비율 (기본값: 0.95).
        title (str): 그래프 제목 (기본값: None). 주면 뒤에 변수쌍이 붙는다.
        column_means (dict): `{변수명: 실제 의미}` 사전 (기본값: None → 변수명을 그대로 쓴다).
        width (int): 그래프 너비 (기본값: 1280).
        height (int): 그래프 높이 (기본값: 640).
        save_path (str): 그래프 저장 경로 (기본값: None). 파일명 뒤에 변수명이 붙는다.

    Raises:
        ValueError: cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.
    """
    # shap 은 별도 설치가 필요한 무거운 패키지라 함수 안에서 import 한다
    import shap

    # --- 1) 파라미터 검증 ---
    if not 0.0 < cum_ratio <= 1.0:    # (0, 1] 범위를 벗어난 경우
        raise ValueError(f"cum_ratio 는 (0.0, 1.0] 범위여야 합니다: {cum_ratio}")

    if 'shap_values' not in summary.attrs:    # attrs 가 비어 있는 경우
        raise ValueError("shap_analysis 가 반환한 요약표가 아닙니다")

    # --- 2) 원시 데이터 꺼내기 ---
    shap_2d = summary.attrs['shap_values']    # (n, f) 기여도 배열
    x_df = summary.attrs['data']              # 모델공간 입력 (x축·점의 색)

    # 분류용 단위 꼬리표 복원 — shap_analysis 가 분류를 다시 지원한다 (2026-09-18, LAB-04 08 SHAP)
    # (2026-09-17 에 주석 처리했던 코드를 되살림. 회귀 결과는 꼬리표가 빈 문자열이다)
    unit_tag = ''

    if summary.attrs.get('task') == 'classification':    # 분류인 경우 — SHAP 값의 단위를 제목에 적는다
        unit_tag = f" [{summary.attrs.get('output_space', '알 수 없음')}]"


    # --- 3) 주변수 고르기 ---
    # 요약표는 이미 mean_abs_shap 내림차순이고 누적 비율까지 들어 있다.
    # 표에서 바로 읽어 채택 개수를 정하므로 막대그래프의 '채택' 변수와 언제나 일치한다.
    k = int(np.searchsorted(summary['cum_ratio'].values, cum_ratio)) + 1
    main_features = list(summary.index[:k])

    # --- 4) 주변수마다 상호작용이 가장 강한 짝 찾기 ---
    partners = []    # 상호작용 짝을 저장할 리스트

    for f in main_features:              # 주변수마다
        fi = x_df.columns.get_loc(f)     # 변수명의 열 인덱스 찾기
        # 구간 안에 남아 있는 SHAP 값의 흩어짐을 가장 잘 설명하는 순서로 돌려준다
        inter = shap.utils.approximate_interactions(fi, shap_2d, x_df)
        # 0번째 항목 = 가장 강한 짝
        partners.append((f, x_df.columns[inter[0]]))

    # --- 5) 시각화 — 변수쌍마다 한 장씩 ---
    if column_means is None:    # 의미 사전을 주지 않은 경우
        column_means = {}       # 변수명을 그대로 제목에 쓴다

    for f, partner in partners:    # 변수쌍마다
        # 사전에 있으면 실제 의미로, 없으면 변수명 그대로 적는다
        pair_tag = f'{column_means.get(f, f)}  ×  {column_means.get(partner, partner)}'

        if title is None:    # 제목을 주지 않은 경우
            # (수정 전) plot_title = f'SHAP Dependence: {pair_tag}'  (2026-09-17 → 2026-09-18 꼬리표 복원)
            plot_title = f'SHAP Dependence: {pair_tag}{unit_tag}'
        else:                # 사용자가 준 제목 — 장마다 변수쌍을 덧붙여 구분한다
            plot_title = f'{title} — {pair_tag}'

        fig, ax = my_plot.init(title=plot_title, width=width, height=height)

        shap.dependence_plot(f, shap_2d, x_df,
                             interaction_index=partner, ax=ax, show=False)

        # (0,0)을 지나는 가로 직선
        ax.axhline(0, color='#990000', linestyle=':', linewidth=2)

        if save_path is None:    # 저장하지 않는 경우
            path = None
        else:                    # 여러 장이므로 파일명 뒤에 변수명을 덧붙인다
            p = Path(save_path)
            path = str(p.with_name(f'{p.stem}_{f}{p.suffix}'))

        my_plot.show(save_path=path)

shap_waterfall_plot

shap_waterfall_plot(
    summary,
    index,
    cum_ratio=0.95,
    title=None,
    label=None,
    column_means=None,
    width=1280,
    height=None,
    save_path=None,
)

SHAP 분석 결과로 관측치 한 건의 Waterfall Plot 을 그린다 (개별 사례 기여 분해).

Parameters:

Name Type Description Default
summary DataFrame

shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.

required
index int

설명할 관측치의 행 위치. 인덱스 라벨이 아니라 0부터 세는 순번이다.

required
cum_ratio float

막대로 보여줄 변수 개수를 정하는 누적 비율 (기본값: 0.95).

0.95
title str

그래프 제목 (기본값: None).

None
label str

이 관측치를 부르는 이름 (기본값: None). 기본 제목의 분위 자리에 대신 적는다.

None
column_means dict

{변수명: 실제 의미} 사전 (기본값: None → 변수명을 그대로 쓴다).

None
width int

그래프 너비 (기본값: 1280).

1280
height int

그래프 높이 (기본값: None → 변수 개수에 맞춰 자동 계산).

None
save_path str

그래프 저장 경로 (기본값: None).

None

Returns:

Name Type Description
DataFrame

설명에 사용된 관측치 한 행 (quantile·pred 컬럼이 앞에 붙는다).

Raises:

Type Description
ValueError

cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.

Source code in jussam/my_diag.py
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
def shap_waterfall_plot(summary, index, cum_ratio=0.95, title=None, label=None,
                        column_means=None, width=1280, height=None, save_path=None):
    """SHAP 분석 결과로 관측치 한 건의 Waterfall Plot 을 그린다 (개별 사례 기여 분해).

    Args:
        summary (DataFrame): shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.
        index (int): 설명할 관측치의 행 위치. 인덱스 라벨이 아니라 0부터 세는 순번이다.
        cum_ratio (float): 막대로 보여줄 변수 개수를 정하는 누적 비율 (기본값: 0.95).
        title (str): 그래프 제목 (기본값: None).
        label (str): 이 관측치를 부르는 이름 (기본값: None). 기본 제목의 분위 자리에 대신 적는다.
        column_means (dict): `{변수명: 실제 의미}` 사전 (기본값: None → 변수명을 그대로 쓴다).
        width (int): 그래프 너비 (기본값: 1280).
        height (int): 그래프 높이 (기본값: None → 변수 개수에 맞춰 자동 계산).
        save_path (str): 그래프 저장 경로 (기본값: None).

    Returns:
        DataFrame: 설명에 사용된 관측치 한 행 (quantile·pred 컬럼이 앞에 붙는다).

    Raises:
        ValueError: cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.
    """
    # shap 은 별도 설치가 필요한 무거운 패키지라 함수 안에서 import 한다
    import shap

    # --- 1) 파라미터 검증 ---
    if not 0.0 < cum_ratio <= 1.0:    # (0, 1] 범위를 벗어난 경우
        raise ValueError(f"cum_ratio 는 (0.0, 1.0] 범위여야 합니다: {cum_ratio}")

    if 'shap_values' not in summary.attrs:    # attrs 가 비어 있는 경우
        raise ValueError("shap_analysis 가 반환한 요약표가 아닙니다")

    # --- 2) 원시 데이터·메타정보 꺼내기 ---
    shap_values = np.asarray(summary.attrs['shap_values'], dtype=float)  # (n, f) 기여도 배열
    data = summary.attrs['data']                                  # 막대 왼쪽의 변수값
    base_value = float(summary.attrs['expected_value'])           # base value (평균 예측)
    feat_names = list(summary.attrs['feature_names'])             # 변환 후 변수명
    # 분류용 꼬리표(클래스·단위) 복원 — shap_analysis 가 분류를 다시 지원한다 (2026-09-18, LAB-04 08 SHAP)
    # (2026-09-17 에 주석 처리했던 코드를 되살림. 회귀 결과는 task 가 없거나 'regression' 이라 꼬리표가 빈 문자열이다)
    task = summary.attrs.get('task', 'regression')              # 회귀 | 분류
    class_names = summary.attrs.get('class_names', [])          # 클래스 목록
    used_class = summary.attrs.get('class_index', None)         # 설명 대상 클래스 위치
    output_space = summary.attrs.get('output_space', '알 수 없음')  # SHAP 값의 단위

    # --- 3) 그래프에 붙일 꼬리표 ---
    cls_tag = ''
    unit_tag = ''

    if task == 'classification':    # 분류인 경우 — 어느 클래스를 어떤 단위로 설명했는가
        cls_tag = f' · class={class_names[used_class]}'
        unit_tag = f' [{output_space}]'


    # --- 3) 행별 예측값 복원 ---
    # SHAP 의 가산성 — 행별 예측 = base value + 그 행의 SHAP 합
    pred = base_value + shap_values.sum(axis=1)
    total = len(pred)

    # --- 4) 설명할 관측치 ---
    i = int(index)

    # 예측값 분포에서 몇 % 지점인지 — 자기보다 작은 예측이 몇 개인가로 센다
    if total > 1:    # 행이 둘 이상인 경우
        quantile = float(np.searchsorted(np.sort(pred), pred[i]) / (total - 1))
    else:            # 행이 하나뿐인 경우
        quantile = 0.0

    # 그린 그래프를 표로도 확인할 수 있게 한 행짜리 결과를 만든다
    pick = data.iloc[[i]].copy()
    pick.insert(0, 'pred', pred[i])          # 복원한 예측값
    pick.insert(0, 'quantile', quantile)     # 예측값 분포에서의 위치

    # 로그오즈 단위의 분류 결과는 확률로 바꾼 값도 싣는다 — 표를 '생존 확률 n%' 로 읽을 수 있다 (2026-09-18, LAB-04 08 SHAP)
    if output_space == '로그오즈':    # 분류 로그오즈인 경우
        pick.insert(2, 'proba', 1.0 / (1.0 + np.exp(-pred[i])))    # 시그모이드로 확률화

    # --- 5) 막대로 보여줄 개수와 높이 ---
    n_all = len(feat_names)     # 전체 변수 개수

    # 막대그래프와 같은 기준으로 채택 개수를 읽는다.
    # shap 은 max_display 개까지만 막대로 그리고 나머지는 'N other features' 한 줄로 합치므로
    # 채택된 k 개를 모두 보여주려면 k + 1 을 넘긴다.
    # (다만 기여 순서는 행마다 다르므로 여기 찍히는 k 개는 그 행 기준 상위 k 개다.)
    k = int(np.searchsorted(summary['cum_ratio'].values, cum_ratio)) + 1
    max_display = min(k + 1, n_all)

    # 변수 하나가 막대 한 줄을 차지하므로 높이를 고정하면 막대가 서로 붙는다.
    # 여백·축(140) + 제목(80) + 줄당 40 으로 잡는다 — shap_bar_plot 과 같은 기준이다.
    if height is None:    # 높이를 주지 않은 경우
        plot_height = 220 + 40 * max_display
    else:                 # 사용자가 준 높이
        plot_height = height

    # --- 6) 제목 ---
    if title is None:    # 제목을 주지 않은 경우
        # 부르는 이름을 주면 분위 대신 그 이름을 적는다 (예: ... (중앙값))
        if label is None:    # 이름을 주지 않은 경우
            pick_tag = f'분위 {quantile:.0%}'
        else:                # 사용자가 준 이름
            pick_tag = label

        # (수정 전) f'pred≈{pred[i]:.4g} ({pick_tag})'  (2026-09-17 → 2026-09-18 꼬리표 복원)
        plot_title = (f'SHAP Waterfall: obs#{i} — '
                      f'pred≈{pred[i]:.4g}{unit_tag} ({pick_tag}){cls_tag}')
    else:                # 사용자가 준 제목
        plot_title = title

    # --- 7) 시각화 ---
    # 사전에 있으면 실제 의미로, 없으면 변수명 그대로 막대 옆에 적는다
    if column_means is None:    # 의미 사전을 주지 않은 경우
        column_means = {}

    plot_names = [column_means.get(f, f) for f in feat_names]

    # shap 이 요구하는 설명 객체 — 기여도·기준값·실제 변수값을 함께 담는다
    expl = shap.Explanation(
        values=shap_values[i],
        base_values=base_value,
        data=data.iloc[i].values,
        feature_names=plot_names,
    )

    fig, _ = my_plot.init(width=width, height=plot_height)

    shap.plots.waterfall(expl, max_display=max_display, show=False)

    # shap.plots.waterfall 은 ax 를 받지 않고, 
    # 그리는 도중 캔버스 크기를 8 × (행수) 인치로 되돌린다. 
    # 그래서 그래프를 그린 뒤에 크기를 다시 잡고 제목을 붙인다.
    # (matplotlib 은 인치 단위, my_plot 은 픽셀 단위라 100 으로 나눈다)
    fig.set_size_inches(width / 100, plot_height / 100)
    plt.title(plot_title, fontsize=18, fontweight=500, pad=25)

    my_plot.show(save_path=save_path)

    return pick

shap_waterfall_stats_plot

shap_waterfall_stats_plot(
    summary,
    cum_ratio=0.95,
    title=None,
    column_means=None,
    width=1280,
    height=None,
    save_path=None,
)

예측값의 대표 통계마다 Waterfall Plot 을 그린다 (최소・1사분위・중앙값・평균・3사분위・최대).

통계값과 예측이 정확히 일치하는 행은 없을 수 있으므로(짝수 개일 때의 중앙값·평균 등) 통계값과 차이가 가장 작은 행을 대표로 골라 shap_waterfall_plot 을 반복해서 부른다.

Parameters:

Name Type Description Default
summary DataFrame

shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.

required
cum_ratio float

막대로 보여줄 변수 개수를 정하는 누적 비율 (기본값: 0.95).

0.95
title str

그래프 제목 (기본값: None). 주면 뒤에 통계 이름이 붙는다.

None
column_means dict

{변수명: 실제 의미} 사전 (기본값: None → 변수명을 그대로 쓴다).

None
width int

그래프 너비 (기본값: 1280).

1280
height int

그래프 높이 (기본값: None → 변수 개수에 맞춰 자동 계산).

None
save_path str

그래프 저장 경로 (기본값: None). 파일명 뒤에 행 위치가 붙는다.

None

Returns:

Name Type Description
DataFrame

대표로 뽑힌 행들 (stat・quantile・pred 컬럼이 앞에 붙는다).

Raises:

Type Description
ValueError

cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.

Source code in jussam/my_diag.py
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
def shap_waterfall_stats_plot(summary, cum_ratio=0.95, title=None, column_means=None,
                              width=1280, height=None, save_path=None):
    """예측값의 대표 통계마다 Waterfall Plot 을 그린다 (최소・1사분위・중앙값・평균・3사분위・최대).

    통계값과 예측이 정확히 일치하는 행은 없을 수 있으므로(짝수 개일 때의 중앙값·평균 등)
    통계값과 차이가 가장 작은 행을 대표로 골라 shap_waterfall_plot 을 반복해서 부른다.

    Args:
        summary (DataFrame): shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 결과표.
        cum_ratio (float): 막대로 보여줄 변수 개수를 정하는 누적 비율 (기본값: 0.95).
        title (str): 그래프 제목 (기본값: None). 주면 뒤에 통계 이름이 붙는다.
        column_means (dict): `{변수명: 실제 의미}` 사전 (기본값: None → 변수명을 그대로 쓴다).
        width (int): 그래프 너비 (기본값: 1280).
        height (int): 그래프 높이 (기본값: None → 변수 개수에 맞춰 자동 계산).
        save_path (str): 그래프 저장 경로 (기본값: None). 파일명 뒤에 행 위치가 붙는다.

    Returns:
        DataFrame: 대표로 뽑힌 행들 (stat・quantile・pred 컬럼이 앞에 붙는다).

    Raises:
        ValueError: cum_ratio 가 (0, 1] 범위 밖이거나, shap_analysis 결과가 아닌 경우.
    """
    # --- 1) 파라미터 검증 ---
    if not 0.0 < cum_ratio <= 1.0:    # (0, 1] 범위를 벗어난 경우
        raise ValueError(f"cum_ratio 는 (0.0, 1.0] 범위여야 합니다: {cum_ratio}")

    if 'shap_values' not in summary.attrs:    # attrs 가 비어 있는 경우
        raise ValueError("shap_analysis 가 반환한 요약표가 아닙니다 (attrs['shap_values'] 없음)")

    # --- 2) 행별 예측값 복원 ---
    # SHAP 의 가산성 — 행별 예측 = base value + 그 행의 SHAP 합
    shap_values = np.asarray(summary.attrs['shap_values'], dtype=float)
    pred = float(summary.attrs['expected_value']) + shap_values.sum(axis=1)

    # --- 3) 예측값 분포의 대표 통계 ---
    stats = {
        '최소값':  float(pred.min()),
        '1사분위': float(np.percentile(pred, 25)),
        '중앙값':  float(np.median(pred)),
        '평균':    float(pred.mean()),
        '3사분위': float(np.percentile(pred, 75)),
        '최대값':  float(pred.max()),
    }

    # --- 4) 통계값마다 가장 가까운 행 ---
    # 두 통계가 같은 행을 가리키면 같은 그래프를 두 번 그리게 되므로 이름만 합친다.
    picks = {}    # {행 위치: 그 행이 대표하는 통계 이름}

    for name, value in stats.items():    # 통계마다
        i = int(np.argmin(np.abs(pred - value)))    # 차이가 가장 작은 행

        if i in picks:    # 다른 통계가 이미 집은 행인 경우
            picks[i] = f'{picks[i]}・{name}'
        else:             # 처음 집는 행인 경우
            picks[i] = name

    # --- 5) 대표 행마다 한 장씩 ---
    rows = []

    for i, name in picks.items():    # 대표 행마다
        if save_path is None:    # 저장하지 않는 경우
            path = None
        else:                    # 여러 장이므로 파일명 뒤에 행 위치를 덧붙인다
            p = Path(save_path)
            path = str(p.with_name(f'{p.stem}_obs{i}{p.suffix}'))

        if title is None:    # 제목을 주지 않은 경우 — 통계 이름만 넘기고 나머지는 맡긴다
            plot_title = None
        else:                # 사용자가 준 제목 — 장마다 통계 이름을 덧붙여 구분한다
            plot_title = f'{title} — {name}'

        rows.append(shap_waterfall_plot(summary, index=i, cum_ratio=cum_ratio,
                                        title=plot_title, label=name,
                                        column_means=column_means,
                                        width=width, height=height, save_path=path))

    # --- 6) 뽑힌 행을 한 표로 ---
    pick = concat(rows)
    pick.insert(0, 'stat', list(picks.values()))    # 어떤 통계의 대표인가

    return pick

shap_proba_table

shap_proba_table(
    summary,
    cum_ratio=1.0,
    bins=5,
    max_levels=10,
    inverse=None,
    column_means=None,
)

분류 SHAP 결과를 변수의 수준(범주)·구간(연속)별 평균 SHAP → 확률로 환산한 표를 만든다.

Parameters:

Name Type Description Default
summary DataFrame

shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 분류 결과표.

required
cum_ratio float

표에 실을 변수를 고르는 누적 비율 (기본값: 1.0 → 전체 변수).

1.0
bins int or dict

연속형 변수의 구간 (기본값: 5 → 분위수 5구간). {변수명: 구간 수 | 경계 리스트} 로 변수별 지정하며 경계는 모델공간 값이다.

5
max_levels int

고유값이 이 개수 이하면 구간을 나누지 않고 값 하나를 수준 하나로 쓴다 (기본값: 10).

10
inverse dict

{변수명: 역변환 함수} (기본값: None). 로그 변환된 변수의 수준 라벨을 원단위로 되돌린다 (예: {'Fare': np.expm1}).

None
column_means dict

{변수명: 실제 의미} 사전 (기본값: None → 변수명을 그대로 쓴다).

None

Returns:

Name Type Description
DataFrame

(Feature, Level) 인덱스. n·share·mean_shap·z(base + mean_shap)·proba·delta_pp(base 대비 %p)· step_dz·step_dpp(같은 변수의 직전 수준 대비 변화). attrs['base_proba'] 에 base 확률이 담긴다.

Raises:

Type Description
ValueError

분류 결과가 아니거나 SHAP 값의 단위가 확률·로그오즈가 아닌 경우.

Source code in jussam/my_diag.py
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
def shap_proba_table(summary, cum_ratio=1.0, bins=5, max_levels=10, inverse=None,
                     column_means=None):
    """분류 SHAP 결과를 변수의 수준(범주)·구간(연속)별 평균 SHAP → 확률로 환산한 표를 만든다.

    Args:
        summary (DataFrame): shap_analysis 가 반환한(또는 my_ml.load_model 로 불러온) 분류 결과표.
        cum_ratio (float): 표에 실을 변수를 고르는 누적 비율 (기본값: 1.0 → 전체 변수).
        bins (int or dict): 연속형 변수의 구간 (기본값: 5 → 분위수 5구간). `{변수명: 구간 수 | 경계 리스트}` 로 변수별 지정하며 경계는 모델공간 값이다.
        max_levels (int): 고유값이 이 개수 이하면 구간을 나누지 않고 값 하나를 수준 하나로 쓴다 (기본값: 10).
        inverse (dict): `{변수명: 역변환 함수}` (기본값: None). 로그 변환된 변수의 수준 라벨을 원단위로 되돌린다 (예: `{'Fare': np.expm1}`).
        column_means (dict): `{변수명: 실제 의미}` 사전 (기본값: None → 변수명을 그대로 쓴다).

    Returns:
        DataFrame: (Feature, Level) 인덱스. n·share·mean_shap·z(base + mean_shap)·proba·delta_pp(base 대비 %p)·
            step_dz·step_dpp(같은 변수의 직전 수준 대비 변화). attrs['base_proba'] 에 base 확률이 담긴다.

    Raises:
        ValueError: 분류 결과가 아니거나 SHAP 값의 단위가 확률·로그오즈가 아닌 경우.
    """
    # --- 1) 파라미터 검증 ---
    if summary.attrs.get('task') != 'classification':    # 회귀 결과이거나 attrs 가 없는 경우
        raise ValueError("분류 모형의 SHAP 결과만 확률로 환산할 수 있습니다")

    output_space = summary.attrs['output_space']    # SHAP 값의 단위

    if output_space not in ('확률', '로그오즈'):    # 마진·알 수 없음은 확률로 옮길 수 없다
        raise ValueError(f"SHAP 값의 단위가 '{output_space}' 라 확률로 환산할 수 없습니다")

    # --- 2) 원시 데이터·메타정보 꺼내기 ---
    shap_2d = summary.attrs['shap_values']              # (n, f) 기여도 배열
    x_df = summary.attrs['data']                        # 모델공간 입력 (수준·구간의 기준)
    base_value = float(summary.attrs['expected_value']) # base value
    class_names = summary.attrs['class_names']          # 클래스 목록
    used_class = summary.attrs['class_index']           # 설명 대상 클래스 위치

    # --- 3) 단위별 환산식 ---
    # 로그오즈는 base 에 더한 뒤 시그모이드를 취해야 확률이 되고, 확률 단위는 더한 값이 이미 확률이다.
    def to_proba(z):
        if output_space == '로그오즈':    # p = 1 / (1 + e^-z)
            return 1.0 / (1.0 + np.exp(-z))
        return z                          # 확률 단위 — 그대로

    base_proba = float(to_proba(base_value))    # 아무 정보가 없을 때의 확률

    # --- 4) 변수 고르기 ---
    # 요약표는 이미 mean_abs_shap 내림차순이고 누적 비율까지 들어 있다. 그래프 함수들과 같은 규칙이다.
    k = int(np.searchsorted(summary['cum_ratio'].values, cum_ratio)) + 1
    features = list(summary.index[:max(1, min(k, len(summary)))])

    if inverse is None:         # 역변환 사전을 주지 않은 경우
        inverse = {}

    if column_means is None:    # 의미 사전을 주지 않은 경우
        column_means = {}

    # --- 5) 변수마다 수준·구간별 평균 SHAP → 확률 ---
    parts = []    # 변수별 결과표

    for f in features:    # 변수마다
        v = x_df[f]                                     # 변수값
        s = shap_2d[:, x_df.columns.get_loc(f)]         # 그 변수의 SHAP 값
        inv = inverse.get(f, lambda a: a)               # 라벨용 역변환 (없으면 항등)

        if str(v.dtype) == 'category' or v.nunique() <= max_levels:    # 범주형·고유값이 적은 변수 — 값 하나가 수준 하나
            key = v.astype(object)
            levels = sorted(key.unique())

            if str(v.dtype) == 'category':    # 범주 코드는 그대로 적는다
                labels = [str(lv) for lv in levels]
            else:                             # 숫자 수준은 역변환해 적는다 (예: log(가족 수) → 가족 수)
                labels = [f'{inv(lv):.4g}' for lv in levels]

            key = key.map({lv: i for i, lv in enumerate(levels)})    # 수준 순서 = 정렬 순서
        else:                                                           # 연속형 — 구간으로 나눈다
            b = bins.get(f, 5) if isinstance(bins, dict) else bins    # 변수별 지정이 있으면 우선

            if isinstance(b, int):    # 구간 수 → 분위수 구간
                key, edges = qcut(v, b, labels=False, retbins=True, duplicates='drop')
            else:                     # 경계 리스트 → 지정 구간 (첫 구간은 왼쪽 끝 포함)
                key, edges = cut(v, b, labels=False, retbins=True, include_lowest=True)

            # 구간 라벨 — 역변환한 경계로 적고, 첫 구간만 왼쪽 끝을 포함한다는 뜻으로 '[' 를 쓴다
            labels = [f'{"[" if i == 0 else "("}{inv(edges[i]):.4g}, {inv(edges[i + 1]):.4g}]'
                      for i in range(len(edges) - 1)]

        g = DataFrame({'key': key, 'shap': s}).groupby('key', observed=True)['shap'].agg(['count', 'mean'])

        z = base_value + g['mean']    # base 에 그 수준의 평균 SHAP 만 더한 값
        p = to_proba(z)               # 그때의 확률

        part = DataFrame({
            'n': g['count'].astype(int),          # 수준에 속한 관측치 수
            'ratio': g['count'] / len(v),         # 비율
            'mean_shap': g['mean'],               # 수준별 평균 SHAP
            'z': z,                               # base + mean_shap
            'proba': p,                           # 확률
            'delta_pp': (p - base_proba) * 100,   # base 대비 %p
            'step_dz': z.diff(),                  # 직전 수준 대비 SHAP 변화 (첫 수준은 NaN)
            'step_dpp': p.diff() * 100,           # 직전 수준 대비 확률 변화 (%p)
        })
        part.index = MultiIndex.from_arrays(
            [[column_means.get(f, f)] * len(part), [labels[i] for i in g.index]],
            names=['Feature', 'Level'])
        parts.append(part)

    table = concat(parts)
    table.attrs['base_proba'] = base_proba      # base 확률
    table.attrs['output_space'] = output_space  # 환산에 쓴 단위

    # --- 6) 읽는 법 출력 ---
    print(f'base value: {base_value:.4f} ({output_space}) → 확률 {base_proba:.1%} / 설명 대상 클래스: {class_names[used_class]}')
    print('proba·delta_pp = base 에 그 수준의 평균 SHAP 만 더했을 때의 확률과 base 대비 %p / step_dz·step_dpp = 같은 변수의 직전 수준과의 차이')
    print(" • n: 수준에 속한 관측치 수")
    print(" • ratio: 전체 관측치 대비 비율")
    print(" • mean_shap: 수준별 평균 SHAP")
    print(" • z: base + mean_shap")
    print(" • proba: 확률")
    print(" • delta_pp: base 대비 %p")
    print(" • step_dz: 직전 수준 대비 SHAP 변화")
    print(" • step_dpp: 직전 수준 대비 확률 변화 (%p)")

    return table