본문 바로가기
HRDI_AI/머신러닝_딥러닝 핵심 기술과 실무 중심 생성형 AI 프로젝트

translate_article.py

by Toddler_AD 2025. 11. 29.

1. 이 스크립트가 하는 일

“로컬에 저장된 한글 기사를 불러와, 미리 파인튜닝해둔 ke-T5 번역 모델로 ‘조각내서 번역’하고, 결과를 파일로 저장하는 스크립트”

조금 더 자세히 말하면:

  1. 파인튜닝해둔 T5 기반 한→영 번역 모델과 토크나이저를 로드하고
  2. 긴 한국어 기사 텍스트를 토큰 길이 기준으로 적당한 크기의 chunk로 분할한 뒤
  3. 각 chunk를 순서대로 번역하고
  4. 번역된 결과를 합쳐서 콘솔에 출력 + 파일로 저장합니다.

2. 상단 설정부: 모델/디바이스/프롬프트

import re
from pathlib import Path

import torch
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM

MODEL_DIR = "./ke_t5_ko2en_tech_final"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("사용 디바이스:", device)

tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR)
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_DIR).to(device)
model.eval()

PROMPT_PREFIX = "한글을 영어로 번역:\n"

MAX_INPUT_TOKENS = 256
MAX_TARGET_TOKENS = 256

2-1. 라이브러리

  • re : 정규표현식으로 문단/문장을 나누는 데 사용
  • Path : 파일 경로 다루기 (OS에 따라 경로 구분자 달라도 깔끔하게 처리)
  • torch : PyTorch 텐서 & GPU 사용
  • transformers : Hugging Face의 T5 계열 번역 모델을 불러오기 위한 라이브러리

2-2. MODEL_DIR

MODEL_DIR = "./ke_t5_ko2en_tech_final"
  • 파인튜닝이 끝난 모델이 저장된 로컬 폴더 경로입니다.
  • 이 폴더 안에는 config.json, pytorch_model.bin, tokenizer.json 같은 파일들이 들어 있습니다.
  • from_pretrained(MODEL_DIR)로 이 디렉토리에서 모델과 토크나이저를 불러옵니다.

2-3. 디바이스 선택

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
  • GPU가 있으면 cuda, 없으면 cpu를 사용하도록 자동 선택.
  • 이후 텐서와 모델을 .to(device)로 올려서 동일한 디바이스에서 연산하게 합니다.

2-4. 모델과 토크나이저 로드

tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR)
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_DIR).to(device)
model.eval()
  • AutoTokenizer / AutoModelForSeq2SeqLM:
    • T5 계열 번역 모델을 로드할 때 사용하는 추상화 클래스.
  • .to(device) : 방금 설정한 GPU/CPU로 모델을 이동.
  • model.eval() : 추론 모드로 전환 (Dropout 끄기 등).

2-5. 프롬프트와 토큰 길이

PROMPT_PREFIX = "한글을 영어로 번역:\n"
MAX_INPUT_TOKENS = 256
MAX_TARGET_TOKENS = 256
  • PROMPT_PREFIX
    • 이 모델을 “인스트럭션 기반(프롬프트 기반)”으로 파인튜닝했기 때문에,
      입력마다 앞에 "한글을 영어로 번역:\n"을 붙여서 동일한 형식을 맞춥니다.
  • 토큰 길이 제한
    • MAX_INPUT_TOKENS : 인풋(프롬프트+한글 문장)의 최대 토큰 수
    • MAX_TARGET_TOKENS : 출력(영어 번역문)의 최대 토큰 수
    • 너무 길면 메모리/시간이 폭주하므로 적당한 값으로 조절한 것.

3. 긴 텍스트를 잘게 나누는 핵심 함수: chunk_text

def chunk_text(text: str, tokenizer, max_tokens: int, prompt_prefix: str) -> list[str]:
    """
    긴 텍스트를 모델이 처리 가능한 길이(max_tokens) 안으로 잘라주는 함수.
    너무 정교한 문장 분리는 아니지만, 기사 테스트용으로는 충분합니다.
    """
    # 문단 단위로 먼저 나누기 (빈 줄 기준)
    paragraphs = re.split(r"\n\s*\n+", text.strip())

    chunks: list[str] = []

    for para in paragraphs:
        para = para.strip()
        if not para:
            continue

3-1. 문단 → 문장 → chunk 순서

  1. 문단 단위로 1차 분할
    • \n 두 줄 이상(빈 줄)을 기준으로 문단을 나눕니다.
  2. 문장 단위로 2차 분할
    • 마침표(.), 물음표(?), 느낌표(!) 뒤의 공백 기준으로 문장을 나눕니다.
  3. 각 문장들을 이어 붙이며 토큰 길이 체크
    • 지금까지 모인 문장들을 하나의 chunk로 유지하다가,
      새 문장을 붙였을 때 max_tokens를 넘으면 새 chunk로 끊습니다.

계속 보면:

        # 문장 단위로 한 번 더 나누기
        sentences = re.split(r"(?<=[\.!?])\s+", para)

        current = ""
        for sent in sentences:
            sent = sent.strip()
            if not sent:
                continue

            # 현재 chunk에 문장을 추가했을 때 길이를 체크
            candidate = (current + " " + sent).strip() if current else sent
            tokenized = tokenizer(
                prompt_prefix + candidate,
                add_special_tokens=True,
                return_attention_mask=False,
                return_token_type_ids=False,
            )
            length = len(tokenized["input_ids"])

 

  • current : 지금까지 모은 문장들(현재 chunk의 텍스트)
  • sent : 새로 처리할 문장
  • candidate : current에 sent를 붙인 가상의 텍스트
  • tokenizer(...)로 프롬프트까지 포함한 전체 길이를 토큰 단위로 계산합니다.
            if length <= max_tokens:
                # 아직 여유 있으면 이어 붙이기
                current = candidate
            else:
                # 지금까지 모은 current는 하나의 chunk로 확정
                if current:
                    chunks.append(current)

 

  • length <= max_tokens : 아직 여유 → 그냥 이어 붙이고 계속 진행.
  • length > max_tokens :
    • 지금까지의 current는 chunk로 확정하고 chunks 리스트에 추가.
    • 이제 sent를 어떻게 처리할지 별도로 판단합니다.

3-2. 한 문장 자체가 너무 긴 특수 케이스 처리

                # 바로 이 문장(sent) 자체가 너무 긴 경우 → 강제로 잘라서 넣기
                tokenized_sent = tokenizer(
                    prompt_prefix + sent,
                    add_special_tokens=True,
                    return_attention_mask=False,
                    return_token_type_ids=False,
                )["input_ids"]

                if len(tokenized_sent) > max_tokens:
                    # 토큰 단위로 잘라서 여러 chunk로 분해
                    start = 0
                    # 프롬프트 부분 길이를 대략 보정
                    prompt_tokens = len(
                        tokenizer(
                            prompt_prefix,
                            add_special_tokens=True,
                            return_attention_mask=False,
                            return_token_type_ids=False,
                        )["input_ids"]
                    )
                    usable = max_tokens - prompt_tokens

 

  • 어떤 문장은 자기 혼자만 넣어도 max_tokens를 넘어갈 수 있습니다.
    • 예: 표, 아주 긴 문단이 한 줄에 몰려 있을 때.
  • 이럴 때는 토큰 배열을 슬라이스(자르기) 해서 몇 덩어리로 강제로 나눕니다.
  • prompt_tokens를 따로 구해서 프롬프트 길이를 제외한 실제 문장에 쓸 수 있는 토큰 수(usable)를 계산.
                    while start < len(tokenized_sent):
                        piece_ids = tokenized_sent[start : start + usable]
                        piece_text = tokenizer.decode(
                            piece_ids, skip_special_tokens=True
                        )
                        # 프롬프트 내용이 포함되어 있을 수 있으니 제거
                        piece_text = piece_text.replace(prompt_prefix, "").strip()
                        if piece_text:
                            chunks.append(piece_text)
                        start += usable
                    current = ""

 

  • tokenized_sent에서 usable 길이만큼 잘라서 piece_ids를 만들고,
  • 다시 텍스트로 디코딩하여 chunk 리스트에 추가.
  • 디코딩된 텍스트에 프롬프트 문자열이 섞여 들어갈 수 있으니 replace(prompt_prefix, "")로 제거.

3-3. 일반적인 경우: 문장 단위로 새 chunk 시작

                else:
                    # 이 문장 하나는 max_tokens 안이므로 새 chunk로 시작
                    current = sent
  • sent 혼자서는 max_tokens 안에 들어가는 경우,
    • current를 이 문장으로 초기화하고,
    • 다음 문장부터 다시 이어 붙이며 길이를 체크.

3-4. 마지막 마무리

        # 문단 내에서 남아 있는 chunk가 있으면 추가
        if current:
            chunks.append(current)

    return chunks
  • 루프가 끝나고 나서도 current에 내용이 남아 있으면 마지막 chunk로 추가.
  • 결과적으로 chunks는 각 chunk가 토큰 길이 제한을 넘지 않는 한국어 텍스트 리스트가 됩니다.

4. 단일 chunk 번역: translate_chunk

def translate_chunk(text: str) -> str:
    """단일 chunk(짧은 문단/문장 묶음)를 번역"""
    src_text = PROMPT_PREFIX + text

    inputs = tokenizer(
        src_text,
        return_tensors="pt",
        truncation=True,
        max_length=MAX_INPUT_TOKENS,
    )
    input_ids = inputs["input_ids"].to(device)
    attention_mask = inputs["attention_mask"].to(device)

    with torch.no_grad():
        generated_ids = model.generate(
            input_ids=input_ids,
            attention_mask=attention_mask,
            max_length=MAX_TARGET_TOKENS,
            num_beams=5,
            early_stopping=True,
        )

    output_text = tokenizer.decode(generated_ids[0], skip_special_tokens=True)
    return output_text.strip()

핵심 포인트

 1. 프롬프트 + 텍스트

  • src_text = PROMPT_PREFIX + text
  • 학습할 때와 완전히 같은 형식으로 입력을 만들어줘야 모델이 제대로 동작합니다.

 2. 토크나이징 & 텐서 변환

inputs = tokenizer(
    src_text,
    return_tensors="pt",
    truncation=True,
    max_length=MAX_INPUT_TOKENS,
)

 

 3. 모델 추론 (생성) 

with torch.no_grad():
    generated_ids = model.generate(
        input_ids=input_ids,
        attention_mask=attention_mask,
        max_length=MAX_TARGET_TOKENS,
        num_beams=5,
        early_stopping=True,
    )

 

  • torch.no_grad() : 추론 시에는 그래디언트 계산 필요 없으므로 메모리 절약.
  • model.generate(...) :
    • num_beams=5 : Beam Search 사용 (조금 더 좋은 문장을 찾기 위한 탐색 전략).
    • max_length : 번역문 길이 제한.
    • early_stopping=True : EOS 토큰 등이 나오면 일찍 종료.

 4. 디코딩

output_text = tokenizer.decode(generated_ids[0], skip_special_tokens=True)
return output_text.strip()
  • 생성된 토큰 ID를 실제 문자열(영문 번역 텍스트)로 변환.
  • skip_special_tokens=True : <pad>, </s> 같은 특수 토큰 제거.
  • .strip() : 앞뒤 공백 제거.

5. 전체 긴 텍스트 번역: translate_long_text

def translate_long_text(text: str) -> str:
    """기사처럼 긴 텍스트 전체를 번역"""
    chunks = chunk_text(text, tokenizer, MAX_INPUT_TOKENS, PROMPT_PREFIX)
    print(f"총 {len(chunks)}개 chunk로 분할되었습니다.")

    translated_chunks: list[str] = []
    for i, chunk in enumerate(chunks, start=1):
        print(f"[{i}/{len(chunks)}] 번역 중...")
        translated = translate_chunk(chunk)
        translated_chunks.append(translated)

    # chunk 사이에 빈 줄을 하나 두고 이어 붙이기
    return "\n\n".join(translated_chunks)

 

1. 먼저 chunking

 

  • chunk_text를 이용해 긴 기사 텍스트를 여러 개의 작은 chunk로 나눕니다.
  • 몇 개로 잘렸는지 로그 출력: 총 N개 chunk로 분할되었습니다.

2. 각 chunk 번역 + 진행상황 표시

for i, chunk in enumerate(chunks, start=1):
    print(f"[{i}/{len(chunks)}] 번역 중...")
    translated = translate_chunk(chunk)
    translated_chunks.append(translated)

 

 

    • 실제 실행 시, 긴 문서를 번역하면 시간이 꽤 걸리므로 현재 몇 번째 chunk를 번역 중인지 콘솔에 표시해서 진행 상황을 알 수 있게 합니다.

 3. 번역 결과 합치기

return "\n\n".join(translated_chunks)

 

  • 각 chunk 번역 결과를 빈 줄 1줄을 사이에 두고 이어 붙여 읽기 좋은 단락 구조를 유지합니다.

6. 파일 단위 입·출력: translate_article_file

def translate_article_file(input_path: str, output_path: str | None = None):
    """
    로컬 텍스트 파일(한국어 기사)을 읽어서 번역 후,
    화면에 출력하고, 필요하면 output_path로 저장.
    """
    input_path = Path(input_path)
    text = input_path.read_text(encoding="utf-8")

    print(f"입력 파일: {input_path}")
    print(f"문자 수: {len(text)}\n")

    translated = translate_long_text(text)

    # 결과 출력 (앞부분만 맛보기로 보고 싶으면 슬라이스해서 출력해도 됨)
    print("\n===== 번역 결과 (전체) =====\n")
    print(translated)

    if output_path:
        out_path = Path(output_path)
        out_path.write_text(translated, encoding="utf-8")
        print(f"\n번역 결과를 '{out_path}'에 저장했습니다.")
  • 입력 파일 읽기
input_path = Path(input_path)
text = input_path.read_text(encoding="utf-8")

 

    • 문자열 경로를 Path 객체로 변환해서 사용.
    • UTF-8 인코딩으로 전체 텍스트를 읽어옵니다.
  • 기본 정보 출력
    • 파일 경로와 총 문자 수를 출력해서,
      “내가 어떤 파일을 번역 중인지” 확인할 수 있게 합니다.
  • 실제 번역 수행
translated = translate_long_text(text)

 

    • 아까 만든 translate_long_text를 호출하여
      긴 기사 전체를 번역합니다.
  • 결과 출력과 저장
    • 콘솔에 전체 결과를 출력.
    • output_path가 전달된 경우에만 파일로 저장.
    • 저장 경로도 Path로 처리하고 UTF-8로 기록.

7. 스크립트 진입점: if __name__ == "__main__":

if __name__ == "__main__":
    # 1) 파일에서 기사 읽어서 번역하는 경우
    INPUT_FILE = "article_ko_1.txt"     # 한국어 기사 텍스트 파일
    OUTPUT_FILE = "article_en_1.txt"    # 번역 결과 저장 파일 (원치 않으면 None)

    translate_article_file(INPUT_FILE, OUTPUT_FILE)

    # 2) 직접 붙여넣은 긴 문자열을 테스트하고 싶다면 아래처럼 사용
    # long_text = \"\"\"여기에 한국어 기사 전체를 붙여넣으세요...\"\"\"
    # result = translate_long_text(long_text)
    # print(result)
  • 이 부분은 스크립트를 직접 실행했을 때만 동작합니다.
    • 다른 파일에서 import translate_article로 가져다 쓸 때는 실행되지 않음.
  • 기본 사용 방식:
    1. 같은 폴더에 article_ko_1.txt 파일을 만들어 한국어 기사를 붙여넣고
    2. python translate_article.py 를 실행하면
    3. article_en_1.txt에 번역 결과가 저장됩니다.
  • 주석 처리한 부분은 직접 문자열을 코드 안에 붙여넣고 테스트할 때 사용하는 예시입니다.

8. 요약

  • 문제 상황
    • 논문/기사처럼 긴 한국어 텍스트를 한 번에 번역하려면,
      모델이 처리할 수 있는 최대 토큰 길이를 넘어서 에러가 발생하거나 품질이 떨어질 수 있습니다.
  • 해결 전략
    1. 문단 → 문장 → 토큰 기준으로 텍스트를 적당한 크기로 잘게 나눈다.
    2. 각 조각(chunk)을 동일한 프롬프트 형식으로 모델에 넣어 번역한다.
    3. 번역된 조각들을 다시 합쳐서 하나의 영어 문서로 만든다.
    4. 진행 상황과 결과를 보기 쉽게 로그/파일로 정리한다.
  • 이 스크립트의 장점
    • 이미 파인튜닝해 둔 T5 모델을 그대로 활용할 수 있고,
    • 토큰 길이 기준으로 안전하게 잘라서 긴 문서도 무리 없이 번역 가능하며,
    • 구조가 단순해서 Colab / 서버 / 로컬 PC 어디서나 쉽게 재사용할 수 있습니다.