Slurm까지 왔으면 이제 진짜 모델 하나를 서버에서 끝까지 돌려보는 경험을 해야 한다. 국회 회의록 요약 과제를 첫 실전으로 잡은 이유는 단순하다. 데이터가 길고, 구조가 있고, 평가 기준이 명확하다!
이 과제는 생각보다 많은 걸 한 번에 경험하게 한다. 입력이 길어서 long sequence 문제를 체감하게 되고, JSON 구조라 전처리를 피할 수 없고, 요약 태스크라 생성 모델 흐름을 그대로 밟게 되고, ROUGE 평가로 감이 아니라 수치로 비교하게 된다. 즉, NLP 실험의 기본 골격이 전부 들어 있다. 처음 실전으로 딱 좋다.
국회 회의록 데이터(말평 2024)는 대략 이런 형태다.
{
"id": "nikluge-2024-국회회의록안건별요약-train-000001",
"input": {
"speaker": [
{"id": "김상희", "occupation": "위원", "original_id": "김상희"},
{"id": "문창진", "occupation": "보건복지부차관", "original_id": "문창진"}
],
"conversation": [
{"id": "SBRW2100000001.1.1.1", "speaker": "김상희", "utterance": "회의를 시작하도록 하겠습니다."},
{"id": "SBRW2100000001.1.1.2", "speaker": "김상희", "utterance": "의사일정 제1항을 상정합니다."}
],
"issue": "2008회계연도 세입세출결산"
},
"output": "본 회의에서 여성부의 2008회계연도 세입세출결산에 관해..."
}
여기서 중요한 건, 하나의 문서가 문장 하나가 아니라 발언들의 시퀀스라는 점이다. input.conversation에 평균 500개 발화가 들어 있고, 실제 모델 입력은 [발언1][발언2][발언3]... 같은 형태로 이어 붙인 긴 텍스트가 된다. 그래서 이 과제는 시작부터 long document 요약 문제다. 그냥 요약 모델 학습이 아니라, 입력 길이 때문에 학습/추론이 흔들릴 수 있다는 걸 처음부터 맞게 된다.
# 서버 접속 후
ssh <user>@seraph.khu.ac.kr
# 프로젝트 루트 (home 말고 /data 사용 - quota 문제 방지)
MY_ID=$(whoami)
PROJECT_ROOT="/data/${MY_ID}/projects/nams_experiments"
# 디렉토리 생성
mkdir -p $PROJECT_ROOT/{raw,data/{interim,processed},src/{preprocess,train,infer,eval,utils},configs,scripts,slurm,logs,outputs}
cd $PROJECT_ROOT
이거 안 하면 home 밑에 캐시가 쌓이고 quota 터질 수 있다.
export HF_HOME="/data/${MY_ID}/.cache/huggingface"
export TRANSFORMERS_CACHE="$HF_HOME/transformers"
mkdir -p $HF_HOME $TRANSFORMERS_CACHE
# 매번 치기 귀찮으면 env_vars.sh 만들기
cat > scripts/env_vars.sh << 'EOF'
export PROJECT_ROOT="/data/$(whoami)/projects/nams_experiments"
export HF_HOME="/data/$(whoami)/.cache/huggingface"
export TRANSFORMERS_CACHE="$HF_HOME/transformers"
export PYTHONUNBUFFERED=1
EOF
환경은 최대한 단순하게 간다.
conda create -n nams python=3.10 -y
conda activate nams
# PyTorch (CUDA 11.8)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
# 핵심 패키지
pip install transformers datasets accelerate
pip install rouge-score bert-score sacrebleu
pip install pandas numpy scikit-learn tqdm pyyaml
이 단계에서 의존성 충돌 나면, 그게 바로 HPC 첫 관문이다. 특히 torch/transformers 계열은 버전 꼬이면 에러가 친절하게 나오지 않는다. 그냥 한 줄 보고 멈춘다. 그러면 로그부터 읽는 습관이 여기서 생긴다.
데이터를 raw/에 넣고, 구조부터 확인한다.
ls -la raw/
# 국회회의록안건별요약_train.json (236MB, 1339개)
# 국회회의록안건별요약_dev.json (29MB, 167개)
# 국회회의록안건별요약_test.json (29MB, 167개)
# 데이터 구조 확인
import json
with open("raw/국회회의록안건별요약_train.json", "r") as f:
data = json.load(f)
print(f"문서 수: {len(data)}") # 1339
print(f"첫 문서 키: {data[0].keys()}") # id, input, output
# 발화 수 확인
conv = data[0]["input"]["conversation"]
print(f"첫 문서 발화 수: {len(conv)}") # 544
# 평균 발화 수
avg_utts = sum(len(d["input"]["conversation"]) for d in data) / len(data)
print(f"평균 발화 수: {avg_utts:.1f}") # ~500
이 시점에서 깨달아야 할 것: 평균 500개 발화 = 엄청 긴 입력. KoBART 같은 모델은 max_length가 1024 토큰이라 대부분 잘린다.
코드 돌리기 전에 GPU가 잡히는지부터 확인한다. 이거 안 하고 바로 학습 돌리면 나중에 왜 안 되지? 하고 삽질하게 된다.
# slurm/debug_gpu_test.sbatch
#!/bin/bash
#SBATCH --job-name=gpu_test
#SBATCH --partition=debug_ugrad
#SBATCH --account=ugrad
#SBATCH --gres=gpu:1
#SBATCH --mem-per-gpu=20G
#SBATCH --time=00:30:00
#SBATCH --output=logs/slurm-%j_gpu_test.out
echo "[INFO] job=$SLURM_JOB_ID"
echo "[INFO] host=$(hostname)"
echo "[INFO] start=$(date)"
nvidia-smi
source ~/.bashrc
conda activate nams
python -c "
import torch
print(f'torch: {torch.__version__}')
print(f'cuda: {torch.cuda.is_available()}')
if torch.cuda.is_available():
print(f'device: {torch.cuda.get_device_name(0)}')
"
echo "[INFO] end=$(date)"
sbatch slurm/debug_gpu_test.sbatch
tail -f logs/slurm-*_gpu_test.out
CUDA available: True 뜨면 성공. 여기서 막히면 뒤로 못 간다. 가끔은 GPU 1장 요청했는데 환경 설정이 꼬여서 CPU로만 돌다가 하루 날리는 경우도 있다.
학습 전에 추론부터 해본다. 모델이 뭘 뱉는지 보는 게 먼저다.
# src/infer/baseline_test.py
import json
import torch
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
def main():
print("[INFO] 모델 로드 중...")
model_name = "gogamza/kobart-summarization"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
model.eval()
print(f"[INFO] device={device}")
# 데이터 로드
with open("raw/국회회의록안건별요약_dev.json", "r") as f:
data = json.load(f)
# 첫 번째 문서로 테스트
doc = data[0]
conv = doc["input"]["conversation"]
# 입력 텍스트 생성 (발화 이어붙이기)
input_text = " ".join([
f"[{utt['speaker']}] {utt['utterance']}"
for utt in conv
])
print(f"[INFO] 입력 길이: {len(input_text)} chars")
print(f"[INFO] 발화 수: {len(conv)}")
# 토크나이즈
inputs = tokenizer(
input_text,
return_tensors="pt",
max_length=1024, # KoBART 최대
truncation=True,
).to(device)
print(f"[INFO] 토큰 수: {inputs['input_ids'].shape[1]}")
# 생성
with torch.no_grad():
outputs = model.generate(
**inputs,
max_length=256,
num_beams=4,
early_stopping=True,
)
summary = tokenizer.decode(outputs[0], skip_special_tokens=True)
print("\n" + "="*50)
print("[결과] 생성된 요약:")
print(summary)
print("\n[정답] 실제 요약:")
print(doc["output"][:500])
print("="*50)
if __name__ == "__main__":
main()
python src/infer/baseline_test.py
여기서 확인할 것만 딱 잡으면 된다. 요약이 나오긴 하는가? 토큰 수가 1024로 잘렸는가? (거의 확실히 잘림) 생성된 문장이 말이 되는가?
대부분 애매한 문장이 나온다. 정상이다. 입력이 잘려서 중요한 정보를 못 봤기 때문이다.
읽어보니 괜찮은데요?는 아무 의미가 없다. 비교하려면 숫자가 있어야 한다.
# src/eval/rouge_eval.py
from rouge_score import rouge_scorer
def compute_rouge(predictions, references):
scorer = rouge_scorer.RougeScorer(
["rouge1", "rouge2", "rougeL"],
use_stemmer=False,
)
scores = {"rouge1": [], "rouge2": [], "rougeL": []}
for pred, ref in zip(predictions, references):
result = scorer.score(ref, pred)
scores["rouge1"].append(result["rouge1"].fmeasure)
scores["rouge2"].append(result["rouge2"].fmeasure)
scores["rougeL"].append(result["rougeL"].fmeasure)
return {
k: sum(v) / len(v) for k, v in scores.items()
}
보통 보는 건 이것.
| 지표 | 의미 |
|---|---|
| ROUGE-1 | 핵심 단어 재현 (unigram) |
| ROUGE-2 | 연속 단어쌍 (bigram) |
| ROUGE-L | 문장 구조 (최장 공통 부분) |
점수가 낮아도 상관없다. 이건 출발선이다. 베이스라인 점수만 확보해도 이후에 바꾼 게 진짜 좋아졌는지 판단할 수 있게 된다. 그리고 그게 실험의 시작이다.
이제 전체 dev셋(167개)에 대해 추론하고 평가한다.
# slurm/01_baseline_infer.sbatch
#!/bin/bash
#SBATCH --job-name=baseline_infer
#SBATCH --partition=batch_ugrad
#SBATCH --account=ugrad
#SBATCH --gres=gpu:1
#SBATCH --mem-per-gpu=20G
#SBATCH --time=02:00:00
#SBATCH --output=logs/slurm-%j_baseline.out
# === 필수 로그 ===
echo "[INFO] job=$SLURM_JOB_ID"
echo "[INFO] host=$(hostname)"
echo "[INFO] start=$(date)"
nvidia-smi
# === 환경 ===
source ~/.bashrc
conda activate nams
source scripts/env_vars.sh
echo "[INFO] HF_HOME=$HF_HOME"
# === PyTorch 확인 ===
python -c "import torch; print(f'[INFO] cuda={torch.cuda.is_available()}')"
# === 추론 실행 ===
python src/infer/run_baseline.py \
--input raw/국회회의록안건별요약_dev.json \
--output outputs/baseline_dev_results.json
# === 완료 ===
echo "[INFO] end=$(date)"
그리고 제출.
sbatch slurm/01_baseline_infer.sbatch
이제 할 일은 딱 하나다.
squeue -u $USER
RUNNING이면 기다리고, PENDING이면 이유를 보고, FAILED면 로그를 연다. 이 루틴이 HPC 생활의 80%다. 참고로 PENDING이 떴다고 무조건 잘못된 게 아니다. 그냥 자리가 없어서 기다리는 중일 수도 있다. 대신 PENDING 사유가 (Resources, Priority 같은) 정상적인 이유인지, 아니면 요청 옵션이 잘못돼서 영원히 못 잡는 상태인지 그걸 보는 게 중요하다.
로그 보는 법:
tail -f logs/slurm-XXXX_baseline.out
GPU가 실제로 잡혔는지, loss가 내려가는지, 갑자기 멈추진 않는지. 그리고 멈췄을 때는 대부분 두 종류다. 하나는 메모리 터짐(OOM), 하나는 데이터/경로 문제로 즉사. 둘 다 로그에 힌트가 남는다.
추론이 끝나면 결과를 본다.
import json
with open("outputs/baseline_dev_results.json", "r") as f:
results = json.load(f)
print(f"ROUGE-1: {results['rouge1']:.4f}")
print(f"ROUGE-2: {results['rouge2']:.4f}")
print(f"ROUGE-L: {results['rougeL']:.4f}")
100개 샘플로 돌린 결과다.
| 지표 | 점수 |
|---|---|
| ROUGE-1 | 0.0879 |
| ROUGE-2 | 0.0193 |
| ROUGE-L | 0.0834 |
| BERT-Score F1 | 0.6535 |
| BLEU | 0.0240 |
점수가 처참하다. 왜 그런지 실제 출력을 보면 바로 이해된다.
샘플 1: 완전히 엉뚱한 내용
[모델 출력]
제265회 국회(임시회) 제2차 법안심사소위원회를 개의해 주신 위원 여러분께
감사의 말씀을 드렸고, '강창일] 오늘 회의에서는 지난 수요일 제1차 회의에 이어...
[정답]
의사일정 제4항 지방자치법 일부개정법률안은 특별지방자치단체를 설치하고
경제자유구역청을 특별지방자치단체로 전환하며 성과공시제도를 입법화하는 내용으로서...
모델이 회의 시작 부분만 보고 개의합니다, 감사합니다 같은 의례 발언을 요약이라고 뱉었다. 진짜 논의 내용은 뒤에 있는데 거기까지 못 봤다.
샘플 2: 입력 잘림으로 인한 정보 손실
[모델 출력]
작년 12월 5일에 처음 상정된 이 법안은 작년 11월 30일에 상정된 소위가 두 번째입니다.
작년 12월 4일에 처음 올렸는데 그때 변호사 출신의 법무담당관과...
[정답]
본 회의는 법사위원회 산하 법안심사제1소위원회의 법무부 소관 법률에 대한 심의를
위한 회의로, 먼저 정부가 제출한 정부법무공단법안 제정안에 대해 논의하였다...
회의 맥락은 잡았는데 핵심 내용(정부법무공단법안)을 전혀 못 잡았다.
| 유형 | 건수 | 비율 |
|---|---|---|
| off_topic (엉뚱한 내용) | 60 | 60% |
| too_long (너무 김) | 22 | 22% |
| partial (일부만 맞음) | 5 | 5% |
| repetition (반복) | 5 | 5% |
| good (괜찮음) | 4 | 4% |
| too_short (너무 짧음) | 4 | 4% |
100개 중 4개만 쓸만했다. 나머지 96개는 문제가 있다.
점수가 낮은 이유는 뻔하다. 입력이 잘린다. 1024 토큰 제한 때문에 앞부분만 보고 요약한다. 그리고 개의하겠습니다 같은 의례 발언이 섞여서 모델이 헷갈린다.
이게 바로 다음 단계(전처리)가 필요한 이유다.
그래서 전처리를 바꿔봤다. 세 가지 버전으로.
결과가 재밌게 나왔다.
| 버전 | ROUGE-1 | ROUGE-2 | ROUGE-L | BERT-Score F1 |
|---|---|---|---|---|
| v0 (원본) | 0.1154 | 0.0217 | 0.1090 | 0.6363 |
| v1 (발언결합) | 0.1151 | 0.0211 | 0.1111 | 0.6371 |
| v2 (의례제거) | 0.0879 | 0.0193 | 0.0834 | 0.6535 |
예상과 다르게 v2가 ROUGE는 가장 낮다. 왜일까?
compression_ratio를 보면 답이 나온다.
v2는 의례 발언을 제거해서 입력이 짧아졌고, 그래서 모델이 더 많은 내용을 볼 수 있었다. 근데 ROUGE가 낮은 이유는 출력 길이가 달라져서다. BERT-Score가 높은 건 의미적으로는 더 잘 맞춘다는 뜻.
결론은 단순 ROUGE만 보면 안 된다는 거다. 전처리 효과는 복합적이다.
긴 문서에서 long-context 모델이 효과 있을까? 평균 36,000자짜리 긴 회의록 50개로 테스트했다.
| 모델 | max_input | ROUGE-1 | ROUGE-2 | ROUGE-L | BERT-Score F1 | truncation_rate |
|---|---|---|---|---|---|---|
| KoBART (truncate) | 1024 | 0.0660 | 0.0115 | 0.0629 | 0.6502 | 100% |
| phi-2 | 2048 | 0.0179 | 0.0000 | 0.0179 | 0.5979 | 100% |
결과가 좀 아쉽다. 둘 다 truncation_rate가 100%라서 결국 전부 잘렸다. 긴 회의록은 평균 36,000자인데 KoBART는 1024토큰, phi-2는 2048토큰이 한계라서 어차피 앞부분만 보게 된다.
그래도 KoBART가 phi-2보다 ROUGE-1 기준 3.7배 좋다. 이건 S6에서 본 것처럼 언어 특화의 힘이다. long-context가 의미 있으려면 최소 16K 이상 지원하는 모델이 필요하다. LongChat-7B-16K나 Yarn-Mistral-7B-128K 같은 모델이 필요한데, 메모리 문제로 이번엔 못 돌렸다.
프롬프트 엔지니어링이 얼마나 효과 있는지 봤다. phi-2(2.7B)로 세 가지 프롬프트를 비교했다.
| 프롬프트 | ROUGE-1 | ROUGE-2 | ROUGE-L | BERT-Score F1 |
|---|---|---|---|---|
| simple | 0.0253 | 0.0000 | 0.0253 | 0.5896 |
| structured | 0.0420 | 0.0105 | 0.0420 | 0.5903 |
| extractive_guide | 0.0385 | 0.0025 | 0.0385 | 0.5918 |
점수가 전체적으로 낮다. phi-2가 한국어에 약하기 때문이다. 하지만 상대적으로 보면 structured 프롬프트가 ROUGE 기준 최고고, extractive_guide가 BERT-Score 최고다. simple은 가장 나쁘다. 아무 가이드 없으면 모델이 헤맨다.
결론은 프롬프트 설계가 중요하긴 하지만, 모델 자체가 한국어를 못하면 한계가 있다는 거다.
이건 좀 재밌는 실험이다. 한국어 요약이 잘 안 되니까, 아예 영어로 바꿔서 요약하고 다시 한국어로 번역하면 어떨까?
| 방식 | ROUGE-1 | ROUGE-2 | ROUGE-L | BERT-Score F1 |
|---|---|---|---|---|
| direct (직접 요약) | 0.0410 | 0.0000 | 0.0410 | 0.5894 |
| pivot (한→영→한) | 0.0605 | 0.0000 | 0.0605 | 0.6154 |
놀랍게도 피벗 방식이 47% 더 좋다. 한국어 → 영어 번역 → 영어 요약 → 한국어 번역. 번역 두 번 하는데도 더 낫다니.
왜 그럴까? 영어 요약 모델이 훨씬 성능이 좋고, 번역 모델(NLLB)이 꽤 괜찮아서 정보 손실이 적다. 결국 약한 한국어 능력보다 강한 영어 능력에 번역 비용 더한 게 낫다는 얘기다.
물론 추론 시간은 3배 이상 걸린다. 하지만 품질이 더 중요한 상황이라면 고려해볼 만하다.
더 큰 모델이 더 좋을까? KoBART(124M) vs phi-2(2.7B)를 비교했다.
| 모델 | 파라미터 | ROUGE-1 | ROUGE-2 | ROUGE-L | BERT-Score F1 | 추론시간(초/샘플) |
|---|---|---|---|---|---|---|
| KoBART | 124M | 0.0879 | 0.0193 | 0.0834 | 0.6535 | 1.00 |
| phi-2 | 2.7B | 0.0236 | 0.0000 | 0.0236 | 0.5952 | 12.91 |
충격적인 결과다. 22배 큰 모델이 오히려 더 못한다.
왜 그럴까? KoBART는 한국어로 학습됐고 요약용으로 파인튜닝됐다. phi-2는 영어 중심이고 범용 LLM이다. 언어 특화와 태스크 특화가 그만큼 중요하다는 거다. 큰 모델이라고 좋은 게 아니다. 도메인과 태스크가 안 맞으면 의미없다.
효율성 측면에서는 KoBART가 45배 더 효율적이다 (score_per_second 기준).
결론은 무조건 큰 모델 쓰지 말고 태스크에 맞는 모델을 찾으라는 거다.
| 시나리오 | 핵심 발견 |
|---|---|
| S1 베이스라인 | 입력 잘림이 치명적 (91% 실패) |
| S2 전처리 비교 | 의례발언 제거가 BERT-Score 향상 |
| S3 Long-context | 현재 모델은 전부 truncation, 16K+ 필요 |
| S4 프롬프트 | structured 프롬프트가 가장 효과적 |
| S5 피벗 번역 | 한→영→한이 직접 요약보다 47% 좋음 |
| S6 모델 규모 | 작은 한국어 모델이 큰 영어 모델보다 3.7배 좋음 |
핵심 교훈은 네 가지다. 첫째, 입력 길이 문제가 가장 크다. long-context 모델이 필요하다. 둘째, 언어 특화 모델이 범용 LLM보다 낫다. 셋째, 프롬프트 엔지니어링은 도움이 되지만 한계가 있다. 넷째, 피벗 번역은 의외로 효과적인 대안이다.
| 단계 | 할 일 | 확인 |
|---|---|---|
| 환경 | Conda 생성, HF 캐시 설정 | torch.cuda.is_available() |
| 데이터 | raw/에 복사, 구조 확인 | 평균 500 발화 확인 |
| GPU 테스트 | debug 파티션에서 확인 | nvidia-smi 정상 |
| 베이스라인 추론 | KoBART로 첫 요약 | 요약 문장 출력 |
| ROUGE 평가 | 숫자로 기록 | 출발선 확보 |