Forge Fate
퀀트·데이터 연구

복잡한 모델은 더 나을까? 로지스틱 회귀와 트리 모델을 같은 조건에서 비교하기

약 10분

근거와 검증 범위 — 모델 비교 설계

모멘텀·로지스틱 회귀·트리 모델의 공통 비교 조건을 설계합니다. ETF 가격 스냅샷·기간 경계·체결 자료·실행 기록이 제시되지 않아 모델별 정확도·수익률·낙폭의 우열을 판정하지 않습니다.

7편에서 만든 로지스틱 회귀 기준선에 이번에는 의사결정나무와 랜덤 포레스트를 더한다. 핵심은 모델 이름의 화려함이 아니다. 복잡한 모델이 의미 있으려면 단순 모멘텀·로지스틱 회귀와 같은 데이터, 같은 시간 순서, 같은 거래 규칙, 같은 비용 가정에서 비교돼야 한다.

이 글은 실제 ETF 성과를 보고하지 않는다. ETF 목록, 가격 스냅샷, 기간 경계, 체결 자료와 실행 기록이 제공되지 않았으므로, 어느 모델의 정확도·수익률·낙폭이 더 좋다고 결론 낼 수 없다. 대신 복잡도를 늘릴 때 필요한 공정한 실험 계약을 고정한다. 교육·연구 목적의 설계이며, 특정 ETF의 매수·매도를 권하지 않는다.

네 비교군을 같은 출발선에 세우기

12개월 모멘텀은 기존 규칙을 유지한다. 나머지 세 모델만 같은 ML 특징과 같은 상승/비상승 레이블을 입력으로 사용한다.

비교군신호 생성 방식복잡성역할
12개월 모멘텀기존 12개월 모멘텀 규칙사전 정의한 규칙단순 기준선
로지스틱 회귀ML 특징으로 양성 클래스 점수 추정정규화된 선형 분류 기준선 [S9]첫 ML 기준선
DecisionTreeClassifier특징과 임계값을 순차적으로 분할 [S1]비선형 분할, 단일 트리복잡도 증가의 첫 단계
RandomForestClassifier여러 트리의 예측을 결합 [S2]부분표본과 평균화 [S2]복잡도 증가의 두 번째 단계

로지스틱 회귀와 트리 계열의 predict_proba 출력은 거래 규칙에 넣기 전의 모델 점수다. 이를 보정된 투자 성공 확률이나 미래 수익의 보장으로 읽어서는 안 된다. [S1] [S2] [S9]

독자가 실험으로 답할 질문은 하나다.

복잡한 모델이 같은 조건에서 분류 성능과 비용 후 경제적 결과를 함께 개선하는가?

그 답은 실행 전에는 알 수 없다.

비선형 분할은 무엇을 더하는가

로지스틱 회귀는 특징들의 관계를 비교적 단순한 기준선으로 다룬다. 반면 의사결정나무는 Gini, entropy, log loss 같은 기준을 사용해 특징과 임계값으로 노드를 나누고, 이 분할을 반복해 더 복잡한 의사결정 영역을 표현한다. [S1] [S9]

예를 들어 트리는 다음과 같은 조건의 조합을 표현할 수 있다.

text
if return_20d > threshold_a:
    if volatility_20d < threshold_b:
        predict higher up score
    else:
        predict lower up score
else:
    predict lower up score

이는 “최근 상승 흐름이 있더라도 변동성이 높은 경우는 다르게 본다”는 가설을 모델에 넣을 수 있다는 뜻이다. 그러나 이 구조가 ETF의 미래 성과를 더 잘 예측한다는 증거는 아니다. 비선형성을 표현할 수 있다는 사실과 테스트 구간에서 우월하다는 결과는 구분해야 한다.

단일 트리의 과적합과 숲의 평균화

DecisionTreeClassifier는 기본 설정에서 완전히 성장한 비가지치기 트리를 만들 수 있다. 깊이가 깊고 리프의 표본 수가 작아질수록 학습창의 세부 패턴까지 외울 위험이 커진다. 그래서 max_depth와 min_samples_leaf는 결과를 보기 전에 제한된 후보로 정해야 한다. [S1]

RandomForestClassifier는 서로 다른 부분표본에 여러 결정트리를 적합하고 예측을 평균화하는 방식이다. scikit-learn은 이를 예측 정확도 개선과 과적합 제어를 위한 방법으로 설명하며, Breiman의 원 논문은 일반화 오차가 개별 트리의 강도와 트리 사이 상관에 의존한다고 설명한다. [S2] [S3]

여기서도 방법론과 결과를 분리한다.

  • 확인된 방법론: 랜덤 포레스트는 여러 트리의 예측을 결합하고 평균화한다. [S2] [S3]
  • 확인되지 않은 결과: 랜덤 포레스트가 이 ETF 실험에서 모멘텀, 로지스틱 회귀 또는 단일 트리보다 높은 테스트 성과를 낸다.

OOB 점수를 켤 수 있더라도, 그것을 시간순 학습·검증·테스트 분할의 대체물로 취급하지 않는다. [S2] 이번 근거 범위에서는 OOB가 시간 순서를 보존한다는 확인이 없다.

공정 비교 계약

복잡성의 효과만 비교하려면 아래 조건을 네 비교군에 공통으로 고정한다.

항목네 비교군에 한 번만 고정할 조건
데이터·입력하나의 ETF 목록·가격 스냅샷을 공통 사용한다. 모멘텀은 기존 규칙을 유지하고, 세 ML 모델에는 같은 특징과 레이블을 넣는다.
시간·학습학습·검증·테스트 경계는 결과 전에 날짜 단위로 정한다. 같은 signal_date의 모든 ETF 행을 함께 배정하고, label_end_date < signal_date인 행만 학습에 쓴다. 이는 시간순 원칙을 ETF 패널에 적용한 설계 판단이다. [S4] [S5]
실행월말 신호 뒤 다음 거래일 종가에 리밸런싱하고, 새 비중은 그 이후 수익에 적용한다. 이는 체결 마찰을 생략한 교육용 근사이며, 선택 종목은 동일가중·미선택은 현금으로 둔다.
선택·평가전처리는 각 학습창에서만 fit한다. 검증에서 고정한 선택을 테스트에서 다시 고르지 않으며, 기본 임계값 0.50과 편도 0/5/10/20bp 비용 시나리오에서 정확도와 비용 후 수익률·최대낙폭·turnover를 따로 본다. [S5] [S6] [S7]

선택 편향도 비용처럼 관리하기

트리 계열의 복잡도는 깊이와 리프 크기, 숲의 트리 수와 특징 선택처럼 추가 선택지를 만든다. 후보를 늘릴수록 검증 구간에 우연히 맞는 설정을 고를 위험도 커지므로, 후보와 탐색 횟수를 결과 전에 제한하고 기록한다. [S6] [S7]

비교 대상사전 제한할 선택지기록할 내용
로지스틱 회귀기존 기준선의 제한 후보후보 수와 실행 횟수
의사결정나무max_depth, min_samples_leaf의 소수 조합조합 수와 검증 선택 기준
랜덤 포레스트n_estimators, max_depth, min_samples_leaf, max_features의 소수 조합조합 수, 시드, 실제 실행 횟수
거래 임계값기본값 0.50추가 탐색 여부와 횟수

기본 임계값 0.50과 편도 0/5/10/20bp는 시장의 참값이 아니라 사전 가정이다. 테스트 성과를 본 뒤 깊이·특징 수·임계값 후보를 추가하면, 그 작업은 새 탐색으로 분리해 기록한다. [S6] [S7]

트리와 숲을 같은 흐름에 추가하는 최소 코드

The input table contains one row per ETF and signal date. signal_date is the observation date, label_end_date is the date on which the future label becomes known, symbol identifies the ETF, feature columns must be available on the signal date, and label is binary. Monthly training windows are skipped when they cannot satisfy the shared class-1 probability output contract. The output includes each eligible model's up_probability, selected portfolio weights, and predictions for evaluation.

python
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier

FEATURES = [
    "return_1d",
    "return_5d",
    "return_20d",
    "ma_gap_20_120",
    "volatility_20d",
    "volume_change",
    "market_return_20d",
]
THRESHOLD = 0.50

# Candidate lists must be declared before validation is inspected.
TREE_CANDIDATES = [
    {"max_depth": 3, "min_samples_leaf": 20},
    {"max_depth": 5, "min_samples_leaf": 40},
]
FOREST_CANDIDATES = [
    {
        "n_estimators": 300,
        "max_depth": 5,
        "min_samples_leaf": 20,
        "max_features": "sqrt",
    },
]

def make_models(selected_tree, selected_forest):
    return {
        "logistic": Pipeline(
            [
                ("scale", StandardScaler()),
                ("classifier", LogisticRegression(max_iter=2_000, random_state=7)),
            ]
        ),
        "tree": DecisionTreeClassifier(random_state=7, **selected_tree),
        "forest": RandomForestClassifier(
            random_state=7,
            n_jobs=-1,
            **selected_forest,
        ),
    }

def positive_probability(model, X):
    positive_index = list(model.classes_).index(1)
    return model.predict_proba(X)[:, positive_index]

def select_equal_weights(frame, threshold=THRESHOLD):
    selected = frame["up_probability"].ge(threshold)
    frame["weight"] = 0.0
    if selected.any():
        frame.loc[selected, "weight"] = 1.0 / selected.sum()
    return frame

def walk_forward_predictions(
    samples,
    test_signal_dates,
    selected_tree,
    selected_forest,
):
    predictions = []

    for signal_date in test_signal_dates:
        # Only labels known before this signal date may enter training.
        train = samples[
            (samples["signal_date"] < signal_date)
            & (samples["label_end_date"] < signal_date)
        ].copy()
        current = samples.loc[
            samples["signal_date"].eq(signal_date)
        ].copy()

        # Skip windows that cannot satisfy the shared class-1 probability output contract.
        if train["label"].nunique() != 2:
            continue

        # Each pipeline fits preprocessing only on the historical window.
        for model_name, model in make_models(
            selected_tree, selected_forest
        ).items():
            model.fit(train[FEATURES], train["label"])

            result = current[["signal_date", "symbol", "label"]].copy()
            result["model"] = model_name
            result["up_probability"] = positive_probability(
                model, current[FEATURES]
            )
            predictions.append(select_equal_weights(result))

    return predictions

During validation, select exactly one predeclared candidate for each model. Apply only those fixed selections during the test period, and do not reselect tree depth, forest settings, or thresholds. [S6] [S7]

This is a minimal contract example, not a full backtest. It loops through test periods defined by date, trains only on labels that ended before each signal date, and fits logistic-regression scaling inside the pipeline for every historical window. The three ML models receive the same feature set and label. [S1] [S2] [S5] [S9]

결과는 분류층과 경제층으로 나누어 읽기

분류층은 예측의 분포를, 경제층은 동일 거래 규칙 아래의 비용 후 결과를 확인한다.

관찰확인할 질문
정확도는 높지만 비용 후 수익률은 낮다turnover 또는 손실 구간의 영향이 커졌는가
0bp에서만 우세하다거래비용 가정에 민감한 결과인가
최대낙폭은 낮고 현금 비중이 높다위험 감소가 단순 노출 감소에서 왔는가
검증에서는 좋고 테스트에서는 약하다선택 편향 또는 국면 의존 가능성이 있는가
단일 트리와 숲의 결과가 다르다평균화의 효과인지, 특정 표본에 맞은 우연인지 추가 검토가 필요한가

정확도와 비용 후 성과를 하나의 숫자로 합치면 원인을 잃기 쉽다. 실제 데이터와 실행 결과가 없으므로, 어느 모델의 우위나 비용 후 지속성을 주장할 수 없다. [S6] [S7]

복잡도를 늘리기 전에 고정할 것

트리의 비선형 분할과 랜덤 포레스트의 평균화는 로지스틱 회귀와 다른 예측을 만들 수 있지만, 우월성을 뜻하지는 않는다. [S1] [S2] [S3]

복잡도를 바꾸기 전에는 데이터·시간·비용·탐색 예산을 고정하고, 테스트 결과를 다음 선택의 근거로 쓰지 않는다.

다음 9편에서는 누수, 선택 편향, 국면 의존처럼 우연을 실력으로 오해하게 만드는 금융 ML의 실패 원인을 점검한다.

Sources

오류 제보·의견 보내기

글 제목과 주소가 포함된 메일 초안을 엽니다. 내용과 받는 사람을 확인한 뒤 보내주세요.

받는 사람: [email protected]

메일 앱에서 작성

메일 앱이 열리지 않으면 아래 내용을 복사해 평소 쓰는 이메일에 붙여 넣으세요.

문의 안내