1편에서 프로젝트 방향을 잡고 2편에서 광고 의심 리뷰의 특징을 변수로 정리했다. 그런데 막상 모델을 만들려고 하니 EDA 때 미뤄뒀던 문제가 그대로 발목을 잡았다. 수집한 리뷰에 광고인지 아닌지 알려주는 라벨이 하나도 없었다는 점이다.
이번 글은 그 라벨을 직접 만들어내고, 모델을 학습시켜 실제 화면까지 연결한 과정을 정리한다.
1. 문제 정의
분류 모델을 만든다고 하면 보통 데이터를 나누고 모델을 고르고 성능을 비교하는 과정을 떠올린다. 하지만 이번엔 그 앞에서 막혀 있었다. 지도학습은 정답이 있어야 학습이 되는데, 우리 데이터에는 그 정답이 없었다.
수천 건을 직접 읽으며 손으로 라벨을 다는 방법도 생각했다. 그런데 광고 리뷰와 일반 리뷰의 경계가 사람이 봐도 애매한 경우가 많았고, 보는 사람마다 기준이 흔들렸다. 그래서 방향을 바꿨다. 누가 봐도 확실한 리뷰만 규칙으로 라벨을 달고, 애매한 나머지는 데이터 분포에 맡겨 자동으로 나누기로 했다.
정답이 없는 데이터에서 라벨을 만들어내는 것, 그게 이번 모델링의 진짜 과제였다. 모델 성능을 끌어올리는 일은 그다음 문제였다.
전체 흐름은 세 단계로 잡았다. 규칙으로 확실한 것만 먼저 라벨링(Seed)하고, 나머지는 GMM 클러스터링으로 자동 라벨을 부여한 다음, 완성된 데이터셋으로 LightGBM을 학습시켰다.
2. 규칙 기반 라벨링
GMM을 바로 돌리지 않고 규칙 기반 라벨링을 앞에 둔 이유가 있다. 클러스터링은 시작 상태에 따라 결과가 꽤 흔들리는데, 명확한 리뷰 몇 개에 미리 라벨을 박아두면 분포가 한쪽으로 안정적으로 자리를 잡는다. Seed 라벨이 GMM의 출발점을 잡아주는 역할을 하는 셈이다.
규칙 자체는 단순하게 갔다. 광고일 리 없는 리뷰와, 광고라고 봐도 무방한 리뷰만 골라냈다.
seed = np.nan
# 내돈내산이 있거나 리뷰가 매우 짧으면 → 찐리뷰(0)
if feats['my_money_mine'] > 0 or feats['review_len'] < 70:
seed = 0
# 협찬/리뷰어/체험단 키워드가 있으면 → 광고(1)
elif feats['ad_cnt'] > 0 and feats['my_money_mine'] == 0:
seed = 1
"내돈내산"이 들어갔거나 70자도 안 되는 짧은 리뷰는 광고로 보기 어려워 0으로 뒀다. 반대로 "협찬", "리뷰어", "체험단"이 명시된 리뷰는 1로 봤다. 이 규칙으로 라벨이 붙는 건 전체의 일부뿐이고, 대다수는 일부러 NaN으로 남겨 다음 단계로 넘겼다. 애매한 리뷰까지 억지로 규칙에 욱여넣으면 라벨 자체가 부정확해지기 때문이다.
3. GMM 클러스터링
비워둔 NaN 리뷰를 어떻게 처리할지가 제일 고민이었다. 여기서 기댄 가정은 광고 리뷰와 일반 리뷰가 피처 공간에서 다른 자리에 모인다는 것이었다. 광고 리뷰는 길이가 길고 감탄사가 많고 음식점명을 반복하고 작성자 활동이 적은 쪽으로 쏠리는 경향이 있다. 이런 쏠림이 실제로 있다면 라벨이 없어도 데이터가 알아서 두 덩어리로 갈라질 거라고 봤다.
3.1. KMeans vs GMM
비지도로 묶는다면 KMeans가 가장 흔한 선택이다. 다만 KMeans는 각 점을 하나의 군집에 딱 잘라 넣는다. 경계가 모호한 이번 데이터에서는 "이 리뷰가 광고 쪽일 확률이 70%" 같은 정도를 표현할 수 있는 편이 라벨 신뢰도를 따지기에 나았다.
GMM은 데이터가 여러 정규분포가 섞여 만들어졌다고 보고, 각 점이 어느 분포에서 나왔을 확률을 계산한다. 그래서 광고/일반을 딱 자르는 대신 소속 확률을 같이 얻을 수 있었고, 확률이 어중간한 리뷰는 따로 빼둘 여지도 생겼다.
from sklearn.mixture import GaussianMixture
from sklearn.preprocessing import RobustScaler
# 11개 피처를 RobustScaler로 정규화 (이상치 영향 최소화)
scaler = RobustScaler()
X_scaled = scaler.fit_transform(df_nan[FEATURE_COLS])
# 2개 클러스터(광고/일반)로 분리
gmm = GaussianMixture(n_components=2, random_state=42)
gmm.fit(X_scaled)
probs = gmm.predict_proba(X_scaled)
df_nan['gmm_label'] = probs.argmax(axis=1)
정규화에 RobustScaler를 쓴 것도 이유가 있다. 리뷰 길이나 작성자 리뷰 수처럼 극단값이 튀는 변수가 많은데, 평균과 표준편차 기반 표준화는 이런 이상치에 끌려간다. 중앙값과 사분위 범위를 쓰는 RobustScaler는 그 영향을 덜 받아서, 분포로 무리를 가르는 GMM과 궁합이 맞았다.
GMM이 나눈 두 클러스터 중 어느 쪽이 광고인지는 따로 정해줘야 했다. Seed 라벨이 많이 모인 클러스터를 광고(1)에 대응시켜 해결했다. 앞 단계에서 만든 규칙 라벨이 여기서 클러스터를 해석하는 기준점 역할을 했다.
4. 감성 점수 (sent_score)
EDA에서 정의한 피처 중 sent_score는 리뷰 본문을 그대로 넣어 만든 값이 아니다. 리뷰엔 오탈자와 띄어쓰기 오류가 많아서, 원문을 바로 감성 분석에 넣으면 점수가 흔들렸다. 그래서 맞춤법을 먼저 고치고 그 결과로 감성을 분석하는 순서로 갔다.
| 순서 | 역할 | 모델 |
| 1 | 맞춤법 교정 | ET5 (j5ng/et5-typos-corrector) |
| 2 | 긍·부정 감성 분석 | KoELECTRA (Copycats/koelectra-base-v3) |
그런데 맞춤법 교정 모델을 그대로 쓰니 음식점명이나 영어 표기 같은 고유명사를 멋대로 고쳐버리는 일이 있었다. "연남동 손칼국수"가 엉뚱한 단어로 바뀌는 식이다. 그래서 교정 전후를 비교해, 첫 글자가 바뀌거나 길이가 확 줄어든 단어는 원문을 그대로 두도록 막았다.
def protect_nouns_logic(original, fixed):
if not original or not fixed:
return original
# 첫 글자가 바뀌거나 길이가 70% 미만으로 줄면 교정 거부
if original[0] != fixed[0] or len(fixed) < len(original) * 0.7:
return original
orig_words, fixed_words = original.split(), fixed.split()
if len(orig_words) == len(fixed_words):
result = []
for o, f in zip(orig_words, fixed_words):
result.append(o if (o and f and o[0] != f[0]) else f)
return " ".join(result)
return fixed
교정한 텍스트를 KoELECTRA에 넣어 긍·부정 라벨과 확신도(0~1)를 얻고, 그중 확신도를 sent_score로 썼다. 광고 리뷰일수록 극단적으로 강한 긍정 확신이 나올 거라는 가정이었다.
5. LightGBM 학습
라벨이 갖춰진 데이터로 분류 모델을 학습시켰다. 모델은 LightGBM을 골랐다. 피처가 수치형 11개로 정리돼 있어 트리 기반이 자연스러웠고, 학습 속도와 성능의 균형도 무난했다. 부스팅 계열이 어떻게 동작하는지는 앞선 글에서 다룬 적이 있어 넘어간다.
하이퍼파라미터는 GridSearchCV로 찾았고 최종 조합은 다음과 같다.
from lightgbm import LGBMClassifier
model = LGBMClassifier(
learning_rate=0.05,
n_estimators=100,
num_leaves=40,
random_state=42,
)
model.fit(X_train, y_train)
예측 단계에서 결정 하나가 더 남아 있었다. 이진 분류는 보통 확률 0.5를 기준으로 가르는데, 여기선 임계값을 0.21까지 낮췄다.
THRESHOLD = 0.21
proba = model.predict_proba(X)[:, 1]
df["predict_proba"] = proba
df["is_ad"] = (proba >= THRESHOLD).astype(int)
# 명시적 키워드는 모델 예측보다 우선 적용
df.loc[cleaned.str.contains("내돈내산"), "is_ad"] = 0
df.loc[df["ad_cnt"] > 0, "is_ad"] = 1
임계값을 낮춘 건 클래스 불균형 때문이었다. 광고 의심 리뷰가 일반 리뷰보다 훨씬 적어서 0.5 기준으로는 광고를 거의 못 잡았다. ROC 곡선에서 광고를 충분히 걸러내는 지점을 찾다 보니 0.21에 왔다.
여기에 "협찬", "내돈내산"처럼 의미가 분명한 키워드는 모델 확률보다 먼저 적용했다. 모델이 헷갈려도 사람 눈에 확실한 신호는 규칙으로 덮어쓰는 게 안전하다고 봤다.
6. 피처 중요도
모델이 어떤 근거로 판단하는지 확인하는 건 1편부터 강조한 부분이다. "왜 이 리뷰를 광고로 봤는지" 설명할 수 있어야 한다는 목표였다. 학습된 모델의 피처 중요도는 이렇게 나왔다.
| 순위 | 피처 | 해석 |
| 1 | user_review_cnt | 작성자 누적 활동량 — 저활동 계정일수록 의심 |
| 2 | review_len | 리뷰 길이 — 지나치게 길면 홍보성 상세 작성 |
| 3 | emoji_cnt | 이모지 수 — 시각적 어필 과다 |
| 4 | place_name_cnt | 음식점명 반복 — 매장 중심 서술 |
| 5 | excl_cnt | 느낌표 수 — 과도한 감탄 |
눈에 띈 건 텍스트보다 작성자 행동 정보(user_review_cnt)가 가장 큰 영향을 줬다는 점이다. 2편에서 "텍스트만으로는 리뷰 신뢰도를 다 설명하기 어렵다"고 봤던 판단이 모델에서도 그대로 나왔다. 광고 리뷰는 무엇을 썼는지만큼이나 누가 썼는지에서 갈렸다.
7. 점수 변환
모델이 내놓는 predict_proba는 0과 1 사이 숫자다. 분석하는 사람에겐 익숙하지만, 음식점을 찾는 사용자에게 "광고 확률 0.34"는 와닿지 않는다. 그래서 확률을 0~100점 신뢰 점수로 뒤집어 보여줬다.
def calculate_trust_score(predict_proba):
proba = float(predict_proba or 0)
return round(100 - proba * 100) # 광고 확률이 낮을수록 신뢰도 높음
광고 확률이 낮을수록 점수가 높아지게 뒤집고, 음식점 단위로 평균을 내 별점으로 바꿨다. 모델 출력을 날것 그대로 노출하지 않고 사용자가 바로 읽히는 지표로 한 번 더 가공한 건데, 분석을 서비스로 넘길 때 이 마지막 변환이 생각보다 중요했다.
8. 서비스 배포
마지막은 1편에서 약속한 엔드투엔드 파이프라인을 닫는 단계였다. 크롤링한 원본은 임시 테이블(Table1)에 쌓이고, 위 과정을 거쳐 분석이 끝나면 영구 테이블(Table2)로 옮겨진다. Table2 저장 성공을 확인한 다음에야 Table1의 해당 행을 지워서 중복 처리나 데이터 유실을 막았다.
크롤링 원본 → Table1 → 피처 생성 + 모델 예측 → Table2 → Table1 삭제
운영하면서 현실적인 선택도 하나 했다. 학습 단계에서는 ET5와 KoELECTRA를 다 돌려 sent_score를 제대로 만들었지만, 배포 환경에서 이 무거운 모델을 매번 띄우긴 부담스러웠다. 그래서 운영 파이프라인에서는 감성 점수를 중립값으로 고정하고 가벼운 피처 위주로 돌아가게 분리했다. 모든 단계를 최고 사양으로 굴리는 것보다 서비스가 안정적으로 도는 선을 찾는 게 맞다고 판단했다.
화면은 Streamlit Cloud로 만들었고 세 갈래로 나눴다. 메인 화면에서는 지역과 음식점명으로 검색하면 광고 의심 리뷰를 걸러낸 신뢰 점수 순으로 음식점이 카드로 정렬된다.

▲ 메인 화면 — 음식점별 신뢰 점수와 광고 의심 리뷰 수를 카드로 정리
카드를 누르면 들어가는 상세 화면에서는 리뷰가 실제와 광고 의심으로 나뉘어 표시된다. 왼쪽 테두리 색과 배지로 구분되고, 리뷰마다 모델이 계산한 광고 의심 확률이 붙는다. 음식점명을 반복하거나 안내성 문구가 많은 리뷰가 어떻게 잡히는지 화면에서 바로 보인다.

▲ 상세 화면 — 실제 리뷰(초록)와 광고 의심 리뷰(빨강) 구분, 광고 확률 표시
관리자 화면에서는 자치구를 골라 크롤링을 직접 돌리고, 수집부터 분석까지 어디까지 진행됐는지 실시간으로 볼 수 있게 했다. 앞서 설명한 Table1 → Table2 흐름이 화면 위에서 단계별로 표시된다.

▲ 관리자 화면 — 크롤링 실행과 분석 파이프라인 진행 모니터링
9. 정리
이번 프로젝트에서 제일 많이 남은 건 모델 자체보다 라벨이 없는 상황을 어떻게 다루느냐였다. Seed로 확실한 걸 잡고 GMM으로 나머지를 채워 학습시키는 흐름은 정리해놓고 보면 깔끔하지만, 솔직히 한계도 분명하다.
가장 아쉬운 건 진짜 광고 리뷰 정답이 없었다는 점이다. 클러스터가 만들어준 라벨로 학습했기 때문에, 성능 수치가 아무리 높아도 그게 실제 광고를 잘 잡는다는 보장이 되진 않는다. 우리가 만든 라벨이 현실과 얼마나 닮았는지는 끝까지 확인하지 못한 채로 남았다. 광고 의심 리뷰가 워낙 적어 클래스 불균형이 컸던 것도 같은 맥락의 숙제다.
그래도 정답 없는 데이터 앞에서 멈추지 않고 규칙과 분포를 엮어 학습 가능한 형태로 만들어본 건 다음에 비슷한 상황을 만나면 분명 도움이 될 것 같다. 실무 데이터는 대체로 이렇게 지저분하게 들어올 테니 말이다. 🙂
'Project' 카테고리의 다른 글
| [AB Compass] 해싱과 통계 엔진, 핵심 로직 구현 (0) | 2026.06.11 |
|---|---|
| [AB Compass] A/B 테스트 툴, 직접 만들어보기 (0) | 2026.06.11 |
| [Team Project] 네이버 플레이스 광고/체험단 리뷰 판별 모델 개발 - EDA (2) (0) | 2026.01.19 |
| [Kaggle] IEEE-CIS Fraud Detection - 모델링 (3) (0) | 2026.01.13 |
| [Team Project] 네이버 플레이스 광고/체험단 리뷰 판별 모델 개발 - 아이디어 선정 (1) (0) | 2026.01.08 |
HELLO WORLD
안녕하세요. 데이터로 말하는 분석가 모모입니다.
데이터를 구조화하고 분석하는 과정과 실무에 활용되는 도구 중심의 내용을 기록합니다.