반응형

시작하며, 알릴 부분이 있어 작성합니다. 그대로 따라하실 수 있게 전부 작성하면 좋겠지만 교재에서 사용되는 데이터를 가져오는 것까지 제가 적어드린다면 저자에게 혹시나 폐를 끼칠까봐 앞으로는 정확한 데이터 전체 또는 데이터의 주소를 기입하는 부분은 마스킹하거나 생략하도록 하겠습니다. 앞에 3개 단원 정도는 마치 맛보기 처럼 오히려 괜찮지 않나? 싶은 마음으로 수정은 안했습니다. 혹시 저자 당사자나 관련자 분들이 문제라고 느끼신다면 댓글이나 다른 채널로 연락주시면 바로 수정하겠습니다.

 

 

박해선님이 작성한 혼자공부하는 머신러닝 + 딥러닝 을 공부한 내용이 colab에도 있지만 복습차원에서 포스트하고 있습니다.

 

 

 

이번 단원에서는 물고기 중 하나를 찍었을때 그게 어떤 종일까? 분류하는 것으로 시작합니다. 데이터를 불러오겠습니다.

import pandas as pd
fish = pd.read_csv('데이터의 위치는 책을 구매하시면 보실 수 있어요!')
fish.head()
print(pd.unique(fish['Species']))

 



['Bream' 'Roach' 'Whitefish' 'Parkki' 'Perch' 'Pike' 'Smelt']

 

fish는 7종의 물고기의 특성이 들어 있는 데이터라는 것을 확인하실 수 있고, pandas 데이터를 sklearn라이브러리에서 사용할 수 있도록 numpy에서 지원하는 배열형태로 변경합니다.

fish_input = fish[['Weight', 'Length', 'Diagonal','Height', 'Width']].to_numpy()
print(fish_input[:5])
[[242. 25.4 30. 11.52 4.02 ]
[290. 26.3 31.2 12.48 4.3056]
[340. 26.5 31.1 12.3778 4.6961]
[363. 29. 33.5 12.73 4.4555]
[430. 29. 34. 12.444 5.134 ]]

 

이제 잘 정제된 데이터를 훈련세트와 테스트 세트로 나눈 뒤 표준화 후 K-최근접이웃 알고리즘으로 학습 후 훈련세트와 테스트 세트의 스코어(결정계수)를 측정해봅니다.

 

 

K-최근접이웃 알고리즘에서 3개 이웃을 기준으로 분류하여 임의의 물고기를 선택했을때 그것이 어떤물고기일지를 연산했더니, 1/3의 배수로만 결과가 나옵니다. => 추가설명드리면 근접한 물고기가 Bream, Parkki, Perch라고 가정하면 예측확률은 각각 1/3이 되는 형태인 것이죠. 위에 5개만 뽑아온 것을 보시면 처음 세 마리에는 가장가까운 3마리의 물고기가 모두 같은 종으로 3마리씩 있던 것이고 4번째는 Perch가 2마리, roach가 1마리가 있었다는 것입니다. 

 

 

이번에는 도미와 빙어만 걸러내겠습니다.

bream_smelt_indexes = (train_target == 'Bream') | (train_target == 'Smelt')
train_bream_smelt = train_scaled[bream_smelt_indexes]
target_bream_smelt = train_target[bream_smelt_indexes]

from sklearn.linear_model import LogisticRegression
lr = LogisticRegression()
lr.fit(train_bream_smelt, target_bream_smelt)

print(lr.predict(train_bream_smelt[:5]))
print(lr.classes_)
print(lr.predict_proba(train_bream_smelt[:5]))
['Bream' 'Smelt' 'Bream' 'Bream' 'Bream']
['Bream' 'Smelt']
[[0.99760007 0.00239993]
[0.02737325 0.97262675]
[0.99486386 0.00513614]
[0.98585047 0.01414953]
[0.99767419 0.00232581]]

결과값을 순서대로 해석하자면,

lr.predict(train_bream_smelt[:5])) => 도미와 빙어로만 이루어진 데이터 내에서 5개를 뽑았을때 순서대로 도미, 빙어, 도미, 도미, 도미라고 예측을 했습니다.

 

lr.classes => target 데이터에는 bream과 smelt가 있고 bream이 음성클래스, smelt가 양성클래스라는 의미예요. (순서에 따른 의미가 있습니다.)

 

lr.predict_proba(train_bream_smelt[:5]) => 5마리에 대해서 [Bream일 확률, smelt일 확률] 배열을 가진 이중배열을 연산합니다. 더높은 쪽이 가장 앞선 결과에서 나온 값이 된 것입니다.

 

 

lr.decision_function 함수로 z값을 직접 구하고 scipy.special의 expit를 통해 시그모이드 함수를 통과시켜 양성클래스의 확률과 동일한 결과를 도출해낼 수도 있습니다.

 

 

두 개 내에서만 예측을 한 것이고, 이제 여러개에서 분류를 수행해봅니다.

# 로지스틱 화귀로 다중 분류 수행하기
lr = LogisticRegression(C=20, max_iter=1000)
lr.fit(train_scaled, train_target)
print(lr.score(train_scaled, train_target))
print(lr.score(test_scaled, test_target))
# 0.9327731092436975
# 0.925
# 결과가 과대나 과소적합이 아니고, 결정계수가 아주 높지는 않지만 꽤 괜찮습니다.

print(lr.predict(test_scaled[:5]))
# ['Perch' 'Smelt' 'Pike' 'Roach' 'Perch'] # 가장 앞 5개 데이터에 대한 예측결과입니다.

proba = lr.predict_proba(test_scaled[:5])
print(np.round(proba, decimals=3))
# 처음 5개에 대한 예측확률을 소수점 3자리 까지만 표현한 결과입니다.
# [[0.    0.014 0.842 0.    0.135 0.007 0.003]
# [0.    0.003 0.044 0.    0.007 0.946 0.   ]
# [0.    0.    0.034 0.934 0.015 0.016 0.   ]
# [0.011 0.034 0.305 0.006 0.567 0.    0.076]
# [0.    0.    0.904 0.002 0.089 0.002 0.001]]

print(lr.classes_)
print(lr.coef_.shape, lr.intercept_.shape)
# ['Bream' 'Parkki' 'Perch' 'Pike' 'Roach' 'Smelt' 'Whitefish']
# (7, 5) (7,)
# 7종류에 대해 각각 5가지 특성이 담겨 있어 변수가 5개인 방정식이 7개가 나옵니다.

decision = lr.decision_function(test_scaled[:5])
print(np.round(decision, decimals=2))
 
from scipy.special import softmax
proba = softmax(decision, axis=1)
print(np.round(proba, decimals=3))
# z값을 구하고,
# [[ -6.51   1.04   5.17  -2.76   3.34   0.35  -0.63]
#  [-10.88   1.94   4.78  -2.42   2.99   7.84  -4.25]
#  [ -4.34  -6.24   3.17   6.48   2.36   2.43  -3.87]
#  [ -0.69   0.45   2.64  -1.21   3.26  -5.7    1.26]
#  [ -6.4   -1.99   5.82  -0.13   3.5   -0.09  -0.7 ]]

# 각 z값에 대해서 시그모이드 함수에 multi로 각각 적용하는 softmax함수를 이용하여 확률을 구합니다.
# 아까 np.round(proba, decimals=3)로 구했던 확률과 동일함을 알 수 있습니다.
# [[0.    0.014 0.842 0.    0.135 0.007 0.003]
#  [0.    0.003 0.044 0.    0.007 0.946 0.   ]
#  [0.    0.    0.034 0.934 0.015 0.016 0.   ]
#  [0.011 0.034 0.305 0.006 0.567 0.    0.076]
#  [0.    0.    0.904 0.002 0.089 0.002 0.001]]

 

 

반응형
반응형

RAG의 개념과 등장

- LLM 고유의 한계 극복: 사전 학습된 언어모델은 학습 시점 이후의 최신 정보 부재, 오픈소스/클라우드 LLM 통틀어서 인터넷에 개방되어 있지 않은 폐쇄 정보 접근 불가

- 모델 사후학습이나 재학습 없이 방대한 데이터와 지식 베이스 결합

 

 

 

최근까지의 LLM 조차도 이전에 학습하거나 사용자와 질의한 정보를 특히 대화내용 삭제 시에는 메모리 컴팩트 과정에서 망각(데이터 손실) 될 수 있습니다. 그런데 만약에 오픈북 테스트처럼, 항상 찾아볼 수 있는 데이터가 있다면?

 

 

[RAG end-to-end pipeline]

 

A. rag 데이터 준비(오프라인 파이프라인)

  1. 문서 준비
  2. 문서 parse
  3. content chunk
  4. chunk별 문서 관련 검색에 유의미한 메터데이터(원본페이지, 작성 또는 수정 일자, 권한 등) 태깅
  5. chunk 를 embedding
  6. vector DB 적재

 

 

B.  AI를 통해 검색하기(온라인 파이프라인)

  1. 사용자 질문에 따른 검색용 query 생성
  2. query를 vectorize
  3. query vector로 유사도 검색
  4. 백터 유사도에 따른 Top-K 인출
  5. (검색결과에서 유사도 순위상 부정확할 경우) rerank
  6. LLM이 검색결과를 정리하여 사용자에 반환

 

 

*기본원칙: 데이터를 임베딩하는 모델과 query를 임베딩하는 모델이 일치해야 합니다. 모델이 달라지면 차원에 따른 의미가 달라지기 때문에 서로 연관관계가 없기 때문입니다.

 

 

지식 데이터의 내용이 문맥상으로 흡사해보이지만 세부적인 의미가 다른 경우 질의 결과가 예상과 다를 수 있는데 이때에는  FineTuning 고려

여기에서 FineTuning은 초기 검색결과 자체가 원하는 바와 상이한 경우임베딩 모델, 대체적으로 검색결과 top-k 안에 원하는 방향의 지식이 있는 경우 -> rerank 모델에 고려하는게 좋습니다. 

 

 

 

기초 본문이라서 rag를 잘 모르시는 분들이 들어오셨을 것 같아요. 이런질문들이 떠오르실 수 있습니다. 예상 답변 또는 안내 드립니다.

1. parse가 뭐지?

여기서 문서를 parsing한다는 것은 문서파일의 형식(확장자)에 따라 문서 내용을 추출하여 markdown 등의 형식으로 변환하는 과정을 말합니다.

 

2. chunk가 뭐지?

의미기반 검색을 하기 위해서 의미를 가진 단락 또는 문장의 집합으로 나눈 조각을 뜻합니다. chunk의 크기가 너무 작으면 의미가 담기기 어렵고 너무 많으면 여러 의미가 담기기 때문에 검색에 적합한 크기로 쪼개는 것이 중요해요.

 

3. embedding / vectorize 가 뭐지?

rag에서 embedding은 vector db에 저장, 검색을 할 수 있도록 데이터를 백터로 만드는 과정을 뜻합니다. vectorize는 백터화라는 의미의 영어단어로 동일한 의미로 사용했습니다. 임베딩할때에는 임베딩 모델이 필요합니다.

 

4. vector는 뭐지?

벡터의 정의는 크기와 방향을 가진 값입니다. 데이터가 가진 의미를 방향으로 표현했다고 생각해보세요. 질문이 임베딩된 벡터와 vector db 적재된 벡터 중 비슷한 방향을 찾는 과정이 rag에서의 검색이다 라고 간단히 이해하셔도 무방하겠습니다.

 

5. rerank가 뭐지?

영어의 뜻 그대로 순위를 다시 매기는 과정을 뜻합니다.

 

embedding과 rerank에는 임베딩 모델, rerank 모델이 필요합니다. 자세한 내용은 이미 기초를 넘어갈지도 모르기 때문에 여기서 포스트는 마칩니다.

 

 

추후 포스팅에는 아마도 vector  DB, embedding 모델, rerank 모델, 간단한 파이프라인 개발에 대해서 다뤄보겠습니다.

 

 

 

 

 

 

 

 

반응형
반응형

 

 

가성비 장비로 핫한 NVIDIA의 DGX Spark에 모델을 구축해보겠습니다. NVIDIA 공식 홈페이지에는 DGX Spark 단일 또는 병렬 연결 구축 시 최적화 Deployment Guide를 제공하는데 여기서는 특히 single DGX Spark 대상 내용을 참조하여 작성했습니다. 바로 시작할게요.

 

 

DGX Spark가 이미 있으시다는 가정하기 때문에 없으신 분들은 모델을 하드웨어 장비에 맞게 변경하시기 바랍니다. 성능이 더 좋은 장비는 이대로 진행하셔도 무방은 하며, 성능이 더 약한 장비에는 파라미터 수가 더 작은 모델로 변경하셔야할 수도 있습니다. 최대 메모리의 0.75까지는 서빙이 됐는데 0.65로 변경했을때 서빙에 실패했습니다. 128GB의 0.75니까 최소한 통합메모리 90GB이상이어야 이 모델 구축이 될 것으로 판단하고 있습니다.

 

DGX Spark Deployment Guide를 보면, single DGX Spark 선에서 지원하는 가장 큰 모델로 Nemotron-3-Super 모델이 있어 이 모델을 서빙해보도록 하겠습니다.

 

 

 

1. putty를 통해 장비에 접속합니다.

 

 

2. docker 명령어를 통해 vllm을 설치합니다. 용량이 커서 시간이 꽤 걸립니다.

docker pull vllm/vllm-openai:cu130-nightly

 

 

3. wget 명령어를 통해 vllm parser 파일을 내려 받습니다.

wget https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4/raw/main/super_v3_reasoning_parser.py

 

 

4. huggingface 공식 홈페이지( https://huggingface.co/ )에 진입해서 access token을 생성합니다. 회원가입이 필요합니다.

 

 

Hugging Face – The AI community building the future.

We’re on a journey to advance and democratize artificial intelligence through open source and open science.

huggingface.co

 

4-1. access token 생성 방법 - 회원가입까지 다루지는 않겠습니다.

 

우측 상단의 아이콘 클릭 ->  나온 메뉴 중에서 하단 부에 가깝게 [Access Tokens]를 클릭

우측에 [+ Create new token] 버튼을 클릭 -> 이름을 작성합니다. 허깅페이스에서 어디에 쓸지 정도 확인하는 이름입니다. 목적에 맞게 작명하시고 권한은 내려만 받는 용도이기 때문에 read only로 주셔도 무방합니다. ->  하단에 [Create new token] 버튼 클릭

 

다음화면에 나온 창에서 토큰이 출력되며, 한 번 닫으면 다시 볼 수 없으니 저장해둡니다. 혹시 까먹었다면 다시 발급하세요

 

 

5. DGX Spark의 리눅스 터미널 화면에서 방금 발급받은 토큰을 환경변수로 추가합니다.

export  HF_TOKEN="여기에 huggingface에서 발급한 토큰 기입!!"

 

 

6. docker 실행명령어를 칩니다. 아래 명령어에는 이름지정이 없어서 임의의 글자로 생길 수 있는데 docker 명령어를 다루기 편하도록   --name nemotron3-120B \ 또는 더 쉽게 줄여서 --name nemo \ 정도를 추가하시면 docker 명령어를 칠때 짧게 기입하실 수 있겠어요.

 

추가로, 백그라운드에서 가동되도록하고 다른작업을 해보실 분들은 --rm -it를 빼고 -d를 추가하세요

(ex) 첫줄을 docker run -d --gpus all \ 로 시작

docker run --rm -it --gpus all \
  -e VLLM_NVFP4_GEMM_BACKEND=marlin \
  -e VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 \
  -e VLLM_FLASHINFER_ALLREDUCE_BACKEND=trtllm \
  -e VLLM_USE_FLASHINFER_MOE_FP4=0 \
  -e HF_TOKEN=$HF_TOKEN \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  -v $(pwd)/super_v3_reasoning_parser.py:/app/super_v3_reasoning_parser.py \
  -p 8000:8000 \
  vllm/vllm-openai:cu130-nightly \
    --model nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4 \
    --served-model-name nemotron-3-super \
    --host 0.0.0.0 \
    --port 8000 \
    --async-scheduling \
    --dtype auto \
    --kv-cache-dtype fp8 \
    --tensor-parallel-size 1 \
    --pipeline-parallel-size 1 \
    --data-parallel-size 1 \
    --trust-remote-code \
    --gpu-memory-utilization 0.90 \
    --enable-chunked-prefill \
    --max-num-seqs 4 \
    --max-model-len 1000000 \
    --moe-backend marlin \
    --mamba_ssm_cache_dtype float32 \
    --quantization fp4 \
    --speculative_config '{"method":"mtp","num_speculative_tokens":3,"moe_backend":"triton"}' \
    --reasoning-parser-plugin /app/super_v3_reasoning_parser.py \
    --reasoning-parser super_v3 \
    --enable-auto-tool-choice \
    --tool-call-parser qwen3_coder

 

 

그대로 치신분들은 실행과정에 남는 로그를 확인하실 수 있겠고 docker run -d --gpus all \ 로 변경하신 분들은 아래 명령어를 치시면 진행과정이 나옵니다. 아까 nemo로 하신 분들은 docker logs -f nemo라고 치시면 됩니다.

docker logs -f <이미지명>

 

꽤 오래걸리지만 구동이 됐죠?

 

 

 

자 이제 네트워크에 연결된 다른 PC에서 open code로 접근 해봅니다. open-webui를 특정 경로로 클론해달라고 했습니다. cli로 개발에 쓸 수도 있다는 것을 알았습니다. 클라우드 모델들보다는 조금 떨어질 수 있지만 기존에 ollama로 저사양 pc에 가동했던 12B나 한자리B모델에 비하면 천국이 따로 없습니다. 

 

 

 

다음은 웹 형식의 접근을 해보겠습니다. open webui를 이용하는데, docker로 실행하셔도 무방합니다. 저는 위에서 opencode를 통해 내려받았기 떄문에 소스 자체를 받은 김에 debug mode로 바로 실행해서 연결해보겠습니다.

 

 

서버 로그를 켜놓은 상태로 진행해서 로그를 캡처해봤습니다. 토큰 생성속도가 20~40tok/s 나올정도로 준수합니다.

 

 

 

맺으며,

 

open code와 open web ui에서 로컬LLM 설정방법은 간단하게 다음 글에서 다뤄 보겠습니다.

 

[참고한 문서]

https://github.com/NVIDIA-NeMo/nemotron/tree/main/usage-cookbook/Nemotron-3-Super/SparkDeploymentGuide

https://docs.nvidia.com/nemotron/nightly/deployment-guides.html

 

Deployment Guides — Nemotron

Cookbooks for the 550B/55B-active hybrid Mamba-Transformer MoE model, including vLLM, SGLang, TensorRT-LLM, LoRA Text2SQL, RL, and agent harness setup.

docs.nvidia.com

 

반응형
반응형

 

다중회귀에서는 물고기의 길이 뿐 아니라 높이와 두께도 이용합니다.  데이터를 가져와서 훈련/테스트 세트로 나눕니다. 그리고 여러 개의 특성을 활용하는 다중 회귀 알고리즘이 담긴 PolynomialFeatures를 사용합니다. 선형 회귀에서 2차까지 사용하는 것이 결정계수가 1에 가까웠기 때문에 다중이면서 2차항까지 사용하도록 학습합니다.

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PolynomialFeatures


df = pd.read_csv('https://bit.ly/perch_csv_data')
perch_full = df.to_numpy()
perch_weight = np.array(
    [5.9, 32.0, 40.0, 51.5, 70.0, 100.0, 78.0, 80.0, 85.0, 85.0, 110.0,
       115.0, 125.0, 130.0, 120.0, 120.0, 130.0, 135.0, 110.0, 130.0,
       150.0, 145.0, 150.0, 170.0, 225.0, 145.0, 188.0, 180.0, 197.0,
       218.0, 300.0, 260.0, 265.0, 250.0, 250.0, 300.0, 320.0, 514.0,
       556.0, 840.0, 685.0, 700.0, 700.0, 690.0, 900.0, 650.0, 820.0,
       850.0, 900.0, 1015.0, 820.0, 1100.0, 1000.0, 1100.0, 1000.0,
       1000.0]
)


train_input, test_input, train_target, test_target = train_test_split(perch_full, perch_weight, random_state=42)



poly = PolynomialFeatures()
poly.fit([[2,3]])
print(poly.transform([[2,3]]))

 

print결과: [[1. 2. 3. 4. 6. 9.]]

 

2,3으로 기입하는 경우 1,2,3, 2*2, 2*3, 3*3 으로 이루어집니다. 1은 절편항(사이킷런 모델은 자동으로 절편항을 제거한다고 합니다.)

2,3,2의 제곱과 3의 제곱은 각 변수의 선형, 2차형 영향력이고 2*3은 두 변수가 서로 영향이 있을때(독립이 아닌관계)에 대한 계수를 측정하기 위함입니다. 

 

이것은 get_feature_names_out()함수로 좀더 직관적으로 알 수 있습니다.

poly = PolynomialFeatures(include_bias=False)
poly.fit(train_input)
train_poly = poly.transform(train_input)
poly.get_feature_names_out()
# => 결과: array(['x0', 'x1', 'x2', 'x0^2', 'x0 x1', 'x0 x2', 'x1^2', 'x1 x2','x2^2'], dtype=object)

 

스코어 

from sklearn.linear_model import LinearRegression

lr = LinearRegression()
lr.fit(train_poly, train_target)
print(lr.score(train_poly, train_target))


test_poly = poly.transform(test_input)
print(lr.score(test_poly, test_target))
0.9903183436982125
0.9714559911594111

 

다중, 2차항으로 결과가 엄청나게 좋아졌습니다. 여기서 차수를 더 높이면 더 정확해지지 않을까 하여 5차까지 올려봅니다.

poly = PolynomialFeatures(degree=5, include_bias=False)
poly.fit(train_input)
train_poly = poly.transform(train_input)
test_poly = poly.transform(test_input)

print(train_poly.shape)
poly.get_feature_names_out()

 

결과에서는 무려 55개의 항이 생겼음을 알 수 있습니다.

(42, 55)
array(['x0', 'x1', 'x2', 'x0^2', 'x0 x1', 'x0 x2', 'x1^2', 'x1 x2', 'x2^2', 'x0^3', 'x0^2 x1', 'x0^2 x2', 'x0 x1^2', 'x0 x1 x2', 'x0 x2^2', 'x1^3', 'x1^2 x2', 'x1 x2^2', 'x2^3', 'x0^4', 'x0^3 x1', 'x0^3 x2', 'x0^2 x1^2', 'x0^2 x1 x2', 'x0^2 x2^2', 'x0 x1^3', 'x0 x1^2 x2', 'x0 x1 x2^2', 'x0 x2^3', 'x1^4', 'x1^3 x2', 'x1^2 x2^2', 'x1 x2^3', 'x2^4', 'x0^5', 'x0^4 x1', 'x0^4 x2', 'x0^3 x1^2', 'x0^3 x1 x2', 'x0^3 x2^2', 'x0^2 x1^3', 'x0^2 x1^2 x2', 'x0^2 x1 x2^2', 'x0^2 x2^3', 'x0 x1^4', 'x0 x1^3 x2', 'x0 x1^2 x2^2', 'x0 x1 x2^3', 'x0 x2^4', 'x1^5', 'x1^4 x2', 'x1^3 x2^2', 'x1^2 x2^3', 'x1 x2^4', 'x2^5'], dtype=object)

이것의 스코어를 연산했더니 훈련 0.999999..., 테스트 -144.4를 기록했습니다.

degree(최대 차수)를 높혔더니 오히려 과대적합이 되어 훈련에 사용된 데이터가 아니면 오히려 결과가 전혀 믿을 수 없는 수준에 이르렀습니다.

 

그러면 차수를 높이면서도 필요한 항만 사용할 수는 없을까? 릿지와 라쏘는 규제가 적용됩니다. 이 교재에서는 계수를 제곱한 값을 기준으로 규제한 릿지, 절대값을 기준으로 규제한 라쏘라는데 어떻게 규제했다는지 설명이 없었습니다. 솔직히 저자도 이 내용을 잘 모르는 구나... 라는 생각이 절로 들었습니다만, 제가 이해가 부족한 것일 겁니다. 이 포스트 작성 후 알아보러 갈 예정입니다만, 일단 설명을 마치고 가겠습니다.

 

릿지와 라쏘를 사용하려면 데이터의 정규화가 필요합니다. StandardScaler를 이용합니다.

from sklearn.preprocessing import StandardScaler
ss = StandardScaler()
ss.fit(train_poly)
train_scaled = ss.transform(train_poly)
test_scaled = ss.transform(test_poly)

 

릿지 학습 후 스코어

from sklearn.linear_model import Ridge
ridge = Ridge()
ridge.fit(train_scaled, train_target)
print(ridge.score(train_scaled, train_target))
print(ridge.score(test_scaled, test_target))

# 0.9896101671037343
# 0.9790693977615387
from sklearn.linear_model import Lasso
lasso = Lasso()
lasso.fit(train_scaled, train_target)
print(lasso.score(train_scaled, train_target))
print(lasso.score(test_scaled, test_target))

# 0.989789897208096
# 0.9800593698421883

 

둘다 아주 정확합니다. 

 

 

릿지 회귀와 라쏘 회귀의 주요 차이를 남깁니다.

릿지 회귀는 의미가 떨어지는 변수의 가중치를 0에 가깝게 줄이긴하나 0은 되지 않습니다.

라쏘 회귀는 의미가 떨어지는 변수의 가중치를 아예 0으로 만들어 버립니다. 위 예제에서도 55개의 coefficient 중 약 40 여개가 0이 되었습니다.

 

릿지는 특성이 많고 다중공선성(여러 특성이 서로 연관관계가 있는 경우)이 있을때 주로 사용합니다.

라쏘는 영향력있는 중요한 특성(가중치가 매우 큰 변수)이 일부분 뿐이어서 중요 특성 선택이 필요한 경우 사용합니다.

반응형
반응형

선형 회귀란 데이터의 분포가 선형의 구조를 가진다는 전제하에 데이터를 예측하는 회귀 알고리즘입니다.
 
 
03-1에서 썼던 물고기 데이터를 가져와서 훈련세트와 테스트 세트로 정제하고 회귀 알고리즘을 사용할 수 있도록 2차원배열로 변경합니다.

from sklearn.model_selection import train_test_split
import numpy as np


perch_length = np.array([8.4, 13.7, 15.0, 16.2, 17.4, 18.0, 18.7, 19.0, 19.6, 20.0, 21.0,
       21.0, 21.0, 21.3, 22.0, 22.0, 22.0, 22.0, 22.0, 22.5, 22.5, 22.7,
       23.0, 23.5, 24.0, 24.0, 24.6, 25.0, 25.6, 26.5, 27.3, 27.5, 27.5,
       27.5, 28.0, 28.7, 30.0, 32.8, 34.5, 35.0, 36.5, 36.0, 37.0, 37.0,
       39.0, 39.0, 39.0, 40.0, 40.0, 40.0, 40.0, 42.0, 43.0, 43.0, 43.5,
       44.0])
perch_weight = np.array([5.9, 32.0, 40.0, 51.5, 70.0, 100.0, 78.0, 80.0, 85.0, 85.0, 110.0,
       115.0, 125.0, 130.0, 120.0, 120.0, 130.0, 135.0, 110.0, 130.0,
       150.0, 145.0, 150.0, 170.0, 225.0, 145.0, 188.0, 180.0, 197.0,
       218.0, 300.0, 260.0, 265.0, 250.0, 250.0, 300.0, 320.0, 514.0,
       556.0, 840.0, 685.0, 700.0, 700.0, 690.0, 900.0, 650.0, 820.0,
       850.0, 900.0, 1015.0, 820.0, 1100.0, 1000.0, 1100.0, 1000.0,
       1000.0])
       
       


train_input, test_input, train_target, test_target = train_test_split(perch_length, perch_weight, random_state=42)


train_input = train_input.reshape(-1,1)
test_input = test_input.reshape(-1,1)

 
그리고 선형 회귀를 이용합니다.  함수이름은 영어 그대로 선형(Linear) 회귀(Regression)이 되겠습니다.

from sklearn.linear_model import LinearRegression
lr = LinearRegression()
lr.fit(train_input, train_target)

print(lr.predict([[50]]))
print(lr.coef_, lr.intercept_)

 
결과:

[1241.83860323]
[39.01714496] -709.0186449535477

 
예측값은 약 1241로 03-1에서 k최근접이웃회귀로 예측했을때 보다 조금더 믿음직한 값이 나왔습니다.
lr.coef_와 lr.intercept_는 각각 coefficient는 1차 함수에서는 기울기, 2차 이상에서는 각 변수의 계수가 되겠습니다. intercept는 y절편을 의미합니다.
lr.coef_는 2차 이상에서 배열로 표현됩니다.
 
여기서는 선형(1차)로 ax + b에서 a가 39.017... , b가 -709.018... 라고 이해하시면 되겠고. 이형태에 기반해서 임의의 x가 15, 50일때 두점을 이어서 선형을 산점도에 표현했습니다.

plt.scatter(train_input, train_target)
plt.plot([15,50], [15*lr.coef_+lr.intercept_, 50*lr.coef_+lr.intercept_])
plt.scatter(50, lr.predict([[50]]), marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

주황색 점이 예측 지점입니다.
 
03-1에서 진행했던 스코어를 연산해보겠습니다.

print(lr.score(train_input, train_target))
print(lr.score(test_input, test_target))


# print의 두 결과. 
# 0.939846333997604
# 0.8247503123313558

=> 결정계수를 보니 train세트 스코어도 높지 않고 test 세트의 스코어가 너무 낮음: 과소 적합
 
위 산점도를 봐도 약간 선형보다는 2차 처럼 곡선형태로 증가하는 느낌이 있습니다. 따라서 선형이 가정된 선형 회귀 자체가 적합하지 않기 때문에 과소적합의 결정계수를 보이는 것으로 보여, 2차형으로 예측을 시도합니다. 2차형 알고리즘을 따로 쓰는 것은 아니고 기존 데이터를 제곱한 데이터를 추가 정제하여 선형 회귀 알고리즘으로 예측합니다.
 

# 위의 scatter plot과 예측 선형 방정식을 봤을때 우리가 직관적으로 예측할 수 있는 모형과는 조금 상이 함.
# 변수가 1개 밖에 없는 1차 선형이 아니라 n차 다항이라면?
# 2차로 시작 예측


# 기존 데이터들의 제곱을 앞에 붙인 데이터로 정제
train_poly = np.column_stack((train_input ** 2, train_input))
test_poly = np.column_stack((test_input ** 2, test_input))
print(train_poly.shape, test_poly.shape)

lr = LinearRegression()
lr.fit(train_poly, train_target)
print(lr.predict([[50**2, 50]]))
print(lr.coef_, lr.intercept_)

 

(42, 2) (14, 2)

[1573.98423528]
[ 1.01433211 -21.55792498] 116.0502107827827

 
제곱으로 예측하니 약 1574로 분포와 뭔가 맞아 떨어져 보이고, 정수부분만 썼을때 1*x^2 - 21*x + 116으로 볼 수 있겠네요.
 
scatter plot을 이용해서 시각적으로 확인해보면

point = np.arange(15, 50)
plt.scatter(train_input, train_target)

plt.plot(point, lr.coef_[0]*point**2 + lr.coef_[1]*point + lr.intercept_)
plt.scatter(50, lr.predict([[50**2, 50]]), marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

 
그렇다면 스코어는?

print(lr.score(train_poly, train_target))
print(lr.score(test_poly, test_target))

 
결과: 

0.9706807451768623
0.9775935108325122

훈련과 테스트 세트 모두 상당히 높지만 여전히 테스트세트가 더 높네요.
 
 

반응형
반응형

본 포스트 이 전에 [머신러닝]이 붙은 것은 혼자 공부하는 머신러닝 + 딥러닝이라는 교재의 단원별 정리 자료입니다. 책이름에 맞춰서 혼공머신으로 변경하였습니다. tensorflow보다는 요즘 pytorch를 많이 쓰기 때문에 정리 자체는 tensorflow 단원 전까지만 하려고 합니다. 
 
 
 
최근접 이웃 회귀란 데이터 중 가장 가까운 n개의 이웃의 데이터를 평균 내서 예측하는 학습 알고리즘 입니다. 예제에서는 물고기의 길이를 가지고 무게를 예측하도록 학습시킵니다.

import numpy as np


perch_length = np.array([8.4, 13.7, 15.0, 16.2, 17.4, 18.0, 18.7, 19.0, 19.6, 20.0, 21.0,
       21.0, 21.0, 21.3, 22.0, 22.0, 22.0, 22.0, 22.0, 22.5, 22.5, 22.7,
       23.0, 23.5, 24.0, 24.0, 24.6, 25.0, 25.6, 26.5, 27.3, 27.5, 27.5,
       27.5, 28.0, 28.7, 30.0, 32.8, 34.5, 35.0, 36.5, 36.0, 37.0, 37.0,
       39.0, 39.0, 39.0, 40.0, 40.0, 40.0, 40.0, 42.0, 43.0, 43.0, 43.5,
       44.0])
perch_weight = np.array([5.9, 32.0, 40.0, 51.5, 70.0, 100.0, 78.0, 80.0, 85.0, 85.0, 110.0,
       115.0, 125.0, 130.0, 120.0, 120.0, 130.0, 135.0, 110.0, 130.0,
       150.0, 145.0, 150.0, 170.0, 225.0, 145.0, 188.0, 180.0, 197.0,
       218.0, 300.0, 260.0, 265.0, 250.0, 250.0, 300.0, 320.0, 514.0,
       556.0, 840.0, 685.0, 700.0, 700.0, 690.0, 900.0, 650.0, 820.0,
       850.0, 900.0, 1015.0, 820.0, 1100.0, 1000.0, 1100.0, 1000.0,
       1000.0])


import matplotlib.pyplot as plt
plt.scatter(perch_length, perch_weight)
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

 
우선 1차원 배열 데이터를 학습이 가능하도록 2차원으로 변경하겠습니다.
 

from sklearn.model_selection import train_test_split
train_input, test_input, train_target, test_target = train_test_split(perch_length, perch_weight, random_state=42)

train_input = train_input.reshape(-1,1)
test_input = test_input.reshape(-1,1)

 
 
 
K-최근접 이웃 회귀 알고리즘이 담긴 사이킷런의 이웃라이브러리를 불러와서 그 중 KNeighborsRegressor 를 사용합니다. 이름만봐도 K이웃회귀분석기 이죠.

from sklearn.neighbors import KNeighborsRegressor

knr = KNeighborsRegressor()

knr.fit(train_input, train_target)
print(knr.score(test_input, test_target))

결과: 0.992809406101064
 
사이킷런의 학습알고리즘에서 제공하는 score함수는 결정계수를 연산해줍니다. 결정계수는 통계 전공이 아니시라면 정답예측률 정도의 뉘앙스로 이해하고 계셔도 무방합니다.
 

0.99면 99%급 정확도아닌가요? 그렇다면 매우 정확하네요.

=> 우선 학습시켰던 데이터로도 결정계수를 구해보겠습니다.
 

print(knr.score(train_input, train_target))

결과: 0.9698823289099254
 

오잉? 어떻게 학습시킨 데이터보다도 테스트데이터가 더 정확하게 예측되죠? 뭔가 이상한데요.

 
=> 네 맞습니다. 훈련세트가 테스트세트보다 스코어가 낮은데, 이 경우와 둘다 낮은 경우를 과소 적합이라고 합니다.
반대로 훈련 세트가 더 높긴하지만 테스트 세트가 너무 낮다면 이를 과대 적합이라고 합니다.
 
 
이 교재에서는 데이터 갯수가 적고, 더 늘리기 어렵기 때문에 K-최근접 이웃 회귀분석기에서 가장 가까운 이웃을 몇 개로 둘지에 대한 설정을 낮춥니다. 참고로 기본값은 5고, 3으로 낮춥니다.

knr.n_neighbors = 3

knr.fit(train_input, train_target)
print(knr.score(train_input, train_target))
print(knr.score(test_input, test_target))

# print()의 각 결과
# 0.9804899950518966
# 0.9746459963987609

=> 이제는 훈련세트의 결정계수가 더 높아지고(과소적합 탈출) 훈련세트보다는 테스트세트가 조금 더 낮지만 유사해짐 -> 과대 적합이 아님
 
 
이제 길이가 50cm인 물고기의 무게를 예측해보겠습니다.

from sklearn.model_selection import train_test_split

train_input, test_input, train_target, test_target = train_test_split(perch_length, perch_weight, random_state=42)


train_input = train_input.reshape(-1,1)
test_input = test_input.reshape(-1,1)

from sklearn.neighbors import KNeighborsRegressor

knr = KNeighborsRegressor(n_neighbors=3)

knr.fit(train_input, train_target)
print(knr.predict([[50]]))

결과: [1033.33333333]
 
뭔가 이상합니다. 본문 가장 서두의 scatter plot을 보면 40과 45cm 사이에 1000 조금 넘는 점들이 분포하고있었는데 길이는 꽤 커진 반면 예측 무게는 오히려 여태나온 가장 무거운 물고기보다 가벼워 보입니다.
 
100cm인 물고기도 같이 예측해볼까요?

import matplotlib.pyplot as plt

for sample in [50, 100]:
  distance, indexes = knr.kneighbors([[sample]])
  print(distance, "\n", indexes)


  plt.scatter(train_input, train_target)
  plt.scatter(train_input[indexes], train_target[indexes], marker='D')
  plt.scatter(sample, 1033, marker='^')
  plt.xlabel('length')
  plt.ylabel('weight')
  plt.show()
  print(np.mean(train_target[indexes]))
  # 100cm 물고기 예측 값은 [1033.3333333333333]
  
  
  # 50, 100 농어의 무게 예측이 똑같음
50cm물고기 예측 점을 포함한 산점도
100cm 물고기 예측 점을 포함한 산점도

아래 두 배열 쌍은, 각각 최근접 이웃으로 결정된 세 점과의 각거리, 그리고 최 근접 이웃 점의 인덱스 입니다.(가장 첫 데이터 배열에서 아래 인덱스 위치의 length를 찾으면 위 산점도에서 주황색 점이 됩니다.
50cm:
[[6. 7. 7.]] [[34 8 14]]
 
100cm:
[[56. 57. 57.]] [[34 8 14]]
 
거리는 더 멀어졌지만 평균을 계산하는 점은 그대로 입니다. k최근접이웃의 정의 때문입니다. 가장 가까운 점을 고르기 때문에 데이터 범위를 벗어나기 시작했을 때 동일한 이웃이 선택되고, 예측값은 항상 같은 경우가 생깁니다.
 
이 책에는 나와있지 않지만 이를 토대로 k최근접 이웃은 2가지 경우가 충족될때 쓸 수 있는 것으로 판단하고 있습니다. 1. 예측 데이터가 훈련 데이터의 min, max 값 안에 있을때, 2. 데이터의 분포가 비교적 고를때
 

반응형
반응형

지난번에 한 번 mcp 연결하는 방법을 설명드렸었죠.

 

 

Claude에서 처음 공개했던 정확한 MCP의 개념을 정리해본적이 없는 것 같아서 알려진 것을 토대로 작성해보겠습니다.

 

 

MCP Server란, 표준화된 프로토콜 인터페이스를 통해서 AI application에 어떠한 기능을 수행할 수 있도록 하는 프로그램입니다.

 

 

MCP Server는 Tools, Resources, Prompts라는 세 가지 영역으로 구성됩니다.

  • Tools: LLM이 직접 호출하는 함수. 사용자의 요청에 따라 언제 어떤 것을 사용할지 LLM이 결정
    • Model이 통제
    • 지정된 입력과 출력 형식을 통해 수행할 작업을 정의
    • Tools의 작업은 두가지 방식 존재
      1. 사용 가능한 Tools 목록을 보는 방식
      2. 사용 가능한 Tools 중 context상 필요한 특정 도구를 실행
  • Resources: read-only 권한으로 context에 데이터(db, 파일내용, api 문서)를 제공하는 소스 원천
    • AI가 해당 어플리케이션 영역에서만 사용하는 문맥을 이해하는데 필요한 데이터를 제공합니다.
    • 두 가지 패턴을 지원합니다.
      1. 직접 데이터를 가리키는 URI를 제공
      2. 리소스 템플릿 제공: [예시] travel://activities/{city}/{category} - 와 같은 동적URI 등
  • Prompt: 모델이 언제 Tools와 Resourecs를 이용할지 지시하는 미리작성된 지침 규격
    • 사용자 통제

 

 

 

 

 

mcp서버 개발 관련 공식링크를 보면 기반이 STDIO와 HTTP인 경우로 나뉘어지는데 오늘은 간단한 예제이므로 HTTP 기반으로 만들겠습니다.

 

 

아니 블로거님. HTTP면 REST api나 홈페이지를 만들어야 하는 것 아닌가요?

-> 네 맞습니다. 어떤 기능을 발휘하는 스펙이 있어야하고 HTTP 프로토콜 기반이라면 REST API 가 대표적이라고 볼 수 있습니다. 참고로, 데이터 전달 방식에 따라 streamable HTTP 방식이 가장 권장됩니다. 하지만 REST API 를 새로 streamable하게 개발 또는 변경하는 것보다는, 기존에 개발되어있는 REST API 중에 하나를 이용해서 진행하겠습니다.

 

저는 개발된 서버의 swagger로 바이브코딩을 할 것이고 여러분들은 Pet store 같은 공개된 swagger를 이용하거나 기존에 개발하신 rest api의 문서화가 잘되어있다면 그 것의 swagger를 이용하셔도 되겠습니다.

 

Pet Store sample의 github url: https://github.com/swagger-api/swagger-petstore?utm_source=chatgpt.com

위 페이지 내에 Pet Store Sample 서버를 설치구동하는 방법이 나와있고, Readme.md에 보시면 swagger.yaml을 바로 보실 수 있습니다.

 

 

GitHub - swagger-api/swagger-petstore

Contribute to swagger-api/swagger-petstore development by creating an account on GitHub.

github.com

 

[준비: ai agent client - antigravity/claude/chatgpt 중 하나]

cli로 안하고 client로 진행하지만, cli로 하셔도 큰 차이는 없습니다. 요즘은 antigravity로 작업해보고 있어서 antigravity로 하겠습니다만, claude나 gpt client도 메뉴만 ui나 이름이 조금씩 다를뿐 방식은 마찬가지 이니 따라오시면 되겠습니다.

 

1. 작업공간 준비

작업공간에 준비한 swagger.yaml을 복사해둡니다.

 

md파일이나 프롬프트로 대략 다음 문맥 유형, 단계를 거쳐 요구를 하시면 되겠습니다.

  • [swagger.yaml 파일의 경로]에 mcp 서버를 만들고 싶은 application의 swagger.yaml이 있다
  • mcp로 쓰고싶은 기능설명 - 저같은 경우는 재직사 솔루션 관련 문서 핸들링을 많이하기 때문에 솔루션 내 문서 보관 폴더 등을 조회하고 폴더 내 문서목록을 보는 기능, 해당 폴더에 문서를 업로드 하는기능, 문서를 요약하는 기능 등을 만들어 달라고 했었습니다. Pet Store를 사용하신다면 아주 간단하게 하셔도 되겠습니다. "해당 yaml 내에는 Pet Store를 운영하기 위해 필요한 기능들이 들어간다. 상점 주인으로서 필요한 기능들만 추려서 MCP 서버를 개발해줘, 어떤 기능을 추렸는지와 그 기능을 사용한 이유를 알려줘"
  • 개발이 완료되었을때, swagger.yaml을 요약시킴과 동시에 mcp서버로 구현된 기능 목록을 달라고하고 부족한 부분이 있으면 추가해달라고하면 되겠습니다.
  • 개발단계가 마무리되면 mcp server를 빌드하는방법, 그리고 agent에 추가하는 방법을 문서화 해달라고 하시면 되겠습니다.
  • 토큰이 조금 더 들기는하지만 계획 문서를 파일로 저장해달라고하면 심지어 개발언어를 몰라도 따라가기 좋습니다. claude 공식문서에 있는 python 예제는 다른 글에서 다루거나 넘어갈 것이고, ts나 다른언어로도 개발되므로 아는 언어가 있다면 사전에 ai와 대화를 통해 지원되는 언어 목록을 받아서 그중에서 하나를 명시해주면 좋습니다.
  • "개발 소스는 A폴더, 계획, To-do List 등 사전 문서작업사항은 B폴더, 중간단계 혹은 초기 개발 이후 수정을 할 때 마다  작업진행 상황 정리 문서는 C폴더에 정리해줘, 완료 후 기능명세서, mcp server 빌드, 설치 방법, 사용자가이드가 담긴 사후 문서는 D폴더에 정리해줘" 라고 남길 필요가 있고, 저러한 문서들을 만들어 달라는 내용도 프롬프트나 md파일에 담는 것을 권장드립니다. 
  • 우리는 이제 C폴더를 보면서 ai가 이상하게 개발한 부분이 있는지, 초기 개발 이후 디버그 단계에서 수정해야될 부분은 없는지 팔로우 하면 됩니다. 

 

 

 

이 정도만해도 꽤 많은 노하우를 알려드렸다고 생각하니 중간에 여러 과정들은 넘어가겠습니다. 

 

자 이제 개발이 완료되었죠. 사용하시는 ai에 따라 다르겠습니다만, 저는 antigravity로 진행했고, 여기에 연결할 수 있는 방법을 작성해달라고 해두었기 때문에 antigravity에 mcp서버를 연결할 수 있는 방법을 전달 받았습니다. Windows PC 기준 [%USERPROFILE%\.gemini\config\mcp_config.json]에 추가하면 된다고 제가 알고있는 바와 같은 방식 형식과 함께을 안내해주었습니다. 방법에 따라 여기에 내용을 추가했습니다.

 

 

그리고 antigravity로 돌아와서, 좌측하단의 [Settings] -> 좌측 탭의 [Customizations] -> [Installed MCP Servers]에서 [Refresh]를 클릭합니다. 그러면 개발 과정이 잘 진행된 경우 초록색 동그라미 표시와 함께 MCP Server가 연결됩니다.

 

 

 

 

이제 chat에서 살짝 ping 쏴봅니다.

 

연결되었다고 하네요.

 

저는 이전에도 여러개를 했지만 조금 완성도 있는 결과물을 보여드리기 위해 문서 저장소 관련으로 개발된 시스템의 MCP Server 구현을 진행해보았습니다. 지정한 어떤 문서 본문을 달라고해보겠습니다. 저는 oauth2 authorization code 방식 인증도 구현해서 mcp 관련 기능을 수행할때 최초 한 번은 인증창도 뜹니다.

 

반응형
반응형

작성시 기준 한국시간으로 어제에서 오늘 오전 사이에 발표된 따끈 따끈한 소식입니다.

 

이제 Open AI의 기본모델이 무료 사용자에게도  GPT-5.6 Luna 로 적용됩니다.

 

 

원문 부터 알아볼까요?

 

 

 

 

원문에 따르면 Plus, Pro 결제 사용자는 이제 더 믿을 수있는 사실 기반에 논리에 더 맞는 답변을 받을 수 있고, 얼마나 많은 추론을 들일지 선택할 수 있는 새로운 슬라이더를 GPT-5.6 Sol에 추가했다고 했습니다.

 

 

또한 GPT-5.6 Luna 모델은 이번주 내로 무료/Go 사용자에게 모두 기본모델로 전환되며, 차주 부터는 text chats에 한정해서는 unlimited [제한이 없는] 사용이 가능합니다. 어려운 문제에 사용할 수 있는 Think 버튼도 생겼다고 합니다.

 

 

제가 text chat이라고 계속 강조드리는데, 파일이나 이미지 업로드 생성 또는 툴을 이용한 것들은 제외입니다. 오직 문자열로 문의하고 내용을 받는 것에 한정됩니다.

 

 

 

참고로 금일 기준 개인 기준의 gpt plan은 4가지가 있습니다. Free, Go, Plus, Pro 공지 내용을 보면 Plus와 Pro에는 이미 기본모델이 GPT-5.6 Luna로 바뀌어있던 것으로 보이고, [  they’ll also have unlimited text chats ] 에서 also 라는 표현을 보았을때 채팅 기준으로는 제한이 없던 것 같습니다. Free와 Go 사용자에게는 아주 기쁜 소식이겠습니다. 사실 저도 GPT는 요즘 결제하고 있지 않아서 아주 기쁩니다.

 

 

그러면 Codex도 파일 안건드리는 채팅은 무료 인가요?

 ->  Work나 Codex에는 반영되지 않는 사항입니다. 공지의 가장마지막 줄에 기입되어 있습니다.

 

 

 

토큰이 떨어지면 멍때리시는 분들이 많이 생겨났는데 코드를 텍스트로 복사해서 텍스트로 받는다면 이것도 역시 무료의 범주이지 않을까 싶네요. 웹 chat 만으로 자동으로 이것저것을 해줄 수는 없겠지만 특히 저처럼 GPT를 결제하지 않고 있던 사용자에게 제한 없이 사용할 수 있는 모델의 선택지로 최상위권으로 평가 받는 모델이 생겼다는 것은 엄청난 메리트가 아닐까 싶습니다.

반응형

+ Recent posts