DL

Deep Learning #


2026-03-04 ⋯ BERT 개념이해 #5 Linear

Linear가 풀려는 문제: “좋은 표현”을 더 좋은 표현으로 가공해야 한다 Self-Attention까지 지나오면 각 토큰은 이미 문맥이 섞인 768차원 벡터를 갖는다. 그런데 여기서 중요한 질문이 생긴다. “문맥을 섞었다”는 건 관계를 모았다는 뜻이지, 그 관계를 사람이 원하는 형태의 특징으로 정리했다는 뜻은 아니다. 예를 들어 분류 문제라면, 모델은 결국 “이 기사에서 business를 강하게 시사하는 패턴” 같은 걸 잡아야 한다. Self-Attention이 단어들 사이의 연결을 만들어줬다면, 그 다음 단계에서는 그 연결 결과를 다시 재조합해서 “유용한 특징”으로 변환해야 한다. Linear 레이어는 바로 이 변환을 담당한다. 한마디로 Linear는 벡터를 “다른 관점의 좌표계”로 옮기는 장치다. 같은 정보를 더 잘 쓰기 위해, 정보를 섞고 재배치하는 가장 기본적인 도구가 Linear다. 1차원에서 선형 변환은 너무 단순해서 감이 바로 온다. x에 w를 곱하고 b를 더해서 y를 만든다. “크기를 조절하고 위치를 이동한다”는 게 전부다. 그런데 신경망은 1차원이 아니라 768차원 같은 고차원 벡터를 다룬다. 여기서 선형 변환의 진짜 의미가 드러난다. 출력 벡터의 각 성분 yᵢ는 입력의 모든 성분 x₁~x₇₆₈을 가중합해서 만든다. 다시 말하면, 출력의 한 차원은 입력의 768개 정보를 다 섞어서 만든 “새로운 특징 하나”다. 이 관점이 정말 중요하다. Linear 레이어는 “특징을 고르는 게 아니라, 특징을 새로 만든다.” 입력 벡터의 각 차원은 이미 어떤 의미를 가진 축들이지만, 그 축들이 지금 당장 문제에 가장 유용한 축이라고 보장할 수 없다. Linear는 그 축들을 섞어서, 모델이 필요로 하는 새 축을 만들어준다. 그래서 Linear는 단순한 차원 변환기가 아니라, “특징 생성기”다. 행렬로 보면 더 명확하다: 한 번에 256개의 새 특징을 만든다 입력이 768차원이고 출력이 256차원이라면, 가중치 행렬 W는 (256×768) 크기다. 이 말은 무엇이냐면, 출력 차원 하나를 만들기 위해 768개의 가중치가 필요하고, 그런 출력을 256개 만들기 위해 256줄이 필요하다는 뜻이다. 즉 W의 각 행은 “출력의 한 차원을 만드는 레시피”다. 그리고 편향 b는 출력 차원마다 하나씩 있어서, 그 새 특징의 기준점을 이동시킨다. 그래서 Linear는 결국 이렇게 행동한다. “입력 벡터를 보고, 내가 학습해둔 레시피 256개를 동시에 적용해서, 256개의 새 특징을 뽑아낼게.” 이 한 문장으로 Linear의 기능이 끝까지 설명된다. BERT에서 Linear가 많이 등장하는 이유: 트랜스포머는 ‘선형 혼합’이 기본 재료다 BERT를 뜯어보면 Linear가 거의 모든 곳에 깔려 있다. 이유는 트랜스포머의 핵심이 “관계를 계산하고, 그 결과를 다시 섞어 표현을 만드는 것”인데, 그 섞기 작업의 기본 도구가 Linear이기 때문이다. Self-Attention 내부에서 Q, K, V를 만드는 것도 Linear다. 여기서 Linear의 역할은 “입력 벡터를 다른 공간으로 투영(projection)해서, 비교에 적합한 형태로 바꾸는 것”이다. 같은 입력이라도 W_Q를 통과하면 “무엇을 찾을지”라는 관점의 벡터가 되고, W_K를 통과하면 “내가 어떤 단서인지”라는 관점의 벡터가 되고, W_V를 통과하면 “내가 전달할 내용”이 된다. 이건 단순히 숫자를 바꾸는 게 아니라, 같은 정보를 서로 다른 역할로 분해하는 과정이다. 그리고 여러 헤드에서 나온 결과를 다시 통합할 때도 W_O라는 Linear를 한 번 더 써서 “12개 관점에서 나온 정보를 다시 한 덩어리로 정리”한다. 즉 Attention의 핵심이 Q·Kᵀ와 Softmax처럼 보이지만, 실제로 그 Attention이 유용해지게 만드는 건 그 앞뒤의 Linear 투영들이다. Linear가 없으면 Attention은 “원래 임베딩 공간에서의 단순 유사도”에 갇히기 쉽고, 관계 학습의 자유도가 크게 줄어든다. FFN에서 768→3072→768을 하는 이유: 넓게 펼쳐서 조합한 뒤 다시 압축한다 트랜스포머 블록에서 Self-Attention 다음에 항상 나오는 것이 FFN(Feed-Forward Network)이다. 구조가 768→3072→768인 이유는 겉으로 보면 이상하다. “왜 굳이 4배로 늘렸다가 다시 줄이지?” 여기서 가장 중요한 직관은 이거다. Self-Attention이 하는 일은 “어떤 토큰이 어떤 토큰을 볼지 정해서 정보를 섞는 것”이다. 즉 관계 기반의 가중합이다. 하지만 그걸로 끝내면 표현이 ‘선형적인 혼합’에 머물 수 있다. 문장 이해에는 “단순 혼합”이 아니라 “조건부 조합”이 필요할 때가 많다. 예를 들어 “A가 있고 B가 있고 동시에 C가 있을 때만” 강한 신호가 되는 패턴은 단순한 가중합만으로는 충분히 표현하기 어렵다. 그래서 FFN은 중간에 GELU 같은 비선형 함수를 넣어서, 특징들 사이의 비선형 조합을 만들 수 있게 한다. 그런데 왜 차원을 3072까지 넓히냐면, 비유로 말하면 작업대가 넓을수록 더 복잡한 조립을 할 수 있기 때문이다. 768차원 공간에서는 768개의 축 위에서만 조합을 해야 하지만, 3072차원으로 확장하면 훨씬 많은 “중간 특징 슬롯”이 생긴다. 그 슬롯들에 “조합 결과”를 임시로 만들어 올려놓고, 마지막에 다시 768로 압축하면서 필요한 것만 추려서 정리한다. 그래서 768→3072는 “표현을 펼쳐서 조합할 공간을 만들기”이고, 3072→768은 “조합된 결과를 다시 모델의 기본 차원으로 정돈하기”다. 이 구조가 트랜스포머에서 경험적으로 매우 잘 작동해서 표준이 됐다. Classifier에서 768→256→4를 하는 이유: 바로 압축하면 중요한 패턴이 뭉개진다 뉴스 분류처럼 최종 출력이 4개 클래스인 경우, 가장 단순한 방법은 768을 바로 4로 보내는 것이다. 하지만 768차원은 굉장히 풍부한 표현이고, 그 안에는 다양한 정보가 얽혀 있다. 그걸 한 번의 선형 변환으로 바로 4개로 압축하면, “분류에 필요한 특징을 추출하는 중간 단계”가 거의 없다. 그래서 EnhancedClassifier처럼 768→256→4로 한 번 완충층을 두면, 256차원 공간에서 “분류에 필요한 정보만 골라 정리”할 시간이 생긴다. 그리고 중간에 GELU를 끼우면 단순 선형 분리보다 더 복잡한 결정 경계를 만들 수 있다. 결과적으로 분류기는 BERT가 만든 표현을 더 잘 활용한다. 계산량 관점에서의 함정: Attention보다 FFN이 더 무거울 때가 많다 사람들은 보통 트랜스포머에서 가장 비싼 게 Self-Attention이라고 생각한다. 왜냐하면 O(L²)라는 말이 너무 강렬하기 때문이다. 그런데 실제로는 길이 L이 192 정도로 제한된 설정에서는 FFN이 더 많은 연산을 할 수 있다. FFN은 토큰마다 768→3072 같은 큰 행렬곱을 수행하고, 이걸 시퀀스 길이 L만큼 반복한다. 그래서 총량이 L×d×4d 형태로 커진다. 결국 “길이가 아주 길어질 때는 Attention이 병목이 되지만, 길이가 적당할 때는 FFN이 연산과 파라미터의 대부분을 차지한다”는 현상이 생긴다. 그래서 BERT 파라미터를 뜯어보면 FFN 쪽이 굉장히 큰 비중을 가져간다. 트랜스포머를 최적화할 때 사람들이 FFN 최적화도 매우 중요하게 보는 이유가 여기에 있다. 초기화와 학습률이 다른 이유: 어디는 이미 배워놓았고, 어디는 새로 배운다. HuggingFace에서 불러오는 BERT의 W_Q, W_K, W_V, W_O, 그리고 FFN의 Linear들은 사전학습으로 이미 꽤 괜찮은 값으로 세팅되어 있다. 그래서 fine-tuning 때는 이 가중치를 크게 흔들지 않도록 학습률을 낮게 잡는다. 반면 EnhancedClassifier의 Linear는 새로 붙인 층이라 랜덤 초기화에서 시작한다. 이 부분은 “처음부터 배워야 하는 영역”이기 때문에 상대적으로 더 높은 학습률을 허용할 수 있다. 즉 Linear는 같은 Linear라도, 어떤 것은 이미 언어 지도를 학습한 상태의 선형 변환이고, 어떤 것은 새 과제에 맞게 처음부터 학습해야 하는 선형 변환이다. 정리: Linear는 “정보를 섞어 새 특징을 만드는 기본 기계”다 Linear 레이어는 입력 벡터의 모든 원소를 가중합해 출력 벡터의 각 차원을 만든다. 그래서 단순히 차원을 늘리고 줄이는 도구가 아니라, 입력 특징들을 섞어서 새로운 특징 축을 만들어내는 장치다. BERT에서 Linear는 Q/K/V/O 투영으로 Attention이 관계를 학습할 수 있게 만들고, FFN의 768→3072→768 구조로 비선형 조합을 위한 넓은 작업 공간을 제공하며, 분류기에서는 768→256→4처럼 단계적으로 압축하면서 분류에 필요한 특징을 추출한다. 트랜스포머에서 Linear는 “모든 중요한 연산이 일어나기 전에, 그리고 일어난 후에” 항상 등장하는 이유가 있다. 관계를 계산하든, 조합을 만들든, 최종 결정을 내리든, 결국 정보는 한 번 더 섞여야 더 쓸만한 형태가 되기 때문이다. - Linear = 모든 입력 원소의 학습된 가중합 + 편향 - `y = xW^T + b`: 입력(in)에서 출력(out) 차원으로 선형 변환 - BERT에서 등장하는 곳: - Self-Attention Q/K/V/*: 768→768 (문맥 추출을 위한 공간 변환) - FFN**: 768→3072→768 (4배 확장 후 압축, 비선형 특징 포착) - Classifier: 768→256→4 (분류를 위한 점진적 차원 축소) - 활성화 함수(GELU)와 함께 써야 비선형 특징 학습 가능 - 파라미터: FFN이 BERT 전체의 가장 큰 비중 차지 (56.6M/110M)


2026-03-04 ⋯ BERT 개념이해 #4 Self-Attention

Self-Attention이 풀려는 문제: 단어는 혼자서는 의미가 부족하다 사람은 문장을 읽을 때 단어를 따로따로 보지 않는다. “it” 같은 단어는 그 자체로는 거의 빈 껍데기다. “그것”이 무엇인지 알려면 앞뒤 문맥을 같이 봐야 한다. "The animal didn't cross the street because it was too tired"에서 “it”이 가리키는 대상을 사람은 자연스럽게 “animal”로 잡는다. 하지만 컴퓨터는 문장을 단어 목록처럼만 보면 “it”이 무엇을 가리키는지 알 근거가 없다. Self-Attention은 바로 여기서 출발한다. 문장 속 각 단어가 문장의 다른 모든 단어를 한 번씩 바라보면서, “내가 의미를 만들 때 누구의 정보를 얼마나 가져올까?”를 계산하는 장치다. 이 계산 결과로 각 단어는 문맥이 섞인 새 벡터 표현을 얻게 되고, 그 새 표현 안에 “it은 animal 쪽에 더 기대고 있다” 같은 정보가 자연스럽게 포함된다. Attention의 직관: 검색창(Q)으로 색인(K)을 비교해 책 내용(V)을 가져온다 도서관에서 어떤 책을 찾을 때 우리는 “내가 원하는 것”을 머릿속에 갖고 있다. 이게 Query다. 그리고 책마다 제목, 태그, 키워드 같은 “요약 정보”가 있다. 이게 Key다. 마지막으로 책의 본문이 실제 정보다. 이게 Value다. 검색은 이렇게 된다. Query와 각 책의 Key를 비교해서 관련도를 점수로 만든 다음, 그 점수들을 확률처럼 바꿔서(Value를) 얼마나 가져올지 결정한다. 관련도가 높은 책의 Value를 더 많이 가져오고, 관련도가 낮은 책의 Value는 조금만 가져오거나 거의 무시한다. 결국 “찾고 싶은 것”이라는 기준으로 문서들을 가중합해서, 내가 원하는 정보를 조합해 얻는다. Self-Attention은 이 구조를 문장 내부로 가져온 것이다. 외부에서 Query가 오는 게 아니라, 문장 속 각 토큰이 자기 자신을 Query로 만들어서 “나는 지금 누구를 참고해야 의미가 좋아질까?”를 스스로 묻는다. 그래서 “it” 토큰은 자기 Query로 문장 안의 Key들을 훑어보고, “animal” 쪽을 더 크게 참고하는 가중치를 만들 수 있다. 결과적으로 “it”의 새 표현은 “animal의 정보가 섞인 it”이 된다. 이게 컴퓨터가 공참조 같은 문제를 풀기 시작하는 방식이다. 왜 Q/K/V로 굳이 나누나: 같은 벡터로 비교하면 늘 자기 자신이 이긴다 임베딩을 거친 각 토큰은 768차원 벡터 x로 표현된다. 여기서 초보자가 흔히 떠올리는 생각은 “그럼 이 x들끼리 그냥 내적해서 가까운 애를 찾으면 되지 않나?”다. 그런데 그렇게 하면 문제가 생긴다. 같은 벡터끼리의 내적은 항상 가장 크다. 즉 각 토큰은 “자기 자신”에게 attention이 과도하게 쏠리기 쉽다. 그러면 문맥을 섞는 장치가 아니라 “난 나만 볼래”가 되어버린다. 그리고 문맥을 섞어야 할 이유가 사라진다. 그래서 트랜스포머는 입력 벡터 x를 그대로 쓰지 않고, 서로 다른 관점의 선형 변환을 세 개 만든다. 하나는 Query를 만드는 변환 W_Q, 하나는 Key를 만드는 변환 W_K, 하나는 Value를 만드는 변환 W_V다. 같은 입력 x라도 W_Q로 바꾸면 “내가 무엇을 찾고 싶은가”라는 성격의 벡터가 되고, W_K로 바꾸면 “내가 어떤 특징을 가진 토큰인가”라는 색인 벡터가 되고, W_V로 바꾸면 “내가 전달할 내용은 무엇인가”라는 내용 벡터가 된다. 이 세 공간이 서로 다르기 때문에, 단순히 자기 자신이 항상 최고점이 되는 현상이 완화되고, “it은 animal을 더 찾는다” 같은 관계 학습이 가능해진다. 즉 Q/K/V 분리는 Attention을 단순한 유사도 매칭이 아니라 “관계 기반 정보 추출”로 바꾸는 핵심 설계다. Scaled Dot-Product의 핵심: 유사도 행렬을 만들고 Softmax로 비율을 만든다 이제 계산을 한 번 머릿속으로 그려보자. 문장 길이가 L이면 토큰은 L개다. 각 토큰은 Query 벡터 하나를 가진다. 그리고 문장에 있는 모든 토큰은 Key 벡터도 하나씩 가진다. 어떤 토큰 i가 “다른 토큰 j를 얼마나 참고할까?”를 계산하려면 Q_i와 K_j의 내적을 보면 된다. 이걸 모든 i, j 조합에 대해 한 번에 계산하면 (L×L) 크기의 점수 행렬이 생긴다. 이 행렬의 i번째 줄은 “i 토큰이 문장 전체를 바라본 점수표”다. 이후 Softmax를 i번째 줄에 적용하면 그 줄이 확률 분포처럼 바뀐다. 즉 i 토큰은 문장 전체의 토큰들을 가중치로 가지게 된다. 그 가중치로 Value들을 가중합하면, i 토큰의 새로운 표현이 만들어진다. 이 새 표현은 “문장 전체에서 내가 필요한 정보를 적당히 섞어 만든 요약”이다. 여기서 스케일링 / √d_head는 수치 안정성을 위한 장치다. 내적은 차원이 커질수록 값이 커지는 경향이 있다. d_head가 64면 내적 값이 상대적으로 커지는데, 그 큰 값이 Softmax로 들어가면 Softmax가 너무 날카로워져서 한두 개 위치만 1에 가깝고 나머지는 0에 가까운 극단 분포가 되기 쉽다. 그러면 학습이 불안정해지고, 작은 변화에 결과가 크게 튄다. 그래서 내적을 √64=8로 나눠 점수의 규모를 적당히 눌러준다. 이것은 “Softmax가 숨 쉬는 범위”를 유지하기 위한 스케일 조절이라고 생각하면 된다. 실제 배치에서는 길이가 다른 문장들을 맞추기 위해 PAD를 붙인다. 그런데 PAD는 의미가 없는 빈칸이다. 만약 Self-Attention이 PAD까지 진짜 단어처럼 참고해버리면, 문맥은 의미 없는 값이 섞이면서 오염된다. 그래서 attention_mask가 필요하다. mask가 0인 위치, 즉 PAD 위치는 점수(scores)에 아주 큰 음수(-1e9 같은 값)를 더해준다. 그러면 Softmax를 통과한 뒤 그 위치의 확률이 사실상 0이 된다. 결과적으로 Value 가중합에서 PAD는 기여하지 못한다. 즉 “빈칸을 절대 보지 마”라는 강제 규칙을 수치적으로 구현한 것이다. Self-Attention은 어떤 토큰이 어떤 토큰을 보는지에 대한 확률 분포를 만든다. 그런데 이 분포가 훈련 데이터에 과하게 맞춰지면 “이 상황이면 무조건 저 단어만 봐라” 같은 편향이 생길 수 있다. 그래서 Softmax로 만든 attention_probs에 Dropout을 걸어준다. 학습 중에는 일부 attention 연결이 랜덤하게 꺼지면서, 모델이 특정 연결 하나에만 의존하지 못한다. 그 결과 여러 단서들을 조합해 문맥을 이해하는 방향으로 학습된다. 말 그대로 “참조 관계 자체를 정규화”하는 효과가 있다. Multi-Head가 필요한 이유: 문장에는 ‘관계’가 한 종류가 아니다 문장을 이해하는 데 필요한 관계는 하나가 아니다. 어떤 때는 주어와 동사를 연결해야 하고, 어떤 때는 형용사가 꾸미는 명사를 찾아야 하고, 어떤 때는 “it”이 무엇을 가리키는지 공참조를 찾아야 하고, 어떤 때는 멀리 떨어진 단어끼리의 원거리 의존성을 잡아야 한다. 만약 Attention을 한 번만 하면, 그 한 번의 공간에서 모든 관계를 다 담아야 한다. 그건 한 사람이 동시에 12개의 역할을 다 하는 것과 비슷해서 표현이 섞이고 충돌하기 쉽다. 그래서 트랜스포머는 768차원을 12개로 쪼개서, 각 헤드가 64차원 공간에서 독립적으로 Attention을 하게 만든다. 중요한 건 “비용이 12배로 늘지 않는다”는 점이다. 12개 헤드가 각각 64차원씩 처리하고, 결과를 다시 붙이면 12×64가 되어 다시 768이 된다. 즉 전체 차원 수는 그대로 유지하면서, 서로 다른 관점의 Attention을 병렬로 수행하는 구조다. 그리고 마지막에 W_O로 한 번 더 섞어주면, 12개 관점에서 얻은 정보를 다시 통합한 최종 출력이 된다. 왜 O(L²)인가: 모든 단어 쌍을 한 번에 비교하기 때문이다. Self-Attention이 강력한 이유는 “모든 단어가 모든 단어를 볼 수 있다”는 전역성이다. RNN은 순차적으로 읽어서 멀리 떨어진 단어의 정보가 약해지기 쉽고, CNN은 지역 창으로 보니 먼 관계를 직접 잡기 어렵다. Self-Attention은 L개의 토큰이 있으면 (L×L)개의 쌍을 한 번에 비교해서, 멀리 떨어진 관계도 단 한 번의 레이어에서 직접 연결할 수 있다. 대신 대가가 L² 연산이다. 그래서 max_length를 512에서 192로 줄이면 연산이 큰 폭으로 줄어드는 이유가 정확히 여기에 있다. “비용은 L의 제곱”이기 때문이다. 파라미터 관점에서 보면: 768→768 네 번이 Attention의 뼈대다. Self-Attention의 핵심 가중치는 W_Q, W_K, W_V, W_O 네 개다. 각각 768×768 크기의 선형 변환이므로 한 레이어에 대략 4×589,824 정도의 파라미터가 들어간다. 이게 레이어 하나당 약 2.36M 파라미터라는 계산으로 이어진다. 그리고 BERT는 이런 Attention 블록을 12번 쌓기 때문에 Attention 파라미터만 해도 상당한 비중을 차지한다. 재미있는 점은, Self-Attention이 “관계를 계산하는 방법”은 수학적으로는 단순한 내적과 Softmax와 가중합인데, 그 안에서 관계를 풍부하게 만들게 해주는 힘의 원천은 결국 이 선형 변환들이 학습되는 데서 나온다는 것이다. 즉 W_Q/W_K/W_V가 “무엇을 찾을지, 무엇이 색인인지, 무엇이 내용인지”를 학습하며 Attention의 의미를 만들어낸다. cf Self attention의 핵심 특성 정리 Self-Attention을 한 문장으로 요약하면 이렇다. 각 토큰은 Query로 “내가 지금 의미를 만들려면 무엇을 참고해야 하지?”를 묻고, 문장 안 모든 토큰의 Key와 비교해 관련도 점수를 만들고, Softmax로 “참조 비율”을 만든 뒤, 그 비율로 Value들을 가중합해서 새로운 표현을 만든다. 마스크는 PAD가 끼어들지 못하게 하고, 스케일링은 Softmax가 포화되지 않게 하고, Dropout은 특정 연결에 과의존하지 못하게 한다. Multi-Head는 관계의 종류가 여러 개라는 현실을 반영해, 서로 다른 관점을 동시에 학습하도록 만든다. 그 결과 “it” 같은 빈 단어도 문장 전체를 훑은 뒤 필요한 정보를 섞어, 문맥적으로 풍부한 표현으로 바뀐다. - Self-Attention = 문장 내 모든 단어 쌍의 관련성을 계산하여 문맥 반영 - Q(Query)/K(Key)/V(Value): 다른 선형 변환으로 "찾는 것", "찾히는 것", "내용" 분리 - Scaled Dot-Produc*: `Q·Kᵀ / √d_head` → 크기 안정화 - 12-head: 768차원을 64차원×12로 나눠 12가지 다른 관계를 동시에 학습 - Attention 비용: O(L²) → max_length=192가 512보다 7배 빠른 이유 - 이 모델 파라미터: 각 BERT Layer당 2.36M, 12 레이어 합계 28.3M


2026-03-04 ⋯ BERT 개념이해 #3 Dropout

Dropout이 왜 필요한가: 모델이 “외우는” 순간 성능이 무너진다 신경망이 잘 학습한다는 말은 원래 “규칙을 배운다”는 뜻이어야 한다. 그런데 실제로는 특히 파라미터가 엄청 많은 모델일수록, 규칙을 배우기보다 훈련 데이터를 통째로 외워버리는 길이 너무 쉽다. BERT처럼 1억 개가 넘는 파라미터를 가진 모델은 표현력이 너무 강해서, 훈련 데이터에서 본 문장과 라벨의 조합을 거의 암기 수준으로 맞춰버릴 수 있다. 문제는 세상은 훈련 데이터와 똑같이 생긴 문장만 주지 않는다는 점이다. “bears”가 스포츠 기사에서 자주 나오니까 스포츠라고 외워버리고, “fall”이 경제 기사에서 자주 나오니까 비즈니스라고 외워버리면, 둘이 섞인 문장이 나왔을 때 모델은 ‘규칙’이 아니라 ‘기억’에 기대기 때문에 판단이 흔들린다. Dropout은 바로 이 “기억으로 가는 쉬운 길”을 일부러 막아서, 모델이 더 일반적인 규칙을 만들게 강제하는 장치다. Dropout을 팀 훈련에 비유한 게 정확하다. 팀이 일을 배울 때 항상 A가 모든 핵심을 맡고 나머지는 보조만 하면, A가 없을 때 팀이 무너진다. Dropout은 매번 훈련 때마다 팀원 몇 명을 랜덤으로 빼버리는 방식이다. 어떤 날은 B가 빠지고, 어떤 날은 D가 빠지고, 어떤 날은 A가 빠진다. 이렇게 하면 팀은 “누가 빠져도 돌아가게” 일을 분산해서 익히게 된다. 신경망에서는 팀원이 뉴런이고, “특정 뉴런이 없으면 모델이 무너지는 상태”가 바로 과적합의 한 형태다. Dropout은 학습 중 매번 다른 뉴런들을 끄면서, 모델이 특정 뉴런이나 특정 경로에만 의존하지 못하게 한다. 결국 모든 뉴런이 “내가 없어도 다른 애들이 해낼 수 있게” 독립적이고 분산된 표현을 배우게 된다. Dropout의 동작은 생각보다 단순하다. 어떤 텐서가 들어오면, 같은 모양의 마스크를 하나 만든다. 이 마스크는 각 위치가 0 또는 1인 랜덤한 값인데, p 확률로 0이 되고 (1-p) 확률로 1이 된다. 그 다음 입력과 마스크를 원소별로 곱한다. 그러면 마스크가 0인 위치는 입력이 통째로 0이 되고, 1인 위치는 그대로 남는다. 즉 학습 중에는 매 배치마다 “다른 부분이 꺼진” 네트워크가 돌아가게 된다. 여기서 사람들이 자주 놓치는 포인트가 하나 있다. 그냥 0으로 끄기만 하면 남아있는 값들의 평균 크기가 줄어든다. 예를 들어 원래 평균적으로 1 정도였던 값들이 10% 꺼지면, 전체 평균은 0.9 정도로 떨어진다. 그러면 학습과 추론에서 스케일이 달라져서 또 다른 불안정성이 생긴다. 그래서 Dropout은 보통 “살아남은 값”을 1/(1-p)만큼 키워서 평균을 맞춘다. p=0.1이면 1/(1-0.1)=1.111…이 된다. 즉 10%는 0이 되고, 나머지 90%는 약 1.111배가 되어서, 기대값 관점에서 학습 시와 추론 시 평균 규모가 같게 유지된다. 이게 너가 적어둔 스케일 보정의 정확한 의미다. Dropout은 학습을 잘 시키기 위한 일부러 만든 방해물이다. 실제 시험(추론) 때도 팀원을 랜덤으로 빼면, 매번 결과가 달라져서 신뢰할 수 없다. 그래서 추론 시에는 Dropout을 끈다. 학습 때는 model.train() 상태에서 Dropout이 활성화되어 랜덤 마스크가 적용되고, 평가/추론 때는 model.eval() 상태에서 Dropout이 비활성화되어 입력이 그대로 통과한다. 많은 실수는 여기서 나온다. eval()을 깜빡하면 같은 입력을 넣어도 매번 출력이 달라진다. 모델이 이상해진 게 아니라 Dropout이 계속 작동하고 있기 때문이다. Dropout을 조금 더 깊게 보면, 이건 사실상 “엄청나게 많은 서로 다른 모델”을 번갈아가며 훈련하는 것과 비슷하다. 오늘은 뉴런 10%가 꺼진 모델, 내일은 또 다른 10%가 꺼진 모델, 그 다음날은 또 다른 조합이 꺼진 모델이 학습된다. 이 조합의 수는 이론적으로 엄청나게 크다. 그래서 학습 과정은 마치 수많은 서브네트워크들이 각각 훈련되는 것처럼 진행된다. 그리고 추론 때는 Dropout을 끄고 모든 뉴런을 다 쓰는데, 이 결과는 직관적으로 “그 수많은 서브네트워크들의 평균적인 예측”에 가까운 효과를 낸다. 그래서 Dropout을 단일 모델에서의 간접적인 앙상블이라고 부르는 것이다. 실제로 앙상블처럼 일반화가 좋아지는 경향이 있고, 과적합이 줄어든다. BERT에서 Dropout이 임베딩 뒤, 어텐션 확률 뒤, 어텐션 출력 뒤, FFN(MLP) 뒤 등 여러 곳에 들어가는 이유는 간단하다. 트랜스포머는 연산이 깊고, 특정 경로나 특정 헤드가 우연히 너무 강해지면 모델이 그쪽으로만 의존할 위험이 있다. 예를 들어 어떤 attention head가 특정 패턴에 과도하게 최적화되면, 모델은 그 head가 항상 존재한다고 가정하고 학습해버릴 수 있다. Dropout은 이런 의존이 굳어지기 전에, 매번 조금씩 흔들어서 “한 곳에만 기대지 말고 여러 경로로 분산해서 해결하라”고 강제한다. BERT 본체는 사전학습을 통해 이미 굉장히 일반적인 언어 표현을 학습해둔 상태다. 그래서 fine-tuning에서는 그 표현을 크게 망가뜨리지 않으면서 약간만 적응시키는 게 좋다. 여기서 Dropout을 너무 세게 걸면, 이미 잘 만들어진 표현 자체를 불필요하게 흔들어 성능을 해칠 수도 있다. 그래서 본체에는 보통 p=0.1처럼 비교적 약한 Dropout을 둔다. 반대로 EnhancedClassifier 같은 분류기 헤드는 보통 랜덤 초기화로 시작하고, 데이터셋 규모도 상대적으로 작다. 즉 “새로 붙인 작은 머리”가 가장 먼저 과적합하기 쉽다. 실제로 뉴스 분류처럼 라벨이 4개인 문제에서는 분류기가 훈련 데이터의 표면 패턴을 빠르게 외워버릴 수 있다. 그래서 이 부분에는 p=0.3처럼 더 강한 Dropout을 걸어 과적합을 강하게 눌러준다. 한 문장으로 말하면, 이미 일반화된 거대한 본체는 약하게 흔들고, 새로 붙은 작은 부분은 강하게 흔든다. Dropout이 없는 모델은 훈련 데이터에서는 손실이 매우 낮게 내려갈 수 있다. 하지만 그건 종종 “외웠기 때문”이다. Dropout을 넣으면 학습이 일부러 어려워진다. 매번 뉴런이 빠지니까 같은 입력이라도 내부 표현이 약간씩 달라지고, 모델은 편법 암기를 하기 힘들어진다. 그래서 훈련 손실은 약간 높아질 수 있다. 하지만 대신 검증 손실이 크게 좋아지는 경우가 많다. 즉 훈련 성능을 조금 희생해서 일반화 성능을 얻는 전형적인 규제(regularization) 전략이다. 결론: Dropout은 모델이 한 가지 길만 외우지 못하게 만드는 “강제 분산 훈련”이다 Dropout은 학습 중 무작위로 일부 뉴런을 0으로 만들어, 모델이 특정 뉴런·특정 헤드·특정 경로에 과도하게 의존하지 못하게 한다. 남은 값은 1/(1-p)로 보정해서 학습과 추론의 기대 스케일을 맞춘다. 추론 때는 꺼서 결과가 안정적으로 나오게 한다. BERT에서는 본체에는 약하게(p=0.1), 새로 붙는 분류기에는 강하게(p=0.3) 적용해, 이미 잘 학습된 표현은 보호하면서도 과적합이 잘 생기는 부분은 단단히 눌러준다. 결국 Dropout의 본질은 “우연히 잘 맞는 지름길을 막고, 어디서든 작동하는 일반적인 길을 만들게 하는 것”이다. 정리 - Dropout = 학습 중 무작위로 p 비율의 뉴런을 0으로 만드는 정규화 기법 - 학습 시에만 적용, 추론 시에는 비활성화 (model.train/eval 구분) - 스케일 보정 `1/(1-p)`: 학습/추론 시 기대값 동일하게 유지 - BERT 본체: `p=0.1` (사전학습으로 이미 일반화됨) - EnhancedClassifier: `p=0.3` (소용량 분류기, 과적합 위험 더 큼) - 효과: 앙상블과 유사한 일반화 능력 + 특정 뉴런 의존 방지


2026-03-04 ⋯ BERT 개념이해 #2 LayerNorm

LayerNorm이 필요한 이유: 트랜스포머의 숫자가 점점 불안정해진다 트랜스포머 안에서는 벡터가 계속 변형된다. Self-Attention에서 가중합을 하고, Linear에서 섞고, Residual(Add)로 더하고, 다시 또 같은 일을 반복한다. 이 과정은 “정보를 풍부하게 만드는 과정”이지만 동시에 “숫자의 크기가 흔들리는 과정”이기도 하다. 어떤 레이어에서는 값이 커지고, 다음 레이어에서는 또 커지고, 이런 일이 누적되면 Softmax에 들어가는 값이 지나치게 커져 지수함수가 터지거나, 그래디언트가 폭발해서 학습이 불안정해질 수 있다. 반대로 값이 너무 작아져 모든 것이 비슷해지면 그래디언트가 거의 전달되지 않아 학습이 멈춘다. LayerNorm은 이 문제를 막기 위해 “각 벡터의 크기를 매번 안정된 범위로 되돌리는” 역할을 한다. 정규화의 직관: 절대 크기를 버리고 상대 패턴만 남긴다 정규화를 시험 점수로 생각하면 제일 이해가 빠르다. 국어 95, 수학 40, 영어 72를 보면 중요한 정보는 “국어가 잘 됐고 수학이 약하다”는 상대적인 패턴이다. 그런데 절대 점수 크기는 상황에 따라 흔들릴 수 있다. 시험이 쉬우면 전체 점수가 올라가고, 어려우면 전체 점수가 내려간다. 정규화는 이런 “전체 난이도 효과”를 없애고, 대신 각 과목이 평균 대비 얼마나 위인지 아래인지 같은 상대 정보를 남긴다. 신경망 벡터에서도 똑같다. 벡터의 각 차원은 어떤 특징의 강도인데, 우리는 “전체가 갑자기 커지거나 작아지는 현상”은 없애고 싶고, 대신 “어떤 차원이 다른 차원보다 상대적으로 크다” 같은 패턴은 유지하고 싶다. LayerNorm은 바로 그 목적에 맞는 정규화다. LayerNorm이 실제로 하는 일: 한 토큰의 768차원을 한꺼번에 표준화한다 BERT에서는 토큰 하나가 768차원 벡터로 표현된다. LayerNorm은 이 벡터를 보고 평균을 구한다. 그리고 평균에서 얼마나 퍼져 있는지를 나타내는 분산과 표준편차를 구한다. 마지막으로 각 차원 값에서 평균을 빼고 표준편차로 나누면, 그 벡터는 평균이 0에 가깝고 표준편차가 1에 가깝게 된다. 여기서 가장 중요한 포인트는 “어느 방향으로 평균과 표준편차를 구하느냐”다. LayerNorm은 배치 전체를 섞지 않는다. 토큰 하나의 768개 값만 보고 그 안에서 평균과 표준편차를 계산한다. 그래서 배치 크기가 작든 크든 결과가 흔들리지 않는다. BatchNorm은 이미지에서 잘 먹히는 방식이다. 같은 채널을 기준으로 배치에 있는 여러 샘플을 모아 평균과 분산을 낸다. 배치가 충분히 크고 데이터 형태가 일정하면 통계가 안정적이다. 하지만 NLP는 배치가 작을 때가 많고, 문장 길이가 계속 달라지고, 패딩이 끼어들고, 배치마다 구성도 달라진다. 이런 환경에서 BatchNorm은 통계가 불안정해질 수 있다. 반면 LayerNorm은 “각 샘플을 독립적으로” 처리하기 때문에 이런 문제를 피한다. 그래서 트랜스포머 계열 모델의 기본 정규화로 LayerNorm이 자리잡았다. ε가 들어가는 이유: 나눗셈이 터지지 않게 하는 안전장치 표준편차로 나누는 순간, 표준편차가 0이면 계산이 터진다. 극단적으로 768개 값이 모두 같으면 분산이 0이고 표준편차도 0이 된다. 실제 학습에서 완전히 0인 경우는 드물지만, 수치적으로 매우 작은 값이 나오는 것은 충분히 가능하다. 그래서 분산에 아주 작은 수 ε를 더해서, 표준편차가 0이 되는 상황을 원천 차단한다. BERT에서 eps=1e-12 같은 값이 들어가는 이유는 “수치 안정성”을 확보하기 위해서다. 여기서 또 자연스러운 질문이 나온다. “정규화해서 평균 0, 표준편차 1로 고정하면 안정적이긴 한데, 그게 항상 좋은가?” 어떤 레이어는 출력이 조금 더 큰 스케일을 가져야 표현이 잘 될 수도 있고, 어떤 레이어는 특정 차원의 값이 의도적으로 치우쳐야 할 수도 있다. 그래서 LayerNorm은 정규화 후에 “조절 손잡이”를 붙인다. 각 차원마다 스케일을 조절하는 γ와, 값을 이동시키는 β를 둔다. 처음엔 γ=1, β=0으로 시작해서 “정규화 그대로” 동작하게 하고, 학습이 진행되면서 모델이 필요하면 γ를 키워 특정 차원을 더 강조하거나, β를 이동시켜 특정 차원의 기준점을 옮길 수 있게 만든다. 즉 LayerNorm은 안정성을 위해 표준화하지만, 유연성을 위해 다시 조절할 수 있는 길을 열어둔다. BERT에서 LayerNorm이 등장하는 위치: “더한 뒤마다” 스케일을 다시 잡는다 BERT에서 LayerNorm은 한 번만 쓰이지 않는다. 임베딩을 만들 때 Token/Position/Segment를 더한 직후에 한 번 등장한다. 이건 “입력 벡터를 트랜스포머에 넣기 전에” 스케일을 정돈하는 단계다. 그리고 각 Transformer layer 안에서 두 번 더 등장한다. Self-Attention을 통과한 뒤 Dropout을 하고 residual로 입력을 더한 다음 LayerNorm을 한다. MLP를 통과한 뒤에도 Dropout을 하고 residual로 더한 다음 LayerNorm을 한다. 이렇게 보면 LayerNorm은 트랜스포머에서 “연산을 한 번 크게 하고, 잔차로 더해서 스케일이 흔들릴 수 있는 순간마다” 다시 균형을 맞추는 장치로 배치되어 있다. 정리 LayerNorm은 한 토큰의 벡터를 평균 0, 표준편차 1로 맞춰 숫자 폭주와 소실을 막는다. Batch 통계를 쓰지 않아서 배치 크기나 문장 길이 변화에 흔들리지 않는다. 그리고 γ, β로 다시 스케일과 기준점을 학습할 수 있게 해 “안정성과 표현력”을 동시에 확보한다. 그래서 BERT처럼 레이어를 깊게 쌓아도 Softmax가 터지지 않고 그래디언트가 살아있으며, 학습이 중간에 멈추지 않고 끝까지 진행될 수 있다. cf2 핵심 요약 - LayerNorm = 각 토큰의 768차원 벡터를 평균=0, 분산=1로 정규화 - Batch Norm과 달리 배치 크기에 무관 → 시퀀스 처리에 적합 - `γ`(스케일), `β`(시프트): 학습 가능한 파라미터로 유연성 부여 - BERT에서 25번 사용 (Embedding 후 + 각 Layer의 Attention/MLP 후) - 역할: 그래디언트 안정화 + 값 폭발/소실 방지 - `eps=1e-12`: 표준편차=0일 때 0으로 나누는 오류 방지


2026-03-04 ⋯ BERT 개념이해 #11 MLflow

MLflow가 풀려는 문제: 실험이 많아지면 “기억”은 반드시 깨진다 모델을 개선할 때는 거의 항상 같은 패턴이 반복된다. 학습률을 바꾸고, max_length를 바꾸고, dropout을 바꾸고, label_smoothing을 켜보고, classifier hidden size를 조정한다. 처음엔 “이 정도는 머리로 기억되지” 싶지만, 실험이 10개만 넘어가도 바로 헷갈린다. 더 큰 문제는 “결과 숫자”만 기억해도 소용이 없다는 점이다. 어떤 실험이 0.96이 나왔는데, 그때 정확히 어떤 하이퍼파라미터였는지, 어떤 데이터 버전이었는지, 어떤 모델 아티팩트를 썼는지를 같이 알아야 그 실험을 다시 재현하거나 운영에 올릴 수 있다. MLflow는 이 실험 과정을 “자동으로 기록하고, 비교하고, 다시 꺼내 쓰게” 만드는 도구다. 한마디로 머신러닝 실험의 버전 컨트롤에 가깝다. MLflow의 기본 단위: Experiment와 Run이 “폴더 구조”로 실험을 정리한다 MLflow를 이해하는 가장 쉬운 방법은 폴더 구조를 상상하는 것이다. 큰 폴더 하나가 Experiment다. 예를 들어 “bert-ag-news-optuna” 같은 이름이 Experiment다. 그 안에는 실험을 한 번 실행할 때마다 Run이라는 하위 폴더가 하나씩 생긴다. “trial_0042”, “retrain_20260305_…” 같은 게 Run이다. 그리고 Run 안에는 세 가지가 들어간다. 첫째는 Params, 즉 그 실험의 설정값이다. learning_rate, batch_size, max_length, dropout 같은 값들이 여기 들어간다. 둘째는 Metrics, 즉 결과 숫자다. accuracy, f1, loss 같은 평가 지표가 들어간다. 셋째는 Artifacts, 즉 파일이다. 학습된 모델, tokenizer 파일, ONNX 파일, confusion matrix 이미지 같은 “실험 산출물”이 전부 artifacts로 들어간다. 결국 MLflow는 “한 번의 실험 실행 = Run 하나 = 설정/결과/산출물이 한 세트로 묶인 기록”이라는 구조를 갖는다. 이 구조가 생기는 순간, 실험이 아무리 많아져도 섞이지 않는다. 실험에서 진짜 중요한 건 “정확도 0.96” 같은 결과 숫자만이 아니다. 그 숫자가 나온 조건이 중요하다. max_length를 128로 했는지 192로 했는지, label_smoothing을 0.1로 했는지 0으로 했는지, classifier hidden을 256으로 했는지 128으로 했는지에 따라 결과는 달라진다. MLflow의 log_params는 이 조건들을 자동으로 저장한다. 그래서 나중에 “accuracy가 0.95 이상인 run만 보고 싶다” 또는 “dropout이 0.3인 실험들끼리만 비교하고 싶다” 같은 조회가 가능해진다. 즉 파라미터를 ‘기록’하는 순간부터, 실험을 단순한 메모가 아니라 검색 가능한 데이터베이스로 바꾸게 된다. 메트릭은 그냥 최종 점수만 저장할 수도 있지만, 더 중요한 기능은 “학습 과정의 흐름”을 남길 수 있다는 점이다. 예를 들어 eval_steps=200으로 평가를 주기적으로 돌리면, f1이 시간이 지나며 어떻게 올라가는지, 어느 지점에서 과적합이 시작되는지 같은 곡선을 저장할 수 있다. MLflow의 metrics 파일이 “timestamp step value” 형식인 이유가 바로 이거다. 값 하나가 아니라, 스텝에 따른 변화를 기록하도록 설계돼 있다. 그래서 웹 UI에서 실험을 보면 “이 run은 초반에 빨리 올라갔는데 후반에 정체됐네”, “이 run은 천천히 올라가지만 최종은 더 높네” 같은 판단이 가능해진다. 단순히 숫자 비교가 아니라 학습의 성격까지 비교할 수 있게 된다. 실험에서 최고 성능이 나왔다고 해도, 그 모델 파일이 없으면 운영에 못 올린다. 그때의 ONNX 파일이 없으면 서버에서 바로 돌릴 수 없다. 그때의 tokenizer가 없으면 같은 전처리를 재현할 수 없다. 즉 실험은 결국 “파일로 완성”된다. MLflow가 artifacts를 저장해주는 순간, “성능이 좋은 run”은 곧 “바로 배포 가능한 산출물을 가진 run”이 된다. 너의 파이프라인에서 champion이 승격될 때 ONNX 파일을 mlflow.log_artifact(ONNX_PATH)로 저장하는 구조는 딱 이 목적에 맞다. 성능이 좋은 모델이 자동으로 아티팩트까지 묶여서 보관되면, 운영에서 가져다 쓰는 일이 매우 쉬워진다 로컬 mlruns/ 구조가 의미하는 것: 서버 없이도 실험 데이터베이스가 생긴다 MLflow는 꼭 중앙 서버가 있어야만 하는 도구가 아니다. tracking URI를 로컬 경로로 지정하면, 모든 실험 기록이 파일로 쌓인다. mlruns/ 디렉토리 아래에 experiment id 폴더가 생기고, 그 안에 run id 폴더들이 생기며, params/metrics/artifacts가 각각 파일로 들어간다. 이게 왜 좋은가 하면, 실험 기록이 “어떤 특수한 시스템 안에 갇히지 않는다.” 그냥 파일이니까 백업도 쉽고, git으로 관리하진 않더라도 스토리지에 복사해두기도 쉽고, 서버 환경이 바뀌어도 그대로 가져갈 수 있다. 작은 프로젝트에서 MLflow를 시작하기에 가장 현실적인 방식이 로컬 파일 기반 저장이다. HuggingFace Trainer와 report_to="mlflow"의 의미: ‘기록을 자동화’한다 실험 기록이 귀찮아지면 결국 아무도 안 하게 된다. MLflow가 유용하려면 기록이 자동이어야 한다. HuggingFace Trainer에서 TrainingArguments(report_to="mlflow")를 켜면, 평가가 발생할 때마다 Trainer가 자동으로 mlflow.log_metrics를 호출해서 기록한다. 즉 “학습 코드를 깔끔하게 유지한 채로” 실험이 자동으로 축적된다. 이 자동화는 특히 하이퍼파라미터 실험에서 효과가 크다. 사람은 실험을 많이 할수록 기록을 빠뜨리는데, 자동화는 그 실수를 원천 차단한다. Optuna와 MLflow를 같이 쓸 때의 감각: Optuna는 ‘찾고’, MLflow는 ‘남긴다’ Optuna는 “최적의 하이퍼파라미터를 찾는 탐색 엔진”이다. 반면 MLflow는 “그 탐색 과정과 결과를 기록하는 저장소”다. 둘을 같이 쓰면, trial마다 run이 하나씩 남고, 나중에 “왜 이 파라미터 조합이 이겼는지”를 데이터로 확인할 수 있다. 다만 trial이 수백 개가 되면 매 trial마다 로깅이 I/O 병목이 될 수 있다. 그래서 “탐색 중에는 기록을 최소화하고, 최종 선택된 파라미터로 제대로 학습할 때만 MLflow를 강하게 기록한다”는 전략이 실무적으로 자주 쓰인다. 너의 정리처럼 trial에서는 report_to를 끄고, 최종 학습만 report_to="mlflow"로 남기는 방식은 속도와 재현성의 균형을 맞춘 선택이다. 재학습 파이프라인에서 MLflow의 역할: 모델 승격을 ‘감사 가능하게’ 만든다 Champion–Challenger 구조에서 중요한 건 단순히 “새 모델이 더 좋다”가 아니라, “왜 승격됐는지”가 남아야 한다는 것이다. 피드백 데이터가 몇 건이었는지, 학습 데이터 크기가 얼마였는지, challenger의 f1이 얼마였는지, 승격 여부가 무엇이었는지 같은 정보가 기록돼야 한다. 그래야 나중에 운영 이슈가 생겼을 때 “어느 시점에 어떤 근거로 모델이 바뀌었는가”를 추적할 수 있다. MLflow에 retrain run을 남기면 이 흐름이 한 번에 정리된다. 게다가 승격된 경우 ONNX까지 artifacts로 남기면, “그때 운영에 올라간 실행 파일”까지 같이 보관된다. 이건 단순 실험 기록이 아니라 운영 변경 이력까지 겸하는 역할이다. 정리: MLflow는 실험을 ‘데이터화’해서 비교와 재현을 가능하게 만든다 MLflow를 도입하면 실험은 더 이상 메모장이 아니다. Experiment는 실험 묶음이고, Run은 한 번의 실행이며, 그 안에 params(조건), metrics(결과), artifacts(산출물)가 한 세트로 저장된다. 로컬 mlruns/만으로도 바로 시작할 수 있고, 웹 UI로 여러 run을 한눈에 비교할 수 있다. Trainer 연동을 통해 기록을 자동화하면 실험이 쌓일수록 가치가 커진다. Optuna는 최적을 찾고, MLflow는 그 과정을 남겨서 재현 가능하게 만든다. 그리고 재학습 파이프라인에서 MLflow는 승격 기록과 배포 아티팩트를 묶어, 모델 운영을 “추적 가능하고 검증 가능한 과정”으로 만들어준다. - MLflow = 머신러닝 실험의 버전 컨트롤 - 4가지 핵심: `Experiment` → `Run` → `Params / Metrics / Artifacts` - 저장 방식: `mlruns/` 디렉토리에 파일로 저장 (서버 불필요) - HuggingFace 연동: `TrainingArguments(report_to="mlflow")`으로 eval 자동 기록 - 재학습 파이프라인: Champion 승격 시 ONNX 아티팩트도 함께 저장 - FastAPI `/experiments`: mlruns/ 디렉토리를 파싱하여 API로 노출 - Optuna + MLflow: trial 내부는 `report_to="none"`, 최종 결과만 `report_to="mlflow"` - 이 프로젝트 실험명: `bert-ag-news-optuna`, `bert-ag-news-retrain`


2026-03-04 ⋯ BERT 개념이해 #10 ONNX 변환

ONNX 변환이 필요한 이유: 학습용 모델을 “서빙용 실행파일”로 바꾸고 싶다 학습이 끝난 BERT 모델은 보통 PyTorch 가중치 파일(.safetensors)과 파이썬 코드 형태로 존재한다. 이 상태로 서버에 올리면 두 가지 현실 문제가 바로 생긴다. 첫째, PyTorch 자체가 무겁다. 서버에 PyTorch와 그 주변 의존성을 깔아야 하고, 환경이 커지고 운영 부담이 커진다. 둘째, 추론을 할 때마다 파이썬 인터프리터 경로를 타게 된다. 결국 “모델 연산은 C++/CUDA로 하더라도, 전체 실행 흐름은 Python이 감싼다”는 구조가 남아서 오버헤드가 생긴다. 여기서 우리가 원하는 건 명확하다. 학습이 끝난 모델을 “어디서든 똑같이 실행되는 범용 형식”으로 내보내고, 그 형식을 최적화된 런타임이 빠르게 돌리게 만들고 싶다. ONNX는 그 목적을 위해 만들어진 표준 교환 형식이다. ONNX의 정체: 모델을 파이썬 객체가 아니라 “계산 그래프”로 저장한다 PyTorch 모델은 사람 입장에서는 편하다. 클래스가 있고, forward가 있고, 조건문도 쓸 수 있고, 코드로 표현이 된다. 하지만 서버 입장에서는 불편하다. 서버는 “코드를 실행하는 것”보다 “이미 정해진 계산 그래프를 최대한 빠르게 돌리는 것”이 더 잘 맞는다. ONNX는 모델을 코드가 아니라 그래프로 만든다. 그래프에는 노드들이 있고, 노드는 MatMul, Add, LayerNorm, Softmax 같은 기본 연산들이다. BERT를 ONNX로 내보내면 “Transformer 12층”이 파이썬 클래스 형태로 저장되는 게 아니라, 수백 개의 연산 노드로 풀어헤쳐진 그래프가 파일에 직렬화된다. 그래서 ONNX 파일은 “이 모델은 이렇게 계산한다”라는 실행 계획서에 가깝다. 이 계획서를 onnxruntime이 읽으면, 파이썬 없이도 같은 계산을 수행할 수 있다. torch.onnx.export()는 모델을 단순히 저장하는 게 아니라, “이 모델이 실제로 어떤 연산을 수행하는지”를 알아내기 위해 한 번 실행해본다. 그래서 더미 입력이 필요하다. 더미 입력을 모델에 넣고 forward가 어떤 연산들을 호출하는지 추적해서, 그 경로를 ONNX 그래프로 기록한다. 여기서 중요한 감각은 이거다. ONNX 변환은 “가중치만 떼어내는 작업”이 아니라 “가중치 + 연산 흐름”을 통째로 굳혀서 그래프로 만드는 작업이다. 그래서 변환이 끝나면 .onnx 파일 하나만으로도 모델의 구조와 파라미터가 함께 들어간 형태가 된다. 더미 입력을 만들 때 흔히 padding="max_length", max_length=192로 만들면 입력 shape이 (1, 192)가 된다. 문제는 ONNX가 이 shape을 “이 모델 입력은 항상 (1, 192)다”라고 고정해버릴 수 있다는 것이다. 그러면 실제 서빙에서 배치 크기가 32가 되거나, dynamic padding으로 길이가 64가 되는 순간 모델이 shape mismatch로 터진다. 그래서 dynamic_axes를 지정해줘야 한다. “0번 축은 배치 크기라서 늘어날 수 있다”, “1번 축은 시퀀스 길이라서 달라질 수 있다”를 명시적으로 선언하는 것이다. 이 선언이 들어가면 ONNX 그래프는 입력 텐서의 0번과 1번 축을 고정 숫자가 아니라 ‘심볼릭 차원’으로 취급하게 되고, 어떤 배치/길이가 들어와도 실행 가능해진다. 즉 dynamic_axes는 “학습 때의 고정 텐서”를 “서빙에 필요한 가변 텐서”로 바꿔주는 안전장치다. 서버에서 onnxruntime에 입력을 줄 때는 딕셔너리 형태로 준다. "input_ids": ..., "attention_mask": ...처럼 이름으로 매칭한다. 그런데 export할 때 input_names를 안 주면, PyTorch가 ONNX 내부 입력 이름을 자동으로 만들어버리는 경우가 있다. 그러면 서버는 "input_ids"를 넣었는데 그래프에는 "onnx::Gather_0" 같은 이름만 있어서 “그 키가 없다”는 에러가 난다. 또 한 가지 자주 걸리는 함정이 HuggingFace BERT의 token_type_ids다. BERT는 원래 문장쌍을 구분하기 위해 token_type_ids를 받을 수 있다. 그런데 더미 입력에 token_type_ids를 포함하지 않았는데 export 과정에서 모델이 기대하는 입력이 달라지거나 이름이 꼬이면, 추론 시 입력 매칭이 어긋날 수 있다. 그래서 가장 안전한 방식은 지금 너가 적어둔 것처럼, export에서 input_names=["input_ids", "attention_mask"]를 명확히 지정하고, 더미 입력 튜플의 순서도 그와 1:1로 맞추는 것이다. 그러면 “서버가 넣는 키”와 “그래프가 기대하는 키”가 확실히 고정된다. ONNX는 “이런 연산 노드들이 존재한다”는 표준 집합을 버전으로 관리한다. 그게 opset_version이다. 버전이 너무 낮으면 BERT에서 필요한 연산(특히 LayerNorm을 구성하는 패턴이나 특정 연산 조합)이 제대로 표현되지 않거나, 런타임이 지원하지 않아 변환/실행이 깨질 수 있다. 반대로 버전이 너무 최신이면 실행 환경의 런타임이 아직 그 opset을 완벽히 지원하지 못할 수도 있다. 그래서 보통 “충분히 최신이면서 호환이 좋은 버전”을 선택한다. 너의 코드에서 opset 14를 쓴 이유도 그 현실적인 균형점 때문이다. ONNX 파일이 생성됐다고 해서 끝이 아니다. 그래프 구조가 깨졌거나, 타입이 맞지 않거나, 노드 연결이 이상해도 파일은 생길 수 있다. 그래서 onnx.checker.check_model()로 그래프의 기본 정합성을 검사한다. 이 단계는 “내보내기 성공”이 아니라 “내보낸 그래프가 ONNX 규격상 올바르다”를 확인하는 단계다. 실제 서빙에서 터지기 전에 미리 잡는 안전벨트라고 보면 된다. 서빙에서의 실행 흐름: 세션을 한 번 만들고, 그 세션에 numpy 입력만 던진다 PyTorch는 보통 요청마다 모델 forward를 호출하는 구조가 되기 쉬운데, ONNX Runtime은 다르게 쓴다. 서버가 시작할 때 InferenceSession을 한 번 만들어서 그래프를 로딩한다. 이때 런타임은 내부적으로 최적화 패스를 돌린다. 연산을 합칠 수 있으면 합치고, 상수를 접을 수 있으면 접고, 메모리 배치를 더 효율적으로 바꾼다. 그 다음부터 요청이 들어오면 sess.run()에 입력을 넣고 결과를 받기만 하면 된다. 여기서 또 하나의 실용 포인트가 있다. onnxruntime은 입력을 보통 numpy로 받는다. 그래서 토크나이저가 만든 PyTorch 텐서를 .numpy()로 바꿔서 넣는다. 그러면 출력으로 logits가 numpy array로 나오고, 그 다음 후처리(softmax, argmax)를 해서 라벨과 confidence를 만든다. 즉 서버에서 “파이썬 모델 실행”이 아니라 “최적화된 그래프 실행”만 남는다. ONNX가 빠른 이유: 런타임이 그래프 전체를 보고 ‘합칠 건 합친다’ PyTorch는 eager 실행 모델이라, 연산이 파이썬 레벨에서 순서대로 호출되는 느낌이 강하다. 반면 ONNX Runtime은 그래프 전체를 한 번에 보고 최적화를 할 수 있다. 예를 들어 LayerNorm은 수학적으로는 평균 계산, 분산 계산, 정규화, 스케일/시프트 같은 여러 연산의 조합인데, 런타임은 이 패턴을 알아보고 “LayerNorm fused kernel” 하나로 합쳐서 실행할 수 있다. 연산을 합치면 커널 호출 횟수가 줄고, 메모리 접근이 줄고, CPU 캐시 효율도 올라간다. 그래서 같은 모델이라도 ONNX가 더 빠르게 나오는 경우가 많다. 너의 벤치마크에서 CPU에서 1.5~3배 향상이 나온 이유는 이런 그래프 레벨 최적화의 결과라고 보면 된다. 실무에서는 모델이 새로 학습될 때마다 서빙 포맷도 같이 갱신되어야 한다. 그래서 retrain 파이프라인에서 champion이 승격될 때만 export_onnx를 돌리고, 그 결과물을 MLflow에 아티팩트로 저장하는 구조는 굉장히 합리적이다. 이 흐름은 한마디로 “훈련 산출물(가중치)과 운영 산출물(ONNX)을 동시에 관리한다”는 뜻이다. 이렇게 해두면 운영 서버는 항상 ONNX 파일만 가져가서 실행하면 되고, PyTorch 환경을 서빙 서버에 유지할 필요가 줄어든다. cf 프로덕션 확장: ONNX + 하드웨어 가속 정리: ONNX 변환은 ‘학습 모델’을 ‘운영 가능한 실행 그래프’로 바꾸는 과정이다 ONNX는 PyTorch 모델을 범용 계산 그래프로 직렬화해, 파이썬과 PyTorch 없이도 최적화된 런타임에서 실행할 수 있게 만든다. export는 더미 입력으로 연산 경로를 추적해 그래프를 만들고, dynamic_axes로 배치/길이를 가변으로 풀어 서빙 환경에서 깨지지 않게 한다. input_names를 명시해 입력 매칭 문제를 막고, opset_version으로 필요한 연산 지원을 확보한다. 변환된 ONNX는 onnxruntime이 로딩 시 최적화하고, 추론 시에는 numpy 입력을 받아 빠르게 logits를 내놓는다. 결국 ONNX 변환은 “모델을 더 빠르고, 더 가볍고, 더 이식성 있게” 만드는 프로덕션용 마무리 단계라고 보면 된다. - ONNX = PyTorch 계산 그래프를 범용 형식으로 직렬화 - `torch.onnx.export()`: 더미 입력으로 그래프를 추적(trace)하여 `.onnx` 파일 생성 - `dynamic_axes`: 배치 크기 / 시퀀스 길이를 가변으로 허용 (필수!) - `input_names` 명시: token_type_ids 이름 불일치 버그 방지 - `opset_version=14`: BERT 연산(LayerNorm 등) 지원하는 최소 버전 - `onnxruntime`: ONNX 파일을 최적화하여 실행하는 런타임 (PyTorch 불필요) - 속도 향상: CPU에서 PyTorch 대비 1.5~3x 빠름 (연산 융합, 커널 최적화) - 이 프로젝트: `bert_enhanced.onnx` → FastAPI `/predict` 엔드포인트에서 기본 추론 엔진


2026-03-04 ⋯ BERT 개념이해 #1 Tokenizer / Embedding

모델은 문자나 단어를 직접 보는 게 아니라, 오직 숫자 행렬을 연산한다. 그래서 우리가 “Wall St. Bears…” 같은 문장을 넣고 싶다면, 먼저 그 문장을 숫자들의 시퀀스로 바꾸고(Tokenizer), 그 숫자들이 의미를 갖도록 연속적인 벡터 공간으로 옮겨줘야 한다(Embedding). 토크나이저: 텍스트를 모델이 먹을 수 있는 숫자로 만드는 과정 우리가 문장을 보면 단어의 의미를 떠올리지만, 컴퓨터는 그렇지 않다. 컴퓨터에게 “wall”은 그냥 문자 4개일 뿐이고, 그걸 바로 신경망에 넣으면 신경망은 “w가 a보다 어떤가?” 같은 말도 안 되는 연산을 하게 된다. 그래서 첫 번째 목표는 간단하다. 텍스트를 정수 ID들의 리스트로 바꿔서 모델 입력으로 만들자. 토크나이저는 이 일을 한다. 그런데 여기서 바로 중요한 선택이 하나 나온다. “그럼 단어 단위로 잘라서 사전에 등록하면 되지 않나?”라는 생각이 떠오르지. 실제로 초기에 많은 NLP 모델이 그렇게 했다. 하지만 단어 단위로만 자르면 바로 벽에 부딪힌다. 예를 들어 “play”, “played”, “playing”, “playfully”는 사람이 보면 뿌리가 같아서 의미가 연결되어 있음을 알지만, 단어 단위 사전은 이걸 전부 다른 항목으로 취급한다. 그렇게 되면 데이터가 조금만 달라져도 사전에 없는 단어가 폭발적으로 늘고, 결국 모델 입력은 UNK로 도배된다. 이건 “새로운 단어를 만나면 아무것도 모른다”는 뜻이라서, 현실 텍스트를 다루기엔 너무 약하다. 그래서 WordPiece 같은 서브워드(subword) 기반 분해가 나온다. 발상은 단순하다. “단어 전체를 외우지 말고, 자주 등장하는 조각들을 외우자.” 그러면 “playing”은 “play”와 “ 쪼개진다. 여기서 붙는 이유는 그 조각이 단독 단어가 아니라 앞 토큰에 이어 붙는 형태라는 표시다. “playfully” 같은 단어도 “play”, “ “ 쪼개면 된다. 이게 좋은 이유는, 모델이 “play”라는 핵심 어근을 여러 변형에서 공유할 수 있고, 심지어 처음 보는 단어도 조각 조합으로 처리 가능해진다는 점이다. 즉 WordPiece는 “단어 사전을 무한히 키우는 대신, 조각 사전을 적당히 크게 만들고 조합으로 해결한다”는 전략이다. 그 다음은 BERT가 쓰는 표준 입력 규칙이 붙는다. BERT는 문장 앞에 [CLS]라는 토큰을 하나 붙여서 “이 문장 전체를 대표하는 자리”를 만든다. 그리고 문장 끝에는 [SEP]를 붙여서 “여기가 문장 끝이야”라고 표시한다. 길이를 맞출 때는 [PAD]로 빈칸을 채운다. 어휘에 없는 건 [UNK], 사전학습에서 가려맞추기용은 [MASK]다. 이렇게 “토큰 리스트”가 완성되면, 이제 각 토큰을 어휘(vocab)에서 찾아서 숫자 ID로 바꾼다. 예를 들어 [CLS]=101, [SEP]=102처럼 정해져 있고 “wall”은 어휘 테이블에서 2813이라는 번호를 가진다. 결국 한 문장은 [101, 2813, 2358, ... , 102] 같은 정수 시퀀스가 된다. 여기까지가 “텍스트를 숫자로 바꾸는 단계”의 핵심이다. 그런데 정수 시퀀스를 만들었다고 끝이 아니다. 이제 현실적인 문제가 하나 더 있다. 문장마다 길이가 다르다. 어떤 기사는 짧고 어떤 기사는 길다. 신경망은 보통 배치(batch)로 여러 문장을 한 번에 처리하는데, 텐서는 직사각형 모양이어야 하니까 “배치 안의 모든 문장을 같은 길이로 맞춰야” 한다. 여기서 padding이 등장한다. 가장 단순한 방식은 “항상 192 길이로 맞추자” 같은 정적 패딩(static padding) 이다. 하지만 이건 낭비가 심하다. 어떤 배치에서 가장 긴 문장이 50토큰인데도, 전부 192까지 [PAD]를 붙여서 모델이 192 길이를 처리하게 만든다. Self-Attention의 계산량은 길이 L에 대해 대략 L²로 커지니까, PAD가 많을수록 연산이 그냥 날아간다. 그래서 너가 정리한 Dynamic Padding이 중요해진다. 배치 안에서 가장 긴 문장 길이에만 맞추자는 것이다. 같은 배치에서 최장 길이가 50이면, 그 배치에서는 전부 50까지만 패딩한다. 그러면 대부분의 배치가 실제 기사 길이(예: 50~80) 수준에서 끝나고, 연산량이 크게 줄어든다. 여기서 함께 만들어지는 게 attention_mask다. 실제 토큰 위치는 1, PAD 위치는 0으로 표시한다. 이 마스크는 나중에 Self-Attention에서 “PAD는 보지 마”라고 강제하는 역할을 한다. 신경망이 PAD에 주의를 주지 않게 만드는 장치다. 이제 max_length=192 선택도 자연스럽게 연결된다. BERT는 최대 512까지 받을 수 있지만, “가능하다고 해서 항상 쓰는 게 최선은 아니다.” 왜냐하면 길이가 길어질수록 Self-Attention 비용이 L²로 폭발하기 때문이다. 512²와 192² 차이는 너가 계산해둔 것처럼 7배 이상이다. 그런데 뉴스 분류 같은 데이터는 대부분 짧다. 실제 토큰 길이 분포를 보고 P95가 95라면, “95%가 95토큰 이하”라는 뜻이다. 그럼 192는 충분히 여유 있는 상한이 된다. 일부만 잘리더라도 속도와 메모리 이득이 훨씬 크다면, 그 트레이드오프는 합리적이다. 즉 max_length는 “모델이 처리 가능한 최대치”가 아니라, “데이터 분포와 계산 자원을 보고 정하는 현실적인 상한”이다. 정리하면 토크나이저 단계는 이렇게 끝난다. 문장을 WordPiece로 쪼개고, [CLS]와 [SEP]를 붙이고, 숫자 ID로 바꾸고, 배치 단위로 길이를 맞추되 dynamic padding을 써서 낭비를 줄이며, PAD를 무시하게 하는 attention_mask까지 만든다. 이제 모델은 드디어 “먹을 수 있는 형태”를 받는다. 하지만 아직 “의미를 이해”하진 못한다. 정수는 크기 비교만 가능한 번호표일 뿐이니까. cf Tokenizer 처리 과정 임베딩: “번호표”를 “의미 있는 좌표”로 바꾸는 과정 이제 두 번째 단계의 출발점은 아주 중요한 깨달음이다. 토큰 ID 2813이 2814보다 작다고 해서 “wall이 그 다음 단어보다 의미적으로 작다”는 뜻은 전혀 아니다. 이 숫자는 단지 사전에서의 행 번호다. 그래서 모델이 진짜로 의미를 다루려면, 이 번호를 연속적인 벡터로 바꿔야 한다. 이게 임베딩이다. 임베딩을 가장 쉽게 비유하면 “거대한 조회표(lookup table)”다. 행 번호를 넣으면 그 행에 저장된 768차원 벡터를 꺼내준다. [CLS]는 101번 행, “wall”은 2813번 행 같은 식이다. 중요한 건, 이 벡터가 그냥 랜덤이 아니라 학습을 통해 “비슷한 의미를 가진 토큰은 비슷한 벡터”가 되도록 조정된다는 점이다. 그래서 “wall”과 “fence”는 벡터 공간에서 가까워지고, “wall”과 “cat”은 멀어진다. 즉 임베딩 공간은 “의미를 거리로 표현하는 공간”이 된다. 그럼 왜 하필 768차원인가? 여기서 차원 수는 “표현력”이라고 생각하면 된다. 1차원 숫자로는 “큰지 작은지”밖에 못 말한다. 하지만 768차원 벡터는 768개의 서로 다른 축을 동시에 쓸 수 있다. 어떤 축은 품사/형태 정보를, 어떤 축은 주제/맥락 정보를, 어떤 축은 감정/강조 같은 패턴을 담는 식으로 역할이 분산된다. 물론 우리가 “몇 번 축이 정확히 무엇”이라고 직접 해석하긴 어렵지만, 학습 과정에서 모델이 필요한 특징을 분산 표현으로 자연스럽게 만들어낸다. 그리고 그 768이라는 값은 BERT-base 설계에서의 균형점이다. 너무 작으면 표현력이 부족하고, 너무 크면 파라미터와 연산이 무거워진다. 이제 BERT의 임베딩 레이어가 단순 조회표 하나로 끝나지 않는 이유가 나온다. BERT는 토큰 의미만 주는 게 아니라, “이 토큰이 문장 어디에 있었는지”, “이 토큰이 어느 문장(A/B)에 속하는지” 같은 정보도 함께 넣어야 한다. Self-Attention은 기본적으로 “순서가 없는 집합”처럼 토큰을 바라보기 때문에, 위치 정보를 주지 않으면 “The cat sat on the mat”과 “mat the on sat cat The”이 같은 것으로 처리될 위험이 있다. 그래서 Position Embedding이 필요해진다. 0번째 위치에는 0번 위치 벡터, 1번째 위치에는 1번 위치 벡터를 더해주는 방식이다. 이렇게 하면 토큰 벡터에 “나는 3번째에 있었어” 같은 좌표가 섞여 들어가서, 모델이 순서를 구분할 수 있게 된다. 여기서 너가 적어둔 “max_length=192로 줄였기 때문에 position embedding도 192까지만 필요”가 아주 깔끔한 연결이다. 위치 임베딩 테이블은 “가능한 위치 수 × hidden size”만큼 파라미터를 가진다. 512에서 192로 줄면 위치 테이블도 작아진다. 물론 전체 110M에서 보면 큰 비중은 아니지만, 설계가 일관되게 “필요한 만큼만” 쓰고 있다는 의미가 있다. 그 다음이 Segment Embedding(또는 token_type embedding)이다. BERT는 원래 “문장 A와 문장 B를 함께 넣고 관계를 맞추는” 사전학습을 했고, 그래서 입력 포맷이 [CLS] A [SEP] B [SEP] 형태를 지원한다. 이때 토큰들이 A인지 B인지 알려줘야 해서 segment id를 0/1로 넣는다. 그리고 그 0/1도 임베딩 테이블에서 벡터를 꺼내서 더한다. 다만 AG News 같은 단일 문장 분류에서는 대부분 전부 segment 0이라 영향은 작다. 그래도 구조는 남아 있다. 왜냐하면 BERT라는 모델이 원래 “두 문장” 케이스까지 지원하는 범용 구조니까. 이제 핵심 합산이 나온다. BERT의 입력 임베딩은 하나가 아니라 세 개를 더한 것이다. 토큰 임베딩은 “이 토큰이 무엇인지”를 말해주고, 위치 임베딩은 “어디에 있었는지”를 말해주고, 세그먼트 임베딩은 “A문장이냐 B문장이냐”를 말해준다. 이 셋은 모두 똑같이 768차원 벡터라서 원소별 덧셈이 가능하다. 그래서 각 위치마다 최종 벡터는 E = E_token + E_position + E_segment가 된다. 결과 텐서의 모양은 (batch, length, 768)이 된다. 토크나이저에서 length는 “배치 내 최장 길이(동적 패딩 결과)”이고, 최대 192를 넘지 않는다. 그런데 더한 뒤에 LayerNorm과 Dropout을 하는 것도 이유가 있다. 덧셈을 하면 벡터 값의 분포가 흔들릴 수 있는데, LayerNorm은 각 토큰 위치의 768차원 벡터를 정규화해서 “스케일이 지나치게 커지거나 작아지는 문제”를 줄여준다. Dropout은 학습 중 일부 차원을 임의로 0으로 만들어서 “특정 차원에 과하게 의존하지 말고, 더 일반적인 표현을 만들어라”는 압박을 준다. 이건 과적합 방지의 전형적인 장치다. 결국 이 과정을 거친 출력이 12개 Transformer 레이어로 들어가는 “진짜 입력 표현”이 된다. cf 파라미터 정리 파라미터 관점에서 보면, 토큰 임베딩 테이블이 압도적으로 크다. 30,522×768이니까 23.4M 수준이다. 위치 임베딩은 192×768이라 0.15M 정도로 훨씬 작고, 세그먼트는 2×768이라 거의 무시할 정도다. 즉 “임베딩 레이어의 대부분은 사실 단어 의미 테이블”이고, 나머지는 그 의미에 ‘좌표’와 ‘구획’을 살짝 얹는 장치다. 그리고 fine-tuning에서 임베딩에 가장 작은 학습률을 주는 이유도 직관적이다. 사전학습 동안 이미 ‘언어의 기본 지도’를 잘 만들어놨으니, 그 지도를 크게 뒤흔들지 말고 현재 과제(뉴스 분류)에 맞게 조금만 조정하겠다는 태도다. 정리 토크나이저 단계에서 우리는 문장을 WordPiece로 잘게 쪼개고, [CLS]/[SEP] 같은 표식을 붙여서, 모델이 읽을 수 있는 정수 시퀀스로 바꾼다. 길이가 제각각이니 배치 안에서만 최장 길이에 맞춰 패딩하고, PAD는 attention_mask로 확실히 무시하도록 준비한다. 그 다음 임베딩 단계에서는 그 정수들을 “번호표”가 아니라 “의미 있는 좌표”로 바꾼다. 토큰 임베딩이 의미를 주고, 위치 임베딩이 순서를 주고, 세그먼트 임베딩이 문장 구분을 주며, 이 셋을 더해 (B, L, 768)의 입력 표현을 만든다. LayerNorm으로 안정화하고 Dropout으로 일반화를 유도한 뒤, 그 결과가 Transformer 블록으로 들어가면서 비로소 “문맥을 이해하는” 단계가 시작된다.


2026-03-03 ⋯ BERT 뉴스 분류 #9 모델 성능평가 - AUROC / AUPRC

지금까지 본 Accuracy, Precision, Recall, F1은 모두 하나의 특정한 규칙에 묶여 있다. "네 클래스 중 확률이 가장 높은 것을 정답으로 선택한다"는 규칙이다. 하지만 이 규칙이 항상 최선인 건 아니다. 예를 들어 모델이 어떤 기사에 대해 World 0.35, Sports 0.25, Business 0.22, Sci/Tech 0.18이라는 확률을 내놓았다고 하자. 기본 규칙에 따르면 World로 분류된다. 0.35가 가장 높으니까. 하지만 0.35라는 확률은 겨우 3분의 1 수준이다. 이걸 정말 World로 확정해야 할까? 임계값을 0.5로 올려서 "최소 50% 이상 확신이 있을 때만 분류한다"는 규칙을 쓰면 이 기사는 미분류로 남을 것이다. 임계값을 어디에 두느냐에 따라 모델의 행동이 완전히 달라진다. 임계값이 낮으면 많은 기사를 분류하지만 오분류도 늘어난다. 임계값이 높으면 확실한 것만 분류해서 정밀도는 올라가지만 많은 기사를 놓치게 된다. 그렇다면 모델의 진짜 실력을 측정하려면 어떤 특정 임계값에서의 성능이 아니라, 모든 가능한 임계값에서의 성능을 종합적으로 봐야 한다. 이것이 ROC 곡선과 PR 곡선의 존재 이유다. ROC 곡선: 모든 임계값에서의 트레이드오프 ROC 곡선은 임계값을 0에서 1까지 천천히 바꿔가면서, 각 임계값에서의 TPR(재현율)과 FPR(오탐율)을 기록한 것이다. TPR은 "실제 양성 중 모델이 잡아낸 비율"이다. 이전 단계에서 본 Recall과 같다. FPR은 "실제 음성 중 모델이 잘못 양성으로 분류한 비율"이다. 이상적인 모델은 TPR이 1이고 FPR이 0인 모델이다. 모든 양성을 다 잡아내면서 오탐은 전혀 없는 것이다. 임계값을 0으로 내리면 모든 것을 양성으로 분류하므로 TPR=1이지만 FPR도 1이다. 임계값을 1로 올리면 아무것도 양성으로 분류하지 않으므로 TPR=0이고 FPR=0이다. 임계값을 0에서 1로 올려가면서 TPR과 FPR이 어떻게 변하는지를 그래프로 그리면 ROC 곡선이 된다. 좋은 모델의 ROC 곡선은 그래프의 왼쪽 상단 모서리에 바짝 붙는다. FPR이 아주 작을 때(오탐이 거의 없을 때)에도 TPR이 높다(대부분을 잡아낸다)는 뜻이다. 나쁜 모델, 즉 랜덤으로 찍는 모델의 ROC 곡선은 대각선이다. FPR이 올라가는 만큼 TPR도 올라가는, 아무런 판별 능력이 없는 상태다. AUROC는 이 곡선 아래의 면적이다. 완벽한 모델은 1.0이고, 랜덤 모델은 0.5다. 이전 DNA 분석에서 ROC-AUC를 평가 지표로 썼을 때 설명한 것과 같은 개념이다. "임의로 고른 양성 샘플의 점수가 임의로 고른 음성 샘플의 점수보다 높을 확률"이 AUROC의 직관적 의미다. PR 곡선: 더 엄격한 잣대 PR 곡선은 Precision과 Recall로 그린다. 임계값을 바꿔가면서 각 지점에서의 Precision(정밀도)과 Recall(재현율)을 기록한 것이다. ROC 곡선과 PR 곡선의 결정적인 차이는 불균형 데이터에서 드러난다. 1,000개의 기사 중 Business가 50개뿐인 상황을 생각해보자. 모델이 100개를 Business로 예측했고 그 중 45개가 실제 Business라면 Precision은 45/100=0.45이고 Recall은 45/50=0.90이다. ROC의 FPR은 어떨까? 비Business 950개 중 55개를 잘못 Business로 예측했으니 FPR=55/950=0.058이다. FPR이 5.8%밖에 안 되니 ROC 관점에서는 꽤 좋아 보인다. 하지만 PR 관점에서는 Precision이 0.45로 절반도 안 된다. 같은 모델인데 ROC로 보면 좋아 보이고 PR로 보면 나빠 보이는 것이다. 이유는 FPR의 분모가 음성 전체 수(950개)이기 때문이다. 음성이 압도적으로 많으면 소수의 오분류가 FPR에 미치는 영향이 희석된다. 반면 Precision의 분모는 모델이 양성으로 예측한 수(100개)이므로 오분류가 직접적으로 반영된다. AUPRC가 AUROC보다 더 엄격한 지표라고 하는 이유가 바로 이것이다. 클래스별 분석 | 클래스 | AUROC | AUPRC | 해석 | |---|:---:|:---:|---| | **World** | 0.9854 | 0.9815 | 우수 | | **Sports** | **0.9995** | **0.9988** | 거의 완벽 | | **Business** | 0.9827 | 0.8778 | AUPRC 상대적 취약 | | **Sci/Tech** | 0.9931 | 0.9816 | 우수 | | **Macro 평균** | **0.9902** | **0.9599** | 전체 우수 | Sports의 AUROC가 0.9995다. 사실상 1.0이다. FPR이 0.001% 수준, 즉 만 개 중 하나도 안 되는 오탐률에서도 TPR이 거의 100%라는 뜻이다. ROC 곡선이 왼쪽 상단 모서리에 거의 완벽하게 달라붙어 있다. AUPRC도 0.9988로 PR 곡선 역시 오른쪽 상단에 달라붙어 있다. 스포츠 기사를 구별하는 일은 이 모델에게 사실상 완전히 풀린 문제다. Business에서 진짜 흥미로운 현상이 나타난다. AUROC가 0.9827인데 AUPRC가 0.8778이다. 약 10%포인트의 격차다. 다른 클래스에서는 이 격차가 0.4%포인트(World), 0.07%포인트(Sports), 1.2%포인트(Sci/Tech)에 불과한데, Business만 유독 크다. 이 격차가 발생하는 메커니즘을 구체적으로 따라가보자. Business의 ROC 곡선에서 AUROC가 0.9827이라는 건, 전반적인 순위 매기기 능력은 훌륭하다는 뜻이다. Business 기사에 높은 점수를, 비Business 기사에 낮은 점수를 대체로 잘 부여한다. 하지만 PR 곡선에서 문제가 드러난다. Recall을 높이려고 임계값을 낮추면, Sci/Tech 기사 중 기술 기업 관련 기사들이 Business 예측에 섞여 들어온다. 이 기사들이 꽤 높은 Business 점수를 받고 있기 때문이다. "TSMC plans $40 billion investment"라는 기사는 Business 점수가 높을 수밖에 없다. 이런 기사들이 유입되면서 Precision이 급격히 떨어지고, PR 곡선이 무너지는 것이다. ROC 곡선에서는 이 현상이 잘 안 보인다. 비Business 기사가 수백 건이나 되니까 Sci/Tech에서 넘어온 10건 정도는 FPR에 큰 영향을 미치지 않는다. 하지만 PR 곡선에서는 모델이 Business로 예측한 건수 대비로 계산하므로 이 10건이 Precision을 직접적으로 깎아먹는다. 정리 AUROC의 직관적 의미를 다시 한번 짚으면, Macro 평균 AUROC가 0.9902라는 건 네 클래스 모두에서 이 순위 매기기 능력이 99% 수준이라는 것이다. 모델의 근본적인 구별 능력은 매우 뛰어나다. Business의 AUROC 0.9827은 "뉴스 기사 하나를 무작위로 골랐을 때, 그것이 실제 Business이면 비Business보다 더 높은 Business 점수를 받을 확률이 98.27%"라는 뜻이다. 100번 비교하면 98번은 모델이 올바른 순서를 매긴다. AUPRC 0.8778은 다른 방식으로 모델을 시험한다. 모델이 Business 점수가 높은 순서대로 기사를 하나씩 꺼내간다. 처음 몇 개는 확실한 Business 기사다. "Q3 earnings beat expectations"같은 기사가 먼저 나온다. 여기까지는 Precision이 100%다. 계속 꺼내면 어느 순간 "TSMC plans $40 billion chip investment" 같은 기사가 끼어든다. 이건 Sci/Tech인데 Business 점수가 높게 나온 것이다. 이 기사가 끼어드는 순간 Precision이 떨어진다. 더 꺼내면 또 경계 기사가 섞이고, Precision이 더 떨어진다. AUPRC는 이 전체 과정에서의 평균 Precision이다. 핵심적인 차이는 여기 있다. AUROC에서 Sci/Tech 경계 기사 10건은 비Business 전체 228건(304건 중 Business 76건을 뺀 나머지) 속에 묻혀서 영향력이 희석된다. 하지만 AUPRC에서 이 10건은 모델이 Business로 예측한 상위권에 직접 끼어들어서 Precision을 깎아먹는다. 모델이 Business라고 자신 있게 내놓은 목록 안에 Sci/Tech가 섞여 있는 것이 AUPRC에서는 바로 드러나지만, AUROC에서는 쉬운 비교들에 가려서 잘 안 보이는 것이다. 그래서 AUROC가 "전체적으로 Business와 비Business를 구별하는 능력"을 측정한다면, AUPRC는 "모델이 Business라고 확신한 것들이 실제로 얼마나 믿을 만한가"를 측정한다. Business의 경우 전체적인 구별 능력은 98%로 훌륭하지만, 상위권의 순도는 88%로 떨어진다. 그 10%포인트의 격차가 곧 Business-Sci/Tech 경계 기사들의 혼동이다. AUPRC가 알려주듯이 "구별할 수 있다"와 "정밀하게 분류할 수 있다"는 같은 말이 아니다. Business-Sci/Tech 경계에서 모델은 두 클래스를 대체로 구별할 수 있지만(AUROC 0.98), 경계 근처의 애매한 기사들을 정밀하게 분류하는 데는 한계가 있다(AUPRC 0.88). 이 격차가 이전 단계에서 본 혼동 행렬의 Business-Sci/Tech 오분류 패턴과 정확히 일치한다. 여러 각도의 지표가 같은 문제를 가리키고 있는 것이다.