Meta의 Ax로 머신러닝 하이퍼파라미터 자동 튜닝하기 — 초보자도 따라 하는 실전 가이드

한 줄 요약: Meta(구 Facebook)가 만든 Ax 라이브러리를 사용하면, 베이지안 최적화(Bayesian Optimization)라는 똑똑한 탐색 방식으로 머신러닝 모델의 하이퍼파라미터를 자동으로 튜닝할 수 있습니다. 이 글에서는 랜덤 포레스트(Random Forest) 모델을 예시로, 제약 조건이 있는 단일 목표 최적화, 다중 목표 최적화(파레토 frontier), 파라미터 제약 조건, 그리고 실험 저장/재로드까지 단계별로 실습합니다.

1. 왜 AI 모델 튜닝이 이렇게 어려울까?

머신러닝 모델은 학습을 시작하기 전, 사람이 미리 정해줘야 하는 하이퍼파라미터(hyperparameter)라는 값들이 많습니다. 예를 들어 랜덤 포레스트라면 n_estimators(트리 개수), max_depth(트리 깊이), min_samples_leaf(리프 노드 최소 샘플 수) 같은 값들이 있죠. 이 값을 어떻게 조합하느냐에 따라 모델의 정확도(accuracy)모델 크기(자원 사용량)가 크게 달라집니다.

문제는 가능한 조합의 수가 너무 많다는 점입니다. 하나하나 손으로 바꿔가며 테스트하는 그리드 서치(grid search)나 무작위로 시도하는 랜덤 서치(random search)는 시간이 오래 걸리고, 최적을 찾기도 어렵습니다. 이때 등장한 방법이 바로 베이지안 최적화입니다. 이전 시도 결과를 학습해 "다음에 어디를 시도하면 좋을지" 똑똑하게 제안해 주는 방식입니다.

이 글에서 다룰 Meta의 Ax는 이 베이지안 최적화를 매우 쉽게 사용할 수 있도록 도와주는 오픈소스 라이브러리입니다.

2. Meta의 Ax란?

Ax는 Meta(구 Facebook) AI Research에서 개발한 적응형 실험 플랫폼(adaptive experimentation platform)입니다. 핵심 특징은 다음과 같습니다.

  • 다양한 파라미터 타입 지원: 정수, 실수, 로그 스케일, 범주형(문자열)을 한 번에 섞어 사용 가능
  • ask–tell 방식의 최적화 루프: "다음 시도할 조합을 알려줘(get_next_trials) → 평가해(evaluate) → 결과를 알려줘(complete_trial)" 라는 단순한 흐름
  • 제약 조건 처리: 결과값에 대한 제약(예: 모델 크기 ≤ 2500)과 입력 파라미터에 대한 제약(예: x1 + x2 ≤ 1.5) 모두 지원
  • 다중 목표 최적화: 정확도와 모델 크기처럼 상반되는 목표를 동시에 고려해 파레토 frontier(최적 trade-off 곡선)를 찾아줌
  • 실험 저장/복원: JSON 파일로 저장하고 나중에 다시 불러와 이어서 분석 가능

3. 실습 1단계 — 환경 준비와 데이터 만들기

먼저 Google Colab 환경을 준비하고 필요한 패키지를 설치합니다. ax-platform이 Ax의 공식 패키지 이름이고, scikit-learn은 머신러닝 모델을 사용하기 위해 필요합니다.

import importlib, subprocess, sys
def _ensure(module, pip_name=None):
    try:
        importlib.import_module(module)
    except ImportError:
        print(f"Installing {pip_name or module} ...")
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pip_name or module])
_ensure("ax", "ax-platform")
_ensure("sklearn", "scikit-learn")

import logging, warnings, time
import numpy as np
import matplotlib.pyplot as plt
warnings.filterwarnings("ignore")
logging.getLogger("ax").setLevel(logging.WARNING)

from ax.api.client import Client
from ax.api.configs import RangeParameterConfig, ChoiceParameterConfig
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score

np.random.seed(0)

이 코드는 (1) 패키지가 없으면 자동으로 설치하고, (2) Ax와 sci킷런에서 필요한 클래스를 불러오며, (3) 경고 메시지와 Ax 내부 로그를 줄여 출력을 깔끔하게 유지합니다. 마지막 줄의 np.random.seed(0)재현성(reproducibility)을 위해 난수 시드를 고정하는 것입니다.

4. 실습 2단계 — 데이터셋과 평가 함수, 검색 공간 만들기

이번에는 가상의 분류용 데이터셋을 만들고, 랜덤 포레스트 모델을 학습시켜 정확도를 측정하는 평가 함수(evaluation function)를 정의합니다.

X, y = make_classification(
    n_samples=1400, n_features=20, n_informative=8, n_redundant=4,
    n_classes=3, random_state=0,
)
CV = StratifiedKFold(n_splits=3, shuffle=True, random_state=0)

def evaluate(p):
    n_est, depth = int(p["n_estimators"]), int(p["max_depth"])
    clf = RandomForestClassifier(
        n_estimators=n_est, max_depth=depth,
        max_features=float(p["max_features"]),
        min_samples_leaf=int(p["min_samples_leaf"]),
        criterion=p["criterion"],
        ccp_alpha=float(p["ccp_alpha"]),
        n_jobs=-1, random_state=0,
    )
    accuracy = cross_val_score(clf, X, y, cv=CV, scoring="accuracy").mean()
    model_size = n_est * depth
    return {"accuracy": float(accuracy), "model_size": float(model_size)}

SEARCH_SPACE = [
    RangeParameterConfig(name="n_estimators", bounds=(50, 300), parameter_type="int"),
    RangeParameterConfig(name="max_depth", bounds=(3, 24), parameter_type="int"),
    RangeParameterConfig(name="max_features", bounds=(0.2, 1.0), parameter_type="float"),
    RangeParameterConfig(name="min_samples_leaf", bounds=(1, 12), parameter_type="int"),
    RangeParameterConfig(name="ccp_alpha", bounds=(1e-5, 1e-1), parameter_type="float", scaling="log"),
    ChoiceParameterConfig(name="criterion", values=["gini", "entropy", "log_loss"], parameter_type="str", is_ordered=False),
]

def run_study(client, total_trials, metric_keys, batch=4):
    records = []
    while len(records) < total_trials:
        trials = client.get_next_trials(max_trials=min(batch, total_trials - len(records)))
        if not trials:
            break
        for idx, params in trials.items():
            full = evaluate(params)
            raw = {k: full[k] for k in metric_keys}
            client.complete_trial(trial_index=idx, raw_data=raw)
            records.append({"trial": idx, "params": params, **full})
    return records

여기서 사용한 핵심 개념들을 정리하면 다음과 같습니다.

  • make_classification: sci킷런이 제공하는 가상 분류 데이터셋 생성 함수. 샘플 1,400개, 특성 20개, 클래스 3개로 구성
  • StratifiedKFold: 클래스 비율을 유지하며 데이터를 k개로 나누는 교차 검증(cross-validation) 전략. 여기서는 3개 fold를 사용
  • model_size: 트리 개수 × 트리 깊이로 정의한, 모델의 대략적인 자원 사용량 지표
  • 검색 공간(search space): 탐색할 하이퍼파라미터들의 후보 범위. 정수형(n_estimators, max_depth, min_samples_leaf), 실수형(max_features), 로그 스케일 실수형(ccp_alpha), 범주형(criterion)이 한꺼번에 섞여 있음
  • run_study: ask–tell 루프를 반복 실행하는 도우미 함수. 한 번에 4개의 조합을 제안받아 평가하고 결과를 기록

5. 실습 3단계 — 제약 조건이 있는 단일 목표 최적화 (Study 1)

첫 번째 실험은 "정확도는 최대한 높이고, 모델 크기는 2500 이하로 유지하라"는 목표입니다. 정확도 하나만 최적화하지만 모델 크기에 제약 조건이 붙어 있는 경우죠.

print("\n=== Study 1: constrained single-objective Bayesian optimization ===")
c1 = Client()
c1.configure_experiment(parameters=SEARCH_SPACE, name="rf_constrained")
c1.configure_optimization(objective="accuracy", outcome_constraints=["model_size <= 2500"])
rec1 = run_study(c1, total_trials=24, metric_keys=["accuracy", "model_size"])

best_params, prediction, best_idx, best_arm = c1.get_best_parameterization()
print("\nBest feasible configuration found:")
for k, v in best_params.items():
    print(f" {k:>16}: {v}")
print(" predicted:", prediction)

feasible = [(r["trial"], r["accuracy"]) for r in rec1 if r["model_size"] <= 2500]
best_so_far, cur = [], -np.inf
for _, acc in feasible:
    cur = max(cur, acc); best_so_far.append(cur)

plt.figure(figsize=(7, 4))
plt.plot(range(1, len(best_so_far) + 1), best_so_far, "o-")
plt.xlabel("feasible trial #"); plt.ylabel("best accuracy so far")
plt.title("Study 1 — convergence (subject to model_size <= 2500)")
plt.grid(alpha=0.3); plt.tight_layout(); plt.show()

여기서 outcome_constraints=["model_size <= 2500"]가 핵심입니다. Ax는 정확도를 높이되 이 조건을 반드시 만족하는 조합만 최적인 것으로 간주합니다. 마지막의 그래프는 수렴(convergence) 곡선으로, 시도 횟수가 늘어날수록 최고 정확도가 어떻게 올라가는지를 보여줍니다.

6. 실습 4단계 — 다중 목표 최적화와 파레토 frontier (Study 2)

두 번째 실험은 제약 조건 없이 "정확도는 높이고, 모델 크기는 작게"라는 두 목표를 동시에 다룹니다. 이처럼 서로 상반되는(trade-off) 목표가 여러 개일 때 사용하는 방법이 다중 목표 최적화(multi-objective optimization)입니다.

print("\n=== Study 2: multi-objective (accuracy vs. model_size) ===")
c2 = Client()
c2.configure_experiment(parameters=SEARCH_SPACE, name="rf_multiobjective")
c2.configure_optimization(objective="accuracy, -model_size")
rec2 = run_study(c2, total_trials=28, metric_keys=["accuracy", "model_size"])

try:
    frontier = c2.get_pareto_frontier()
    print(f"Ax identified {len(frontier)} Pareto-optimal configurations.")
except Exception as e:
    frontier = None
    print("get_pareto_frontier unavailable in this version:", e)

acc = np.array([r["accuracy"] for r in rec2])
size = np.array([r["model_size"] for r in rec2])
order = np.argsort(size)
pareto_idx, best_acc = [], -np.inf
for i in order:
    if acc[i] > best_acc:
        best_acc = acc[i]; pareto_idx.append(i)

plt.figure(figsize=(7, 5))
plt.scatter(size, acc, c="lightgray", label="all trials")
plt.scatter(size[pareto_idx], acc[pareto_idx], c="crimson", zorder=3, label="Pareto front")
plt.plot(size[pareto_idx], acc[pareto_idx], "--", c="crimson", alpha=0.6)
plt.xlabel("model_size (lower = cheaper)"); plt.ylabel("accuracy (higher = better)")
plt.title("Study 2 — accuracy vs. model size trade-off")
plt.legend(); plt.grid(alpha=0.3); plt.tight_layout(); plt.show()

여기서 등장하는 파레토 frontier(Pareto frontier)라는 개념이 중요합니다. 쉽게 말하면 "어떤 다른 조합을 골라도 두 목표를 동시에 더 좋게 만들 수 없는 최적 trade-off 지점들의 집합입니다. 그래프에서는 회색 점이 모든 시도, 빨간색 선이 파레토 frontier를 나타냅니다. 이 곡선을 보면 "조금 더 작은 모델을 원한다면 정확도를 얼마나 양보해야 하는지" 같은 의사결정을 직관적으로 할 수 있습니다.

7. 실습 5단계 — 파라미터 제약 조건 다루기 (Study 3)

세 번째 실험은 더 단순한 2차원 문제로, 입력 파라미터 자체에 제약 조건을 거는 방법을 보여줍니다. x1x2라는 두 변수의 합이 1.5 이하여야 한다는 조건입니다.

print("\n=== Study 3: parameter constraints on a synthetic surface ===")
c3 = Client()
c3.configure_experiment(
    parameters=[
        RangeParameterConfig(name="x1", bounds=(0.0, 1.0), parameter_type="float"),
        RangeParameterConfig(name="x2", bounds=(0.0, 1.0), parameter_type="float"),
    ],
    parameter_constraints=["x1 + x2 <= 1.5"],
    name="constrained_surface",
)
c3.configure_optimization(objective="-dist")
for _ in range(14):
    for idx, p in c3.get_next_trials(max_trials=1).items():
        dist = (p["x1"] - 0.9) ** 2 + (p["x2"] - 0.9) ** 2
        c3.complete_trial(trial_index=idx, raw_data={"dist": float(dist)})

bp, _, _, _ = c3.get_best_parameterization()
print(f"Best point: x1={bp['x1']:.3f}, x2={bp['x2']:.3f}, "
      f"sum={bp['x1'] + bp['x2']:.3f} (constraint: <= 1.5)")
print("Unconstrained optimum would be (0.9, 0.9); Ax respects the boundary.")

제약이 없었다면 최적점은 (0.9, 0.9)가 되어 합이 1.8이 되므로 제약을 위반합니다. Ax는 이 제약을 존중하면서, 허용 범위 안에서 가장 좋은 점을 찾아냅니다. 실무에서는 "두 입력값의 합이 특정 예산을 넘으면 안 된다" 같은 현실적 제약을 표현할 때 유용합니다.

8. 실습 6단계 — 결과 분석과 실험 저장

마지막 단계에서는 Ax가 제공하는 내장 분석 도구실험 저장/복원 기능을 사용합니다.

print("\n=== Ax built-in analyses for Study 1 ===")
try:
    import plotly.io as pio
    if "google.colab" in sys.modules:
        pio.renderers.default = "colab"
    cards = c1.compute_analyses(display=True)
    print(f"Computed {len(cards)} analysis cards.")
except Exception as e:
    print("Interactive analyses didn't render in this environment:", e)
    print("(The matplotlib plots above already capture the key results.)")

print("\n=== Saving / loading the experiment ===")
try:
    c1.save_to_json_file("ax_study1.json")
    reloaded = Client.load_from_json_file("ax_study1.json")
    print("Saved to ax_study1.json and reloaded successfully.")
    rp, _, _, _ = reloaded.get_best_parameterization()
    print("Best params from reloaded client match:", rp == best_params)
except Exception as e:
    print("JSON persistence API differs in this version:", e)
    print("See: https://ax.dev/docs/recipes/experiment-to-json")

print("\nDone. You optimized a mixed-type search space with constraints, "
      "traced a Pareto frontier, and persisted in the experiment.")

compute_analyses를 호출하면 Ax는 민감도 분석(sensitivity analysis), 교차 검증 결과, 파라미터 중요도 같은 진단 카드를 Plotly 인터랙티브 차트로 만들어 줍니다. Colab 환경이 아닌 경우 위 코드처럼 예외 처리로 우아하게 넘어갑니다.

save_to_json_fileload_from_json_file를 사용하면 실험 전체(어떤 조합을 시도했고, 어떤 결과를 얻었는지)를 JSON 파일로 저장했다가 나중에 그대로 복원할 수 있습니다. 실험의 재현성공유에 매우 유용한 기능입니다.

9. 정리 — 무엇을 배웠는가?

① 다양한 파라미터 타입을 한꺼번에 다루는 검색 공간을 정의할 수 있습니다.

② 결과값에 대한 제약 조건(model_size <= 2500)을 걸어 단일 목표 최적화를 수행할 수 있습니다.

③ 다중 목표 최적화로 정확도와 모델 크기의 trade-off를 분석하고 파레토 frontier를 시각화할 수 있습니다.

④ 입력 파라미터에 대한 제약 조건(x1 + x2 <= 1.5)도 자연스럽게 표현할 수 있습니다.

⑤ 내장 분석 도구로 결과를 진단하고, JSON 파일로 실험을 저장·복원할 수 있습니다.

핵심 용어 한눈에 보기

용어쉬운 설명
베이지안 최적화이전 결과를 학습해 "다음 시도할 곳"을 똑똑하게 제안하는 탐색 방법
하이퍼파라미터모델 학습 전에 사람이 정해주는 설정값(트리 개수, 깊이 등)
검색 공간탐색할 하이퍼파라미터 후보의 범위
ask–tell 루프Ax에게 다음 시도를 요청(ask)하고 결과를 알려주는(tell) 반복 과정
파레토 frontier서로 상반되는 목표 사이의 최적 trade-off 지점들의 집합
교차 검증데이터를 여러 조각으로 나눠 모델 성능을 안정적으로 측정하는 방법

이 가이드를 따라 하면, 머신러닝 모델 튜닝에 익숙하지 않은 개발자도 제약 조건이 있는 최적화다중 목표 trade-off 분석을 손쉽게 시도해 볼 수 있습니다. Ax는 다양한 분야(ML, A/B 테스트, 시뮬레이션 등)에 범용적으로 쓸 수 있는 도구이므로, 한 번 익혀두면 여러 프로젝트에서 응용할 수 있습니다.