09_DL(Deep_Learning)

32_트랜스포머

chuuvelop 2025. 5. 11. 22:16
728x90

사전 학습 모델

언어 모델(language model)

  • 시퀀스에 확률을 부여하는 모델
    • 단어 시퀀스를 입력받아 해당 시퀀스가 얼마나 그럴듯한지 확률을 출력하는 모델
    • 한국어 말뭉치로 학습한 언어 모델은 자연스러운 한국어 문장에 높은 확률값을 부여함
  • 문장에서 i번째로 등장하는 단어를 wi로 표시한다면 n개 단어로 구성된 문장이 해당 언어에서 등장할 확률, 즉 모델의 출력은 P(w1, w2, ..., wn) 처럼 쓸 수 있음
    • n개 단어가 동시에 나타날 결합 확률(joint probability)을 의미
    • 잘 학습된 한국어 모델이 있다면 P(무모, 운전) 보다는 P(난폭, 운전)이 큰 확률값을 지녀야 함
  • "난폭"이 나타난 다음에 "운전"이 나타날 확률을 조건부 확률(conditional probability)라고 함
    • P(운전|난폭) = P(난폭, 운전) / P(난폭)
    • 조건부 확률을 표기할 때 결과가 되는 사건을 앞에, 조건이 되는 사건을 뒤에 씀
      • 결과가 되는 사건(운전)은 조건이 되는 사건(난폭)의 영향을 받아 변함
        • 앞선 단어가 "난폭" 이라면 다음 단어로 자연스러운 단어는 선택지가 줄어들기 때문
  • 단어 3개가 동시에 등장할 결합 확률 수식
    • P(w1, w2, w3) = P(w1) * P(w2|w1) * P(w3|w1, w2)
    • 단어 3개로 구성된 문장이 나타나려면 다음 3가지 사건이 동시에 일어나야 함
      • 첫 번째 단어(w1) 등장
      • 첫 번째 단어 등장 후 두 번째 단어(w2) 등장
      • 첫 번째 단어와 두 번째 단어 등장 후 세 번째 단어(w3) 등장
  • 전체 단어 시퀀스가 나타날 확률은 이전 단어들이 주어졌을 때 다음 단어가 등장할 확률의 연쇄
    • 이 때문에 언어 모델을 "이전 단어들이 주어졌을 때 다음 단어가 나타날 확률을 구하는 모델"이라고 정의하기도 함

 

순방향 언어 모델(forward language model)

  • 임의의 단어 시퀀스가 해당 언어에서 얼마나 자연스러운지 이해하고 있는 언어모델을 구축하려면 이전 단어들이 주어졌을 때 다음 단어 맞히기로 정해도 목표를 달성할 수 있음

 

  • 위 이미지에서 검은색 단어는 컨텍스트, 주황색 단어는 맞혀야 할 다음 단어
    • 문장 앞부터 뒤로, 사람이 이해하는 순서대로 계산하는 모델을 순방향 언어 모델(forward language model)이라고 함
    • GPT, ELMo 같은 모델이 이런 방식으로 사전 학습을 수행함

 

역방향 언어 모델(backward language model)

  • 방향만 바뀌었을 뿐이고 다음 단어 맞히기 과정에서 전체 단어 시퀀스가 나타날 확률을 계산할 수 있음

 

 

넓은 의미의 언어 모델

  • 최근에는 언어 모델을 다음 수식과 같이 정의함
    • P(w|context)
  • 이는 컨텍스트(주변 맥락 정보)가 전제된 상태에서 특정 단어가 나타날 조건부 확률을 나타냄
  • 이렇게 정의된 언어 모델은 단어나 단어 시퀀스로 구성된 컨텍스트를 입력받아 특정 단어가 나타날 확률을 출력
    • 컨텍스트와 맞힐 단어를 어떻게 설정하느냐에 따라 다양하게 변형 가능

 

 

마스크 언어 모델(masked language model)

  • 학습 대상 문장에 빈칸을 만들어 놓고 해당 빈칸에 올 단어로 적절한 단어가 무엇일지 분류하는 과정으로 학습
    • BERT가 대표적인 마스크 언어 모델

 

  • 검정색 단어는 컨텍스트, 주황색 단어는 맞혀야 할 타깃 단어
  • 마스크 언어 모델은 맞힐 단어를 계산할 때 문장 전체의 맥락을 참고할 수 있다는 것이 장점
    • 따라서 마스크 언어 모델에는 양방향성(맞힐 단어 앞뒤를 모두 보는 특성)이 있음

 

언어 모델의 유용성

  • 잘 학습된 언어 모델은 어떤 문장이 자연스러운지 가려낼 수 있어 그 자체로 값어치가 있음
    • 학습 대상 언어의 풍부한 맥락을 포함하고 있다는 점도 장점
  • 기계 번역, 문법 교정, 문장 생성 등 다양한 태스크를 수행할 수 있음
    • 기계 번역 : P(?|You can't be free from death)
    • 문법 교정 : P(두시 삼십 이분) > P(이시 서른 두분)
    • 문장 생성 : P(?|발 없는 말이)

 

  • 위 이미지는 네이버 댓글 데이터로 학습한 BERT 모델의 계산 결과
  • 최근 언어 모델이 주목받는 이유 중 하나는 데이터 제작 비용 때문
    • 다음 단어 맞히기나 빈칸 맞히기로 학습 태스크를 구성하면 사람이 일일이 수작업 하지 않아도 많은 학습 데이터를 확보할 수 있음
    • 대규모 언어 모델 학습이 가능
  • 또 하나의 이유는 전이 학습
    • 대량의 말뭉치로 사전학습한 언어 모델을 다운스트림 태스크에 적용하면 적은 양의 데이터로도 성능을 큰 폭으로 올릴 수 있음
    • 최근 제안되는 기법들은 사전학습 언어 모델을 바탕으로 할 때가 많음
    • 언어 모델의 최종 출력값 또는 중간 출력값(임베딩)으로 다양한 태스크 수행

 

트랜스포머

  • 2017년 구글이 제안한 seq2seq 모델

 

seq2seq

  • 트랜스포머는 seq2seq 과제를 수행하는 모델
    • 시퀀스 : 단어 같은 무언가의 나열
    • seq2seq : 특정 속성을 지닌 시퀀스를 다른 속성의 시퀀스로 변환하는 작업
  • 기계 번역을 예시로 든다면
    • 소스 언어의 토큰 시퀀스를 다른 언어의 토큰 시퀀스로 변환하는 과제
      • 예) 어제, 카페에, 갔었어, 거기, 사람, 많더라(소스 언어)
      • I, went, to, the, cafe, there, were, many, people, there(타깃 언어)
    • seq2seq는 소스와 타깃의 길이가 달라도 해당 과제를 수행하는 데 문제가 없음

 

인코더와 디코더

  • 트랜스포머는 seq2seq 과제 수행에 특화된 모델로써, 임의의 시퀀스를 해당 시퀀스와 속성이 다른 시퀀스로 변환하는 작업이라면 꼭 기계번역이 아니더라도 수행할 수 있음
    • 예) 필리핀 앞바다의 한 달 치 기온 데이터로 앞으로 1주일간 하루 단위로 태풍이 발생할지를 맞히는 과제 (기온의 시퀀스 -> 태풍 발생 여부의 시퀀스)

 

  • 인코더는 소스 시퀀스의 정보를 압축해 디코더로 보내는 역할
    • 인코더가 소스 시퀀스 정보를 압축하는 과정을 인코딩이라고 함
  • 디코더는 인코더가 보내 준 소스 시퀀스 정보를 받아서 타깃 시퀀스를 생성
    • 디코더가 타깃 시퀀스를 생성하는 과정을 디코딩 이라고 함

 

 

트랜스포머 블록

  • 위 이미지는 트랜스포머의 인코더의 반복되는 요소를 나타낸 것
    • 이런 구조를 블록 또는 레이어 라고 함
  • 트랜스포머의 인코더는 인코더 블록을 수십 개 쌓아서 구성
  • 인코더 블록의 구성 요소
    • 멀티 헤드 어텐션(multi-head attention)
    • 피드포워드 뉴럴 네트워크(feedforward neural network)
    • 잔차 연결 및 레이어 정규화

  • 디코더 블록도 인코더 블록과 본질적으로 다르지 않지만, 마스크를 적용한 멀티 헤드 어텐션과 인코더가 보내 온 정보와 디코더 입력을 함께 이용해 멀티 헤드 어텐션을 수행하는 모듈이 추가됨

 

셀프 어텐션(self attention)

  • 어텐션은 시퀀스 입력에 수행하는 기계학습 방법의 일종
    • 시퀀스 요소 가운데 중요한 요소에 집중하고, 그렇지 않은 요소는 무시해 태스크 수행 성능을 끌어 올림
    • 어텐션은 기계 번역 과제에 처음 도입되었음
      • 타깃 언어를 디코딩할 때 소스 언어의 단어 시퀀스 가운데 디코딩에 도움이 되는 단어 위주로 취사선택해서 번역 품질을 끌어올릴 수 있음
    • 즉, 어텐션은 디코딩할 때 소스 시퀀스 가운데 중요한 요소만 추림
  • 셀프 어텐션은 자신에게 수행하는 어텐션 기법
    • 입력 시퀀스 가운데 태스크 수행에 의미 있는 요소들 위주로 정보를 추출

 

특징 및 장점

 

  • 셀프어텐션은 자기 자신에 수행하는 어텐션
    • 위 이미지는 "거기"라는 단어가 어떤 의미를 가지는지 계산하는 상황
  • 잘 학습된 셀프 어텐션 모델이라면 "거기"에 대응하는 장소는 "카페" 라는 사실을 알아야 함
    • 추가로 "거기"는 "갔었어"와도 연관이 있음
  • 트랜스포머 인코더 블록 내부에서는 "거기"라는 단어를 인코딩할 때 카페, 갔었어 라는 단어의 의미를 강조해서 반영함
  • 셀프 어텐션 수행 대상은 입력 시퀀스 전체
    • 예) 어제 - 전체 입력 시퀀스, 카페 - 전체 입력 시퀀스, 갔었어 - 전체 입력 시퀀스 등 모두 어텐션 계산을 수행함
  • 개별 단어와 전체 입력 시퀀스를 대상으로 어텐션 계산을 수행해 문맥 전체를 고려하므로 지역적인 문맥만 보는 CNN보다 강점이 있음
  • 모든 경우의 수를 고려하기 때문에 시퀀스의 길이가 길어져도 정보를 잊거나 왜곡할 우려도 없으므로 RNN의 단점도 극복
  • 어텐션과 셀프 어텐션의 주요 차이
    • 어텐션은 소스 시퀀스 전체 단어들과 타깃 시퀀스 단어 하나 사이를 연결
    • 셀프 어텐션은 입력 시퀀스 전체 단어들 사이를 연결
    • 어텐션은 RNN 구조 위에서 동작하지만 셀프 어텐션은 RNN 없이 동작
    • 타깃 언어의 단어를 1개 생성할 때 어텐션은 1회 수행하지만, 셀프 어텐션은 인코더, 디코더 블록의 개수만큼 반복 수행

 

계산 예시

  • 셀프 어텐션은 쿼리(query), 키(key), 밸류(value) 3가지 요소가 서로 영향을 주고받는 구조
  • 각 단어 벡터는 블록 내에서 쿼리, 키, 밸류 3가지로 변환
    • 만약 트랜스포머 블록에 입력되는 문장이 6개 단어로 구성되어 있다면 이 블록 셀프 어텐션 계산 대상은 쿼리 벡터 6개, 키 벡터 6개, 밸류 벡터 6개로 모두 18개가 됨

  • 셀프 어텐션 모듈은 위와 같은 결과에 밸류 벡터들을 가중합 하는 방식으로 계산
    • Z카페 = 0.1 * V어제 + 0.1 * V카페 + ...... + 0.1 * V많더라
  • 모든 단어에 대해 셀프 어텐션을 각각 수행한 후 그 결과를 다음 블록으로 전달

 

셀프 어텐션 동작 원리

import torch
import numpy as np
from torch.nn.functional import softmax

 

(1) 쿼리, 키, 밸류 생성

# 입력 벡터 시퀀스 x
x = torch.tensor([
    [1, 0, 1, 0],
    [0, 2, 0, 2],
    [1, 1, 1, 1]
], dtype = torch.float64)
# 3개의 단어를 4개의 숫자로 표현

 

x
tensor([[1., 0., 1., 0.],
        [0., 2., 0., 2.],
        [1., 1., 1., 1.]], dtype=torch.float64)
  • 인코더에서 수행되는 셀프 어텐션의 입력은 이전 인코더 블록의 출력 벡터 시퀀스
  • 임베딩 차원 수가 4이고, 인코더에 입력된 단어 개수가 3일 경우 셀프 어텐션 입력은 위의 x와 같은 형태가 됨
    • 4차원짜리 단어 임베딩이 3개 모인 형태
  • 셀프 어텐션은 쿼리, 키, 밸류 3개 요소 사이의 문맥적 관계성을 추출하는 과정
    • Q = X * WQ
    • K = X * WK
    • V = X * WV
  • 위의 수식처럼 입력 벡터 시퀀스(X)에 쿼리, 키, 밸류를 만들어주는 행렬(WQ, WK, WV)을 각각 곱함
w_query = torch.tensor([
    [1, 0, 1],
    [1, 0, 0],
    [0, 0, 1],
    [0, 1, 1]
], dtype = torch.float64)

w_key = torch.tensor([
    [0, 0, 1],
    [1, 1, 0],
    [0, 1, 0],
    [1, 1, 0]
], dtype = torch.float64)

w_value = torch.tensor([
    [0, 2, 0],
    [0, 3, 0],
    [1, 0, 3],
    [1, 1, 0]
], dtype = torch.float64)

 

querys = torch.matmul(x, w_query)
querys
tensor([[1., 0., 2.],
        [2., 2., 2.],
        [2., 1., 3.]], dtype=torch.float64)

 

keys = torch.matmul(x, w_key)
keys
tensor([[0., 1., 1.],
        [4., 4., 0.],
        [2., 3., 1.]], dtype=torch.float64)

 

values = torch.matmul(x, w_value)
values
tensor([[1., 2., 3.],
        [2., 8., 0.],
        [2., 6., 3.]], dtype=torch.float64)
  • w_query, w_key, w_value 는 태스크를 가장 잘 수행하는 방향으로 학습됨

 

(2) 셀프 어텐션 출력값 계산

  • 셀프 어텐션의 정의

  • 쿼리와 키벡터에 전치를 취한 행렬을 행렬곱한 뒤, 해당 행렬의 모든 요솟값을 키 차원 수의 제곱근으로 나누고, 이 행렬을 행 단위로 소프트맥스를 취해 스코어 행렬로 만들고 이 스코어 해렬에 밸류를 행렬곱

 

keys.T
tensor([[0., 4., 2.],
        [1., 4., 3.],
        [1., 0., 1.]], dtype=torch.float64)

 

 

attn_scores = torch.matmul(querys, keys.T)
attn_scores
tensor([[ 2.,  4.,  4.],
        [ 4., 16., 12.],
        [ 4., 12., 10.]], dtype=torch.float64)
 
  • [2, 4, 4] 에서 2는 첫 번째 쿼리와 첫 번째 키 벡터 사이의 문맥적 관계성이 결합된 결과물
    • 두 번째 요솟값(4)은 첫 번째 쿼리 벡터와 두 번째 키 벡터의 문맥적 관계성이 포함돼 있음

 

keys.shape # (3개의 단어) (차원:하나의 단어를 3개의 숫자로 표현)
torch.Size([3, 3])

 

 

# 소프트맥스 확률값
key_dim_sqrt = np.sqrt(keys.shape[-1])

 

attn_probs = softmax(attn_scores / key_dim_sqrt, dim = 1) # dim: numpy의 axis

 

 

attn_probs
tensor([[1.3613e-01, 4.3194e-01, 4.3194e-01],
        [8.9045e-04, 9.0884e-01, 9.0267e-02],
        [7.4449e-03, 7.5471e-01, 2.3785e-01]], dtype=torch.float64)

 

# 소프트맥스 확률과 밸류를 가중함
weighted_values = torch.matmul(attn_probs, values)
weighted_values
tensor([[1.8639, 6.3194, 1.7042],
        [1.9991, 7.8141, 0.2735],
        [1.9926, 7.4796, 0.7359]], dtype=torch.float64)
  • 셀프 어텐션의 학습 대상은 쿼리, 키, 밸류를 만드는 가중치 행렬(WQ, WK, WV)임

 

멀티 헤드 어텐션(Multi-Head Attention)

  • 셀프 어텐션을 동시에 여러 번 수행

  • 위 이미지에서 입력 단어 수는 2개, 밸류의 차원은 3, 헤드는 8개인 멀티 헤드 어텐션을 나타냄
    • 개별 헤드의 셀프 어텐션 수행 결과는 "입력 단어 수 * 밸류 차원 수"
      • 위 이미지에서는 2 * 3 크기를 갖는 행렬
    • 8개 헤드의 셀프 어텐션 수행 결과를 이어 붙이면 2 * 24의 행렬이 됨
  • 멀티 헤드 어텐션은 개별 헤드의 셀프 어텐션 수행 결과를 이어 붙인 행렬에 WO를 행렬곱해서 마무리
    • WO의 크기는 "셀프 어텐션 수행 결과 행렬의 열 수 * 목표 차원 수"

 

 

GPT 구조

  • GPT는 트랜스포머에서 인코더를 제외하고 디코더만 사용
    • 인코더 쪽에서 보내오는 정보를 받는 모듈(멀티 헤드 어텐션)도 제거되어 있음
  • 입력 단어 시퀀스가 "어제 카페 갔었어 거기 사람 많더라" 이고, 이번이 카페를 맞혀야 되는 상황이라면, GPT는 "어제"라는 단어만 참고할 수 있음

 

BERT

  • 트랜스포머에서 디코더를 제외하고 인코더만 사용
  • 입력 단어 시퀀스가 "어제 카페 갔었어 [MASK] 사람 많더라"라면 마스크 토큰 앞뒤 문맥을 모두 참고할 수 있음
    • 앞뒤 정보를 준다고 해서 정답을 미리 알려주는 것이 아님

 

 

사전 학습 모델로 단어/문장을 벡터로 변환

파인튜닝

  • 사전학습을 마친 언어 모델 위에 작은 모듈을 조금 더 쌓아 태스크를 수행하는 구조
    • 사전학습을 마친 BERT와 그 위의 작은 모듈을 포함한 전체 모델을 다운스트림 데이터로 업데이트하는 과정을 파인튜닝 이라고 함
from transformers import BertTokenizer, BertConfig, BertModel
import torch

 

# 토크나이저 선언
https://huggingface.co/beomi/kcbert-base
tokenizer = BertTokenizer.from_pretrained(
    "beomi/kcbert-base",
    do_lower_case = False, #알파벳 소문자 변환을 하지않고 학습을 했으면 False
)
tokenizer_config.json:   0%|          | 0.00/49.0 [00:00<?, ?B/s]
vocab.txt:   0%|          | 0.00/250k [00:00<?, ?B/s]
config.json:   0%|          | 0.00/619 [00:00<?, ?B/s]

 

  • 모델 초기화
    • 사용대상 BERT 모델이 사전학습할 때 썼던 토크나이저를 그대로 사용해야 벡터 변환에 문제가 없음
    • 모델과 토크나이저의 토큰화 방식이 다르면 모델이 엉뚱한 결과를 출력
# BERT 모델을 사전학습할 때 설정했던 설정값
pretrained_model_config = BertConfig.from_pretrained(
    "beomi/kcbert-base"
)

# 위의 설정에 따라 모델 전체를 초기화한 뒤 미리 학습된 kcbert-base 체크포인트를 읽어들임
model = BertModel.from_pretrained(
    "beomi/kcbert-base",
    config = pretrained_model_config
)
model.safetensors:   0%|          | 0.00/438M [00:00<?, ?B/s]

 

 

pretrained_model_config
BertConfig {
  "architectures": [
    "BertForMaskedLM"
  ],
  "attention_probs_dropout_prob": 0.1,
  "classifier_dropout": null,
  "directionality": "bidi",
  "hidden_act": "gelu",
  "hidden_dropout_prob": 0.1,
  "hidden_size": 768,
  "initializer_range": 0.02,
  "intermediate_size": 3072,
  "layer_norm_eps": 1e-12,
  "max_position_embeddings": 300,
  "model_type": "bert",
  "num_attention_heads": 12,
  "num_hidden_layers": 12,
  "pad_token_id": 0,
  "pooler_fc_size": 768,
  "pooler_num_attention_heads": 12,
  "pooler_num_fc_layers": 3,
  "pooler_size_per_head": 128,
  "pooler_type": "first_token_transform",
  "position_embedding_type": "absolute",
  "transformers_version": "4.51.3",
  "type_vocab_size": 2,
  "use_cache": true,
  "vocab_size": 30000
}
 
# 입력값 만들기
sentences = ["안녕하세요", "하이"]

features = tokenizer(
    sentences,
    max_length = 10,
    padding = "max_length",
    truncation = True
)

 

features
{'input_ids': [[2, 19017, 8482, 3, 0, 0, 0, 0, 0, 0], [2, 15830, 3, 0, 0, 0, 0, 0, 0, 0]], 'token_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 0, 0, 0, 0, 0, 0], [1, 1, 1, 0, 0, 0, 0, 0, 0, 0]]}

 

  • input_ids 는 2개의 입력 문장에 대해 워드피스 토큰화를 수행한 뒤 이를 토큰 인덱스로 변환한 결과
# BERT 로 단어/문장 수준 벡터 구하기
features = {k : torch.tensor(v) for k, v in features.items()}

 

features
{'input_ids': tensor([[    2, 19017,  8482,     3,     0,     0,     0,     0,     0,     0],
         [    2, 15830,     3,     0,     0,     0,     0,     0,     0,     0]]),
 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]),
 'attention_mask': tensor([[1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
         [1, 1, 1, 0, 0, 0, 0, 0, 0, 0]])}

 

# 임베딩 계산
outputs = model(**features)

 

outputs
BaseModelOutputWithPoolingAndCrossAttentions(last_hidden_state=tensor([[[-0.6969, -0.8248,  1.7512,  ..., -0.3732,  0.7399,  1.1907],
         [-1.4803, -0.4398,  0.9444,  ..., -0.7405, -0.0211,  1.3064],
         [-1.4299, -0.5033, -0.2069,  ...,  0.1285, -0.2611,  1.6057],
         ...,
         [-1.4406,  0.3431,  1.4043,  ..., -0.0565,  0.8450, -0.2170],
         [-1.3625, -0.2404,  1.1757,  ...,  0.8876, -0.1054,  0.0734],
         [-1.4244,  0.1518,  1.2920,  ...,  0.0245,  0.7572,  0.0080]],

        [[ 0.7565, -1.7148,  2.1903,  ..., -0.4291,  0.9469,  0.7394],
         [ 1.1048, -1.3408,  1.9444,  ...,  0.3340,  0.2276, -0.5220],
         [-0.1316, -0.6982,  1.3086,  ...,  0.4448,  0.0836, -0.6280],
         ...,
         [ 0.0666, -0.7225,  1.8425,  ...,  1.2435,  1.6184,  0.1339],
         [ 0.1781, -0.7823,  1.6892,  ...,  1.1230,  1.5262,  0.1855],
         [-0.0183, -0.8602,  2.5182,  ...,  0.9201,  0.3697, -0.7420]]],
       grad_fn=<NativeLayerNormBackward0>), pooler_output=tensor([[-0.1594,  0.0547,  0.1101,  ...,  0.2684,  0.1596, -0.9828],
        [-0.9349,  0.1990, -0.0390,  ...,  0.5419,  0.0375, -0.9960]],
       grad_fn=<TanhBackward0>), hidden_states=None, past_key_values=None, attentions=None, cross_attentions=None)

 

 

# 문장 2개에 속한 각각의 토큰이 768차원의 벡터로 변환
# 단어 수준 벡터 시퀀스
outputs.last_hidden_state.shape
torch.Size([2, 10, 768])

 

# 문장 수준 벡터
outputs.pooler_output.shape
torch.Size([2, 768])
728x90

'09_DL(Deep_Learning)' 카테고리의 다른 글

34_XAI(설명 가능한 인공지능)  (2) 2025.05.11
33_bert_문서분류  (2) 2025.05.11
31_트랜스포머_토큰화  (3) 2025.05.11
30_LLM의 개념  (1) 2025.05.08
29_개체명 인식(양방향LSTM)  (1) 2025.05.08