Ver4.0 ๐ก๏ธ Python์ผ๋ก AI ์ฝํ ์ธ ํํฐ๋ง ์์คํ ๋ง๋ค๊ธฐ โ ์์คยท์คํธยท์ ํด ์ด๋ฏธ์ง๊น์ง ๊ฑธ๋ฌ๋ด๋ ์ค์ ํ์ดํ๋ผ์ธ ๐

๐ก๏ธ Python์ผ๋ก AI ์ฝํ ์ธ ํํฐ๋ง ์์คํ ๋ง๋ค๊ธฐ โ ์์คยท์คํธยท์ ํด ์ด๋ฏธ์ง๊น์ง ๊ฑธ๋ฌ๋ด๋ ์ค์ ํ์ดํ๋ผ์ธ ๐
ํค์๋ ๊ธ์ง์ด ๋ชฉ๋ก ํ๋๋ก ๋ฒํฐ๋ ์๋๋ ๋๋ฌ์ด.
์ด์ ๋ ์๋ฒ ๋ฉ, ํธ๋์คํฌ๋จธ, ๊ทธ๋ฆฌ๊ณ ์ฝ๊ฐ์ ์ ๊ท์ ์ผ์ค๊ฐ ํ์ํด.
๐ ๋ค์ด๊ฐ๋ฉฐ: "ใ .ใ " ์ ์ ์ ๊ฑธ๋ฌ์ง๊น?
์น๊ตฌ์ผ, ํน์ ์ปค๋ฎค๋ํฐ ์ด์ํด๋ณธ ์ ์์ด? ์๋ค๊ณ ? ๊ทธ๋ผ ์์๋ง ํด๋ด.
์๋ฒฝ 3์์ ์๋ฆผ์ด ์ธ๋ ค. ๋๊ตฐ๊ฐ ๊ฒ์ํ์ ๋๋ฐฐ๋ฅผ ์์ํ๊ฑฐ๋ .
๋๋ ๋ถ๋ด๋ถ๋ด ๋
ธํธ๋ถ์ ์ด๊ณ ๊ธ์ง์ด ๋ชฉ๋ก์ ๋จ์ด ํ๋๋ฅผ ์ถ๊ฐํด.
๊ทธ๋ฆฌ๊ณ 10๋ถ ๋ค, ๊ทธ ์ฌ๋์ ใ
ใ
, ใ
.ใ
, ์1๋ฐ, ใ
ใ
ฃ๋ฐ ์ด๋ฐ ์์ผ๋ก ์ฐํํด์ ๋ค์ ์ฌ๋ ค.
์ด๊ฒ ๋ฐ๋ก ๊ท์น ๊ธฐ๋ฐ ํํฐ๋ง์ ํ๊ณ์ผ.
์ฌ๋์ ์ฐฝ์์ ์ด๊ฑฐ๋ . ํนํ ์ํ ๋๋ ๋๋์ฑ.
๋ฐ๋๋ก ์ด๋ฐ ๊ฒฝ์ฐ๋ ์์ด.
"์ด ๊น์น์ฐ๊ฐ ์ง์ง ๋ฏธ์น ๋ง์ง์ด๋ค"๋ผ๋ ๋ฌธ์ฅ์ด ๊ธ์ง์ด ๋ฏธ์น ๋๋ฌธ์ ์ฐจ๋จ๋ผ.
์ฌ์ฅ๋์ ์ต์ธํ๊ณ , ์ ์ ๋ ๋ ๋๊ณ , ๋๋ CS ์ง์ฅ์ ๋น ์ง์ง.
ํต์ฌ ๋๋ ๋ง ๋ ๊ฐ์ง
โ False Negative(๋์นจ) โ ๋์ ๊ฑธ ๋ชป ๊ฑธ๋ฌ๋ โ ์ปค๋ฎค๋ํฐ ๋งํจ
โก False Positive(์คํ) โ ๋ฉ์ฉกํ ๊ฑธ ์ฐจ๋จํจ โ ์ ์ ๋ ๋จ
AI ์ฝํ
์ธ ํํฐ๋ง์ ์ด ๋ ์ฌ์ด์์ ์ต์ ์ ์ง์ ์ ์ฐพ๋ ๊ธฐ์ ์ด์ผ.
100% ์ ํํ ํํฐ๋ ์ธ์์ ์์ด. ์ง์ง๋ก. OpenAI๋, Google๋ ๋ชป ๋ง๋ค์์ด.
์ค๋ ์ฐ๋ฆฌ๊ฐ ๋ง๋ค ๊ฑด ์ด๊ฑฐ์ผ. ๐
์, ์ด์ ํ๋์ฉ ๋ถ์ด๋ณด์. ์ฝ๋๋ ์ ๋ถ ์ค์ ๋ก ๋์๊ฐ๋ ํ์ด์ฌ์ด์ผ.
๐งผ STEP 1. ์ ๊ทํ โ ์ฐํ ๊ณต๊ฒฉ์ ๋ฌด๋ ฅํํ๋ ์ฒซ ๋ฐฉ์ด์
ํํฐ๋ง์ 8ํ ์ ์ ์ฒ๋ฆฌ์ผ. ์ง์ง์ผ.
๋ชจ๋ธ ์๋ฌด๋ฆฌ ์ข์๋ ์
๋ ฅ์ด ์๋ง์ด๋ฉด ์์ฉ์์ด.
1-1. ์ ๋์ฝ๋ ์ ๊ทํ (NFKC)
์ฌ๋๋ค์ ์ด๋ฐ ์์ผ๋ก ์ฐํํด:
๏ฝ๏ฝ๏ฝ๏ฝ โ ์ ๊ฐ ๋ฌธ์
๐๐๐๐ โ ์ํ ๊ธฐ์ธ์ ์ ๋์ฝ๋
โโคโโ โ ์๋ฌธ์
fโuโcโk โ ์ฌ์ด์ฌ์ด ์ ๋กํญ ๊ณต๋ฐฑ(U+200B)
ํ์ด์ฌ ํ์ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ unicodedata ํ๋๋ฉด ๋๋ถ๋ถ ์ ๋ฆฌ๋ผ.
import unicodedata
import re
# ์ ๋กํญ ๋ฌธ์ / ๋ฐฉํฅ ์ ์ด ๋ฌธ์ ์ ๊ฑฐ์ฉ
INVISIBLE = re.compile(
r'[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\u00ad]'
)
def normalize_text(text: str) -> str:
# 1) ๋ณด์ด์ง ์๋ ๋ฌธ์ ์ ๊ฑฐ
text = INVISIBLE.sub('', text)
# 2) ์ ๋์ฝ๋ ํธํ ์ ๊ทํ (์ ๊ฐ โ ๋ฐ๊ฐ, ์๋ฌธ์ โ ์ผ๋ฐ๋ฌธ์)
text = unicodedata.normalize('NFKC', text)
# 3) ์๋ฌธ์ํ
text = text.lower()
# 4) ๋ฐ๋ณต ๋ฌธ์ ์ถ์ฝ: "๋ฏธใ
ฃใ
ฃใ
ฃ์น" ๊ฐ์ ๋
ธ์ด์ฆ ๋์
text = re.sub(r'(.)\1{2,}', r'\1\1', text)
# 5) ๊ณต๋ฐฑ ์ ๋ฆฌ
text = re.sub(r'\s+', ' ', text).strip()
return text
print(normalize_text("๏ฝใ๏ฝใ๏ฝใ๏ฝ")) # -> "f u c k"
print(normalize_text("โโโโโ")) # -> "hello"
๐ก NFKC๊ฐ ๋ญ์ผ?
NFKC = Normalization Form Kompatibility Composition.
"๋ชจ์์ด ๋น์ทํ๊ฑฐ๋ ํธํ๋๋ ๋ฌธ์๋ฅผ ํ์คํ ํ๋๋ก ํฉ์น๋ค"๋ ๋ป์ด์ผ.
์ ๊ฐ ๏ฝ์ ๋ฐ๊ฐ a๋ฅผ ๊ฐ์ ๊ฑธ๋ก ์ทจ๊ธํด์ฃผ์ง.
๋จ, NFKC๋ ํ๊ธ ์๋ชจ ๋ถ๋ฆฌ๊น์ง๋ ๋ชป ์ก์. ๊ทธ๊ฑด ๋ค์ ๋จ๊ณ์์.
1-2. ํ๊ธ ์๋ชจ ๋ถํด โ ํ๊ตญ์ด ํํฐ๋ง์ ์จ์ ๋ณด์ค
์์ด๊ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๊ทธ๋๋ก ์ฐ๋ฉด ํ๊ตญ์ด์์ ์ฒ์ฐธํ๊ฒ ๊นจ์ ธ.
์๋๋ฉด ํ๊ธ์ ์ด์ฑยท์ค์ฑยท์ข
์ฑ์ผ๋ก ์ชผ๊ฐ์ง๊ฑฐ๋ .
์๋ฐ โ ใ
ใ
ฃใ
ใ
ใน โ ใ
1๋ฐ โ ใ
ใ
ฃ์ด๋ฐ ...
๊ฒฝ์ฐ์ ์๊ฐ ํญ๋ฐํด.
๊ทธ๋์ ์๋ชจ ๋จ์๋ก ๋ถํดํด์ ๋น๊ตํ๋ ๊ฒ ์ ์์ด์ผ.
CHO = list("ใฑใฒใดใทใธในใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
")
JUNG = list("ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
กใ
ขใ
ฃ")
JONG = list(" ใฑใฒใณใดใตใถใทในใบใปใผใฝใพใฟใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
ใ
")
def decompose_hangul(text: str) -> str:
"""ํ๊ธ์ ์๋ชจ ๋ฌธ์์ด๋ก ๋ถํด. '์๋ฐ' -> 'ใ
ใ
ฃใ
ใ
ใน'"""
out = []
for ch in text:
code = ord(ch)
if 0xAC00 <= code <= 0xD7A3: # ์์ฑํ ํ๊ธ ์์ญ
idx = code - 0xAC00
cho = idx // (21 * 28)
jung = (idx % (21 * 28)) // 28
jong = idx % 28
out.append(CHO[cho])
out.append(JUNG[jung])
if jong:
out.append(JONG[jong])
else:
out.append(ch)
return ''.join(out)
print(decompose_hangul("์๋ฐ")) # ใ
ใ
ฃใ
ใ
ใน
print(decompose_hangul("์จ1๋ฐ")) # ใ
ใ
ฃ1ใ
ใ
ใน
์ด๋ ๊ฒ ๋ถํดํ ๋ค, ์ซ์ยทํน์๋ฌธ์ยท๊ณต๋ฐฑ์ ๋ชจ๋ ์ ๊ฑฐํ๊ณ ๋น๊ตํ๋ฉด
ใ
.ใ
, ใ
ใ
, ์1๋ฐ์ด ์ ๋ถ ๊ฐ์ ํจํด์ผ๋ก ์กํ.
1-3. ์๊ฐ ์ ์ฌ ๋ฌธ์(Homoglyph) ์นํ
0๊ณผ O, 1๊ณผ l๊ณผ I, ํค๋ฆด ๋ฌธ์ ะฐ์ ๋ผํด a.
๊ฒ๋ณด๊ธฐ์ ๋๊ฐ์๋ฐ ์ฝ๋ํฌ์ธํธ๋ ์์ ํ ๋ฌ๋ผ.
HOMOGLYPH = {
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's',
'7': 't', '@': 'a', '$': 's', '!': 'i',
'ะฐ': 'a', 'ะต': 'e', 'ะพ': 'o', 'ั': 'c', 'ั': 'p', # ํค๋ฆด
}
def fold_homoglyph(text: str) -> str:
return ''.join(HOMOGLYPH.get(c, c) for c in text)
print(fold_homoglyph("h3ll0 w0rld")) # hello world
โ ๏ธ ์ฃผ์: ๊ณผํ๋ฉด ์คํ์ด ํฐ์ง๋ค
4๋ฅผ a๋ก ๋ฐ๊พธ๋ฉด "4๋ฒ ๊ฒ์๊ธ"์ด "a๋ฒ ๊ฒ์๊ธ"์ด ๋ผ.
๊ทธ๋์ ์ค๋ฌด์์๋ ์๋ฌธ๊ณผ ์ ๊ทํ๋ณธ์ ๋ ๋ค ์ ์งํ๊ณ ,
์ ๊ทํ๋ณธ์ ๊ธ์ง์ด ๋งค์นญ ์ ์ฉ์ผ๋ก๋ง ์ฐ๋ ๊ฒ ์์ ํด.
โก STEP 2. ๊ท์น ๊ธฐ๋ฐ ํํฐ โ ๋น ๋ฅด๊ณ ์ธ๊ณ ํ์คํ ๋
"AI ์๋์ ๋ฌด์จ ์ ๊ท์์ด์ผ?" ๋ผ๊ณ ์๊ฐํ๋ค๋ฉด ์ ๊น.
ํ์ค ์๋น์ค์์๋ ํธ๋ํฝ์ ๋๋ถ๋ถ์ด ๊ท์น ๋จ๊ณ์์ ๋๋.
GPU ์ถ๋ก ์ ๋น์ธ๊ณ ๋๋ ค. ํ์คํ ๊ฑด ๋จผ์ ์ณ๋ด๋ ๊ฒ ๋ง์.
2-1. Aho-Corasick: ๊ธ์ง์ด 10๋ง ๊ฐ๋ ํ ๋ฐฉ์
๊ธ์ง์ด๊ฐ 1๋ง ๊ฐ์ธ๋ฐ for word in banned: ์ด๋ ๊ฒ ๋๋ฆฌ๋ฉด?
O(N ร M)์ด๋ผ ๋ฌธ์์ด ๊ธธ์ด์ง๋ฉด ์ฃฝ์ด.
Aho-Corasick ์๊ณ ๋ฆฌ์ฆ์ ์ฌ๋ฌ ํจํด์ ํธ๋ผ์ด(trie)๋ก ๋ฌถ์ด์
ํ
์คํธ๋ฅผ ๋ฑ ํ ๋ฒ๋ง ํ์ผ๋ฉด์ ์ ๋ถ ์ฐพ์๋ด. O(N + ๋งค์นญ์)์ผ.
# pip install pyahocorasick
import ahocorasick
class BannedWordMatcher:
def __init__(self, words):
self.A = ahocorasick.Automaton()
for idx, w in enumerate(words):
self.A.add_word(w, (idx, w))
self.A.make_automaton()
def find(self, text: str):
hits = []
for end_idx, (idx, word) in self.A.iter(text):
start = end_idx - len(word) + 1
hits.append({'word': word, 'start': start, 'end': end_idx})
return hits
matcher = BannedWordMatcher(['๋๋ฐ', '๋ถ๋ฒ', '๋์ถ', '์นด์ง๋
ธ'])
print(matcher.find("์ฌ๊ธฐ ๋ถ๋ฒ ๋๋ฐ ์ฌ์ดํธ ์ถ์ฒ"))
# [{'word': '๋ถ๋ฒ', ...}, {'word': '๋๋ฐ', ...}]
๐ ์ฑ๋ฅ ๋น๊ต (๊ธ์ง์ด 10,000๊ฐ / ํ ์คํธ 1,000์ ๊ธฐ์ค, ์ฐธ๊ณ ์น)
| ๋ฐฉ์ | ์๊ฐ ๋ณต์ก๋ | ์ฒด๊ฐ ์๋ |
|---|---|---|
๋จ์ ๋ฐ๋ณต in | O(NรM) | ๋๋ฆผ (์์ญ ms) |
์ ๊ท์ | ๊ฒฐํฉ | ๋ฐฑํธ๋ํน ์ํ | ๋ถ์์ |
| Aho-Corasick | O(N + ๋งค์นญ์) | ๋งค์ฐ ๋น ๋ฆ (0.1ms ์์ค) |
2-2. ํ์ดํธ๋ฆฌ์คํธ โ ์คํ ๋ฐฉ์ง ์ฅ์น
์ด๊ฒ ์ง์ง ์ค์ํด. ๊ท์น ํํฐ์ ์๋ช ์ ์์ธ ์ฒ๋ฆฌ์ผ.
WHITELIST_CONTEXT = [
"๋์ถ ๊ท์ ", "๋๋ฐ ์ค๋
์๋ด", "๋ถ๋ฒ ์ ๊ณ ",
"๋ฏธ์น ๋ง์ง", "๋ฏธ์น ํ๋ฆฌํฐ",
]
def is_whitelisted(text: str, hit_word: str, window: int = 12) -> bool:
"""๋งค์นญ๋ ๋จ์ด ์ฃผ๋ณ ๋ฌธ๋งฅ์ด ํ์ดํธ๋ฆฌ์คํธ์ ๊ฑธ๋ฆฌ๋์ง ํ์ธ"""
for safe in WHITELIST_CONTEXT:
if safe in text:
return True
return False
์ค๋ฌด์์๋ ์ด ํ์ดํธ๋ฆฌ์คํธ๊ฐ ๊ณ์ ๋์ด๋.
CSํ์์ "์ด๊ฑฐ ์ ๋งํ์ด์?" ๋ฌธ์๊ฐ ์ฌ ๋๋ง๋ค ํ๋์ฉ ์์ด๊ฑฐ๋ . ๐
2-3. ์คํธ ์๊ทธ๋: ํ ์คํธ ๋ง๊ณ 'ํ๋'์ ๋ด๋ผ
๋ด์ฉ๋ง ๋ณด๋ฉด ๋ฉ์ฉกํ๋ฐ ์คํธ์ธ ๊ฒฝ์ฐ๊ฐ ๋ง์.
๊ทธ๋ด ๋ ๋ฉํ ํน์ง(meta feature)์ด ๋ต์ด์ผ.
import re
from urllib.parse import urlparse
URL_RE = re.compile(r'https?://\S+|www\.\S+')
PHONE_RE = re.compile(r'01[016789][-.\s]?\d{3,4}[-.\s]?\d{4}')
def extract_spam_signals(text: str) -> dict:
urls = URL_RE.findall(text)
length = max(len(text), 1)
# ๋๋ฌธ์ ๋น์จ (์๋ฌธ ๋๋ฐฐ ํ์ง)
alpha = [c for c in text if c.isalpha() and c.isascii()]
upper_ratio = sum(c.isupper() for c in alpha) / max(len(alpha), 1)
# ํน์๋ฌธ์ ๋น์จ
special_ratio = sum(not c.isalnum() and not c.isspace()
for c in text) / length
# ๋์ผ ๋ฌธ์ ์ต๋ ๋ฐ๋ณต
max_repeat = max((len(m.group())
for m in re.finditer(r'(.)\1*', text)), default=0)
return {
'url_count': len(urls),
'has_phone': bool(PHONE_RE.search(text)),
'upper_ratio': round(upper_ratio, 3),
'special_ratio': round(special_ratio, 3),
'max_repeat': max_repeat,
'length': length,
}
print(extract_spam_signals("๊ธด๊ธ!!! 010-1234-5678 ๋ก ์ฐ๋ฝ http://spam.kr"))
์ด ํน์ง๋ค์ ๊ทธ๋๋ก LightGBM์ด๋ XGBoost์ ๋ฃ์ผ๋ฉด
๋ฅ๋ฌ๋ ์์ด๋ ์คํธ ํ์ง ์ ํ๋๊ฐ ๊ฝค ์ ๋์. ์ง์ง๋ก.
๐ง STEP 3. ML ๋ถ๋ฅ๊ธฐ โ ๋ฌธ๋งฅ์ ์ดํดํ๋ ํํฐ
๋๋์ด ๋ฉ์ธ ์๋ฆฌ๋ค. ์ฌ๊ธฐ์๋ถํฐ๋ "๋จ์ด"๊ฐ ์๋๋ผ "์๋ฏธ"๋ฅผ ๋ณธ๋ค.
3-1. ์๋ฐ์ : TF-IDF + ๋ก์ง์คํฑ ํ๊ท
๋ฅ๋ฌ๋ ๊ฐ๊ธฐ ์ ์ ๊ผญ ์ด๊ฑธ ๋จผ์ ํด๋ด.
์๋๊ณ ? ๋ฒ ์ด์ค๋ผ์ธ์ด ์์ผ๋ฉด ๊ฐ์ ํ๋์ง ์ ์๊ฐ ์๊ฑฐ๋ .
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report
from sklearn.model_selection import train_test_split
texts = [...] # ๋ฌธ์ฅ ๋ฆฌ์คํธ
labels = [...] # 0=์ ์, 1=์ ํด
X_tr, X_te, y_tr, y_te = train_test_split(
texts, labels, test_size=0.2, stratify=labels, random_state=42
)
pipe = Pipeline([
# ํ๊ตญ์ด๋ ํํ์ ๋ถ์์ด ๊น๋ค๋ก์ฐ๋ ๋ฌธ์ n-gram์ด ์์ธ๋ก ๊ฐ๋ ฅํ๋ค
('tfidf', TfidfVectorizer(
analyzer='char_wb',
ngram_range=(2, 4),
min_df=2,
sublinear_tf=True,
max_features=200_000,
)),
('clf', LogisticRegression(
C=4.0, max_iter=2000, class_weight='balanced'
)),
])
pipe.fit(X_tr, y_tr)
pred = pipe.predict(X_te)
print(classification_report(y_te, pred, digits=4))
๐ก ์ char_wb n-gram์ด์ผ?
ํ๊ตญ์ด๋ ๊ต์ฐฉ์ด๋ผ ์ด๋ฏธ ๋ณํ๊ฐ ์ฌํด. "๋จน์๋ค / ๋จน๋๋ค / ๋จน์ด๋ผ"...
๋จ์ด ๋จ์๋ก ์๋ฅด๋ฉด ์ ๋ถ ๋ค๋ฅธ ํ ํฐ์ด ๋๋๋ฐ,
๋ฌธ์ 2~4๊ธ์ ๋จ์๋ก ์๋ฅด๋ฉด "๋จน" ์ด๋ผ๋ ๊ณตํต ํจํด์ด ์ด์๋จ์.
๊ฒ๋ค๊ฐ ์คํ์ ์ฐํ ํ๊ธฐ์๋ ํจ์ฌ ๊ฐํด. ๊ฐ์ฑ๋น ์ต๊ณ .
3-2. ๋ณธ๊ฒฉ: ํ๊ตญ์ด ํธ๋์คํฌ๋จธ ํ์ธํ๋
์ด์ ์ง์ง๋ค. ์ฌ์ ํ์ต ์ธ์ด๋ชจ๋ธ์ ๋ฐ๋ ค์์ ์ฐ๋ฆฌ ๋ฐ์ดํฐ๋ก ๊ธธ๋ค์ธ๋ค.
ํ๊ตญ์ด๋ผ๋ฉด ์ด ์ค ํ๋๋ฅผ ๊ณ ๋ฅด๋ฉด ๋ผ:
| ๋ชจ๋ธ | ํน์ง | ์ถ์ฒ ์ํฉ |
|---|---|---|
klue/roberta-base | KLUE ๋ฒค์น๋งํฌ ๊ธฐ์ค ํ๊ตญ์ด ๋ฒ์ฉ ์ฑ๋ฅ ์ฐ์ | ์ผ๋ฐ์ ์ธ ๋ถ๋ฅ ์์ |
beomi/KcELECTRA-base | ๋๊ธยท๊ตฌ์ด์ฒด ๋ฐ์ดํฐ๋ก ํ์ต, ์ ์กฐ์ด์ ๊ฐํจ | ๋๊ธ/์ปค๋ฎค๋ํฐ ํํฐ๋ง |
monologg/koelectra-base-v3 | ELECTRA ๊ตฌ์กฐ, ๊ฒฝ๋ ๋๋น ์ฑ๋ฅ ์ํธ | ์ถ๋ก ์๋ ์ค์ํ ๋ |
xlm-roberta-base | 100๊ฐ ์ธ์ด ์ง์ | ๋ค๊ตญ์ด ์๋น์ค |
๋๊ธ ํํฐ๋ง์ด๋ฉด KcELECTRA๊ฐ ์ฒด๊ฐ์ ์ ์ผ ์ ๋ง์.
ํ์ต ๋ฐ์ดํฐ ์์ฒด๊ฐ ์ธํฐ๋ท ๋๊ธ์ด๋ผ "ใ
ใ
", "ในใ
", "๊ฐ๊ฟ" ๊ฐ์ ๋ง์ ์ด๋ฏธ ์๊ฑฐ๋ .
import torch
from torch.utils.data import Dataset
from transformers import (
AutoTokenizer, AutoModelForSequenceClassification,
TrainingArguments, Trainer
)
import numpy as np
from sklearn.metrics import f1_score, precision_recall_fscore_support
MODEL_NAME = "beomi/KcELECTRA-base"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
class ToxicDataset(Dataset):
def __init__(self, texts, labels, max_len=128):
self.enc = tokenizer(
texts, truncation=True, padding='max_length',
max_length=max_len
)
self.labels = labels
def __len__(self):
return len(self.labels)
def __getitem__(self, i):
item = {k: torch.tensor(v[i]) for k, v in self.enc.items()}
item['labels'] = torch.tensor(self.labels[i])
return item
model = AutoModelForSequenceClassification.from_pretrained(
MODEL_NAME, num_labels=2
)
def compute_metrics(p):
preds = np.argmax(p.predictions, axis=1)
pr, rc, f1, _ = precision_recall_fscore_support(
p.label_ids, preds, average='binary', zero_division=0
)
return {'precision': pr, 'recall': rc, 'f1': f1}
args = TrainingArguments(
output_dir='./toxic-filter',
num_train_epochs=3,
per_device_train_batch_size=32,
per_device_eval_batch_size=64,
learning_rate=2e-5,
warmup_ratio=0.1,
weight_decay=0.01,
eval_strategy='epoch',
save_strategy='epoch',
load_best_model_at_end=True,
metric_for_best_model='f1',
fp16=torch.cuda.is_available(),
logging_steps=50,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=ToxicDataset(X_tr, y_tr),
eval_dataset=ToxicDataset(X_te, y_te),
compute_metrics=compute_metrics,
)
trainer.train()
trainer.save_model('./toxic-filter/best')
tokenizer.save_pretrained('./toxic-filter/best')
๐ง ํ์ดํผํ๋ผ๋ฏธํฐ ๊ฐ ์ก๊ธฐ
learning_rate: 2e-5 ~ 5e-5 ์ฌ์ด. ์ด๋ณด๋ค ํฌ๋ฉด ์ฌ์ ํ์ต ์ง์์ด ๋ ์๊ฐ.
epochs: 2~4. ๊ทธ ์ด์์ ๊ฑฐ์ ํญ์ ๊ณผ์ ํฉ.
max_length: ๋๊ธ์ด๋ฉด 128, ๊ฒ์๊ธ ๋ณธ๋ฌธ์ด๋ฉด 256~512.
fp16: GPU ์์ผ๋ฉด ๋ฌด์กฐ๊ฑด ์ผ. ๋ฉ๋ชจ๋ฆฌ ์ ๋ฐ, ์๋ 1.5~2๋ฐฐ.
3-3. ์ด์ง ๋ถ๋ฅ๋ฅผ ๋์ด์: ๋ฉํฐ๋ผ๋ฒจ ์นดํ ๊ณ ๋ฆฌ ๋ถ๋ฅ
"์ ํดํจ / ์ ์ ํดํจ"๋ง์ผ๋ก๋ ๋ถ์กฑํด.
์์ค์ด๋ ์ฑ์ ์ฝํ
์ธ ๋ ์ํด ์กฐ์ฅ์ ๋์ ๋ฐฉ์์ด ์์ ํ ๋ฌ๋ผ์ผ ํ์์.
๊ทธ๋์ ์ค๋ฌด ํํฐ๋ ๋๋ถ๋ถ ๋ฉํฐ๋ผ๋ฒจ๋ก ๊ฐ๋ค.
CATEGORIES = [
'harassment', # ๊ดด๋กญํ/๋ชจ์
'hate', # ํ์ค ํํ
'sexual', # ์ฑ์ ์ฝํ
์ธ
'violence', # ํญ๋ ฅ
'self_harm', # ์ํด/์์ด
'spam', # ์คํธ/๊ด๊ณ
'personal_info', # ๊ฐ์ธ์ ๋ณด ๋
ธ์ถ
]
model = AutoModelForSequenceClassification.from_pretrained(
MODEL_NAME,
num_labels=len(CATEGORIES),
problem_type="multi_label_classification", # โ ํต์ฌ!
)
# ๋ผ๋ฒจ์ [1,0,0,0,0,1,0] ๊ฐ์ ๋ฉํฐํซ float ๋ฒกํฐ๋ก ์ ๊ณต
# ๋ด๋ถ์ ์ผ๋ก BCEWithLogitsLoss ์ฌ์ฉ โ ์๊ทธ๋ชจ์ด๋ ๋
๋ฆฝ ํ์
ํฌ์ธํธ: problem_type="multi_label_classification" ํ ์ค์ด๋ฉด
์์ค ํจ์๊ฐ Softmax CrossEntropy์์ BCEWithLogits๋ก ๋ฐ๋์ด.
์ฆ, ๊ฐ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ๋
๋ฆฝ์ ์ผ๋ก ํ๋จํ๊ฒ ๋๋ ๊ฑฐ์ง.
๐๏ธ STEP 4. ์ ์ฑ ์์ง โ ์๊ณ๊ฐ์ด ์๋น์ค์ ์ฑ๊ฒฉ์ ๊ฒฐ์ ํ๋ค
๋ชจ๋ธ์ด 0.63์ด๋ผ๋ ์ ์๋ฅผ ๋ฑ์์ด. ๊ทธ๋์ ์ฐจ๋จํ ๊ฑฐ์ผ, ๋ง ๊ฑฐ์ผ?
์ด ์ง๋ฌธ์ ๋ตํ๋ ๊ฒ ์ ์ฑ
์์ง์ด์ผ.
๊ทธ๋ฆฌ๊ณ ์ฌ๊ธฐ๊ฐ ์์ง๋์ด๋ง์ด ์๋๋ผ '์๋น์ค ์ฒ ํ'์ ์์ญ์ด์ง.
4-1. ์ ์ฑ ์์ง ๊ตฌํ
from dataclasses import dataclass, field
from enum import Enum
class Action(Enum):
ALLOW = "allow"
REVIEW = "review"
BLOCK = "block"
# ์นดํ
๊ณ ๋ฆฌ๋ณ ์๊ณ๊ฐ โ ์ํ๋๊ฐ ๋์์๋ก ๋ฎ๊ฒ ์ค์
THRESHOLDS = {
'self_harm': {'review': 0.30, 'block': 0.60},
'sexual': {'review': 0.45, 'block': 0.75},
'hate': {'review': 0.45, 'block': 0.78},
'violence': {'review': 0.50, 'block': 0.82},
'harassment': {'review': 0.55, 'block': 0.85},
'spam': {'review': 0.60, 'block': 0.90},
'personal_info': {'review': 0.40, 'block': 0.70},
}
@dataclass
class ModerationResult:
action: Action
scores: dict
triggered: list = field(default_factory=list)
reason: str = ""
def decide(scores: dict) -> ModerationResult:
triggered, worst = [], Action.ALLOW
for cat, score in scores.items():
th = THRESHOLDS.get(cat)
if not th:
continue
if score >= th['block']:
triggered.append((cat, score, Action.BLOCK))
worst = Action.BLOCK
elif score >= th['review']:
triggered.append((cat, score, Action.REVIEW))
if worst != Action.BLOCK:
worst = Action.REVIEW
reason = ", ".join(f"{c}={s:.2f}" for c, s, _ in triggered) or "clean"
return ModerationResult(worst, scores, triggered, reason)
4-2. ์๊ณ๊ฐ์ ์ด๋ป๊ฒ ์ ํด? โ PR ์ปค๋ธ๋ฅผ ๋ด
๊ฐ์ผ๋ก 0.5 ์ฐ์ง ๋ง๊ณ , ๋ฐ์ดํฐ๋ก ์ ํ์.
from sklearn.metrics import precision_recall_curve
import numpy as np
def find_threshold_for_precision(y_true, y_prob, target_precision=0.95):
"""์ ๋ฐ๋ ๋ชฉํ๋ฅผ ๋ง์กฑํ๋ฉด์ ์ฌํ์จ์ด ์ต๋์ธ ์๊ณ๊ฐ ์ฐพ๊ธฐ"""
precision, recall, thresholds = precision_recall_curve(y_true, y_prob)
# ๋ง์ง๋ง ์์๋ threshold ๋์์ด ์์ผ๋ฏ๋ก ์ ์ธ
precision, recall = precision[:-1], recall[:-1]
ok = precision >= target_precision
if not ok.any():
return 1.0, 0.0, 0.0
best = np.argmax(np.where(ok, recall, -1))
return thresholds[best], precision[best], recall[best]
th, p, r = find_threshold_for_precision(y_te, probs, 0.95)
print(f"์ฐจ๋จ ์๊ณ๊ฐ={th:.3f} ์ ๋ฐ๋={p:.3f} ์ฌํ์จ={r:.3f}")
โ ๏ธ ์๋ ์ฐจ๋จ์๋ "์ ๋ฐ๋"๋ฅผ ์ฐ์ ํ๋ผ
์๋ ์ฐจ๋จ ๊ตฌ๊ฐ์์ ์คํ 1๊ฑด์ ์ ์ ์ดํ๊ณผ ๋ธ๋๋ ์ ๋ขฐ ์์์ผ๋ก ์ด์ด์ ธ.
๊ทธ๋์ BLOCK ์๊ณ๊ฐ์ ์ ๋ฐ๋ 0.95 ์ด์์ ๋ชฉํ๋ก ์ก๊ณ ,
๋์น๋ ๊ฑด(์ฌํ์จ ์์ค) REVIEW ํ์์ ์ฌ๋์ด ์ก๋ ๊ตฌ์กฐ๋ก ์ค๊ณํด.
์ฆ, ๊ธฐ๊ณ๋ ํ์คํ ๊ฒ๋ง, ์ ๋งคํ ๊ฑด ์ฌ๋์๊ฒ.
๐ผ๏ธ ๋ณด๋์ค: ์ด๋ฏธ์ง ํํฐ๋ง์ CLIP์ผ๋ก ์์ํ์
ํ ์คํธ๋ง ํํฐ๋งํ๋ฉด ๋ฐ์ชฝ์ด์ผ. ์์ฆ์ ์ด๋ฏธ์ง๊ฐ ๋ ๋ฌธ์ ๊ฑฐ๋ .
๊ทผ๋ฐ ์ด๋ฏธ์ง ๋ถ๋ฅ ๋ชจ๋ธ์ ์ฒ์๋ถํฐ ํ์ต์ํค๋ ค๋ฉด ๋ฐ์ดํฐ๊ฐ ์ด๋ง์ด๋งํ๊ฒ ํ์ํด.
๊ทธ๋์ CLIP(Contrastive Language-Image Pre-training)์ ์ด๋ค.
CLIP์ ๋ง๋ฒ์ ์ด๊ฑฐ์ผ:
์ด๋ฏธ์ง์ ํ
์คํธ๋ฅผ ๊ฐ์ ๋ฒกํฐ ๊ณต๊ฐ์ ๋ฃ์ด๋ฒ๋ฆฐ๋ค.
๊ทธ๋์ ํ์ต ์์ด(Zero-shot) "์ด ์ด๋ฏธ์ง๊ฐ 'ํญ๋ ฅ์ ์ธ ์ฅ๋ฉด'๊ณผ ์ผ๋ง๋ ๊ฐ๊น์ด๊ฐ?"๋ฅผ ๊ณ์ฐํ ์ ์์ด.
import torch
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
# ํ๋ณ ๊ธฐ์ค์ '๋ฌธ์ฅ'์ผ๋ก ์ ์ํ๋ค (ํ๋กฌํํธ ์์ง๋์ด๋ง!)
PROMPTS = [
"a normal safe everyday photo",
"a photo containing graphic violence or blood",
"a sexually explicit photo",
"an image of a weapon such as a gun or knife",
"a screenshot of an advertisement or spam banner",
]
@torch.no_grad()
def classify_image(path: str):
image = Image.open(path).convert("RGB")
inputs = processor(
text=PROMPTS, images=image,
return_tensors="pt", padding=True
)
outputs = model(**inputs)
probs = outputs.logits_per_image.softmax(dim=1)[0]
return {p: round(float(s), 4) for p, s in zip(PROMPTS, probs)}
print(classify_image("sample.jpg"))
๐ก CLIP ์ค์ ํ
โ "์์ " ํ๋กฌํํธ๋ฅผ ๋ฐ๋์ ํฌํจํด. ์์ผ๋ฉด ๋ชจ๋ ์ด๋ฏธ์ง๊ฐ ๋ญ๊ฐ์ ๊ฑธ๋ ค.
โก ํ๋กฌํํธ๋ ๊ตฌ์ฒด์ ์ผ์๋ก ์ ํํด. "bad image"๋ณด๋ค "a photo containing blood"๊ฐ ํจ์ฌ ๋ซ๋ค.
โข Zero-shot์ผ๋ก ๋๋ต ๊ฑธ๋ฌ๋ธ ๋ค, ๋ชจ์ธ ๋ฐ์ดํฐ๋ก ์ ์ฉ ๋ถ๋ฅ๊ธฐ๋ฅผ ํ์ธํ๋ํ๋ ๊ฒ ์ต์ข
์ฝ์ค.
โฃ ์ ๋๋ฉ์ด์
ยท์ผ๋ฌ์คํธ ๋๋ฉ์ธ์ CLIP์ด ์ฝํด. ๋ณ๋ ๋ฐ์ดํฐ ๋ณด๊ฐ ํ์.
์ฐธ๊ณ ๋ก ์ฌ๋ฅ๋ท ๊ฐ์ ์ฌ๋ฅ ๊ณต์ ํ๋ซํผ์ฒ๋ผ
ํฌํธํด๋ฆฌ์ค ์ด๋ฏธ์ง์ ์ค๋ช
ํ
์คํธ๊ฐ ๋์์ ์ฌ๋ผ์ค๋ ์๋น์ค์์๋
์ด๋ฏธ์ง์ ํ
์คํธ๋ฅผ ํจ๊ป ํ๋จํ๋ ๋ฉํฐ๋ชจ๋ฌ ํํฐ๊ฐ ํนํ ํจ๊ณผ์ ์ด์ผ.
ํ
์คํธ๋ ๋ฉ์ฉกํ๋ฐ ์ด๋ฏธ์ง๊ฐ ์ด์ํ๊ฑฐ๋, ๊ทธ ๋ฐ๋์ธ ์ผ์ด์ค๊ฐ ์ค์ ๋ก ๊ฝค ๋ง๊ฑฐ๋ .
๐ ์ ์ฒด ์กฐ๋ฆฝ: ํ๋ก๋์ ํ์ดํ๋ผ์ธ
์ด์ ์ง๊ธ๊น์ง ๋ง๋ ๊ฑธ ํ๋๋ก ํฉ์ณ๋ณด์.
import time
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
class ContentModerator:
def __init__(self, model_path, banned_words, device=None):
self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu')
self.tokenizer = AutoTokenizer.from_pretrained(model_path)
self.model = AutoModelForSequenceClassification.from_pretrained(
model_path
).to(self.device).eval()
self.matcher = BannedWordMatcher(banned_words)
def _ml_scores(self, text: str) -> dict:
enc = self.tokenizer(
text, truncation=True, max_length=128, return_tensors='pt'
).to(self.device)
with torch.no_grad():
logits = self.model(**enc).logits[0]
probs = torch.sigmoid(logits).cpu().tolist() # ๋ฉํฐ๋ผ๋ฒจ
return dict(zip(CATEGORIES, probs))
def moderate(self, text: str) -> dict:
t0 = time.perf_counter()
# --- STEP 1: ์ ๊ทํ ---
norm = normalize_text(text)
jamo = decompose_hangul(fold_homoglyph(norm))
jamo_clean = re.sub(r'[^๊ฐ-ํฃใฑ-ใ
ใ
-ใ
ฃa-z]', '', jamo)
# --- STEP 2: ๊ท์น ํํฐ (Early Exit) ---
hits = self.matcher.find(jamo_clean)
if hits and not is_whitelisted(text, hits[0]['word']):
return {
'action': Action.BLOCK.value,
'stage': 'rule',
'reason': f"๊ธ์ง์ด ๋งค์นญ: {hits[0]['word']}",
'latency_ms': round((time.perf_counter() - t0) * 1000, 2),
}
# --- STEP 3: ML ์ถ๋ก ---
scores = self._ml_scores(norm)
# --- STEP 4: ์ ์ฑ
๊ฒฐ์ ---
result = decide(scores)
return {
'action': result.action.value,
'stage': 'ml',
'scores': {k: round(v, 4) for k, v in scores.items()},
'reason': result.reason,
'latency_ms': round((time.perf_counter() - t0) * 1000, 2),
}
FastAPI๋ก ์๋นํ๊ธฐ
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Content Moderation API")
moderator = ContentModerator('./toxic-filter/best', BANNED_WORDS)
class Req(BaseModel):
text: str
user_id: str | None = None
@app.post("/moderate")
def moderate(req: Req):
return moderator.moderate(req.text)
@app.get("/health")
def health():
return {"status": "ok"}
๐ ์ฒ๋ฆฌ๋ ์ฌ๋ฆฌ๋ 3๊ฐ์ง ์ค์ ๊ธฐ๋ฒ
โ ๋์ ๋ฐฐ์นญ โ ๋ค์ด์ค๋ ์์ฒญ์ 20~50ms ๋ชจ์์ ํ ๋ฒ์ GPU๋ก. ์ฒ๋ฆฌ๋์ด 5~10๋ฐฐ ๋ด๋ค.
โก ONNX Runtime ๋ณํ โ CPU ์ถ๋ก ์๋๊ฐ 2~3๋ฐฐ ๊ฐ์ ๋๋ ๊ฒฝ์ฐ๊ฐ ํํ๋ค.
โข ๊ฒฐ๊ณผ ์บ์ฑ โ ํ
์คํธ ํด์๋ฅผ ํค๋ก Redis์ ์ ์ฅ. ๋๋ฐฐ ๊ณต๊ฒฉ ์ ์บ์ ํํธ์จ์ด ํญ๋ฐ์ ์ผ๋ก ์ฌ๋ผ๊ฐ๋ค.
# ONNX ๋ณํ ์์
# pip install optimum[onnxruntime]
from optimum.onnxruntime import ORTModelForSequenceClassification
ort_model = ORTModelForSequenceClassification.from_pretrained(
'./toxic-filter/best', export=True
)
ort_model.save_pretrained('./toxic-filter/onnx')
๐ ํ๊ฐ: ์ ํ๋(Accuracy)๋ฅผ ๋ฏฟ์ง ๋ง๋ผ
์ง์ง ์ค์ํ ์๊ธฐ ํ๋๋ง ํ ๊ฒ.
์ ํด ์ฝํ
์ธ ๋ ๋ณดํต ์ ์ฒด์ 2~5%์ผ. ๊ทน์ฌํ ๋ถ๊ท ํ ๋ฐ์ดํฐ์ง.
๊ทธ๋ฌ๋ฉด "์ ๋ถ ์ ์์
๋๋ค"๋ผ๊ณ ๋ง ๋ตํ๋ ๋ชจ๋ธ๋ ์ ํ๋ 96%๊ฐ ๋์.
๋ง๋ ์ ๋์ง? ๊ทธ๋์ ๋ค๋ฅธ ์งํ๋ฅผ ๋ด์ผ ํด.
| ์งํ | ์๋ฏธ | ์ธ์ ์ค์ํ๊ฐ |
|---|---|---|
| Precision | ์ฐจ๋จํ ๊ฒ ์ค ์ง์ง ์ ํดํ ๋น์จ | ์คํ(์ต์ธํ ์ฐจ๋จ)์ ์ค์ด๊ณ ์ถ์ ๋ |
| Recall | ์ค์ ์ ํด๋ฌผ ์ค ์ก์๋ธ ๋น์จ | ๋์นจ์ ์ค์ด๊ณ ์ถ์ ๋ |
| F1 | ๋์ ์กฐํํ๊ท | ๊ท ํ ์กํ ๋จ์ผ ์งํ๊ฐ ํ์ํ ๋ |
| PR-AUC | PR ์ปค๋ธ ์๋ ๋ฉด์ | ๋ถ๊ท ํ ๋ฐ์ดํฐ์ ํ์ค ์งํ |
| FPR@95TPR | ์ฌํ์จ 95% ์ง์ ์ ์คํ๋ฅ | ์ด์ ๋ชฉํ ๊ธฐ์ค ์ฑ๋ฅ ํ์ธ |
์ค๋ฅ ๋ถ์: ์ง์ง ๊ฐ์ ์ ์ฌ๊ธฐ์ ๋์จ๋ค
๋ชจ๋ธ ์ฑ๋ฅ์ด ์ ๋์ฌ ๋ ํ์ดํผํ๋ผ๋ฏธํฐ๋ถํฐ ๋ง์ง์ง ๋ง.
ํ๋ฆฐ ์ผ์ด์ค๋ฅผ ๋์ผ๋ก ์ง์ ๋ด. ์ด๊ฒ ํจ์ฌ ๋นจ๋ผ.
import pandas as pd
df = pd.DataFrame({'text': X_te, 'true': y_te, 'prob': probs})
df['pred'] = (df['prob'] >= 0.5).astype(int)
# ํ์ ์ ์ฐจ์ ํ๋ฆฐ ๊ฒ๋ค = ๊ฐ์ฅ ๋ฐฐ์ธ ๊ฒ ๋ง์ ์ํ
fp = df[(df.true == 0) & (df.pred == 1)].nlargest(20, 'prob')
fn = df[(df.true == 1) & (df.pred == 0)].nsmallest(20, 'prob')
print("=== ์ต์ธํ๊ฒ ์ฐจ๋จ๋ ๊ฒ๋ค (FP) ===")
print(fp[['text', 'prob']].to_string())
print("\n=== ๋์น ๊ฒ๋ค (FN) ===")
print(fn[['text', 'prob']].to_string())
์ด๊ฑธ ํด๋ณด๋ฉด ๋ณดํต ์ด๋ฐ ํจํด์ด ๋์:
๐ ํน์ ๋จ์ด(์: ์ง์ญ๋ช
, ํน์ ์ง๋จ๋ช
)๋ง ๋์ค๋ฉด ๋ฌด์กฐ๊ฑด ์ ํด๋ก ์ฐ๋๋ค โ ๋ฐ์ดํฐ ํธํฅ
๐ ๋ฐ์ด๋ฒยทํ์๋ฅผ ์ ํ ์ดํด ๋ชป ํ๋ค โ ๋ฌธ๋งฅ ๋ฐ์ดํฐ ๋ถ์กฑ
๐ ์ธ์ฉ๋ฌธ("๊ทธ ์ฌ๋์ด ๋ํํ
~๋ผ๊ณ ํ์ด")์ ๋ฐํ๋ก ์ค์ธํ๋ค โ ๋ผ๋ฒจ๋ง ๊ฐ์ด๋ ๋ฌธ์
๐ณ๏ธ ์ค๋ฌด์์ ๋ฐ๋์ ๋ง์ฃผ์น๋ ํจ์ ๋ค
ํจ์ 1. ๋ฐ์ดํฐ ํธํฅ (Bias)
์ ๋ช
ํ ์ฐ๊ตฌ ๊ฒฐ๊ณผ๊ฐ ์์ด. ์์ด๊ถ ์ ํด์ฑ ํ์ง ๋ชจ๋ธ๋ค์ด
์ํ๋ฆฌ์นด๊ณ ๋ฏธ๊ตญ์ธ ์์ด(AAE) ํ
์คํธ๋ฅผ ์ ์๋ฏธํ๊ฒ ๋ ๋์ ํ๋ฅ ๋ก ์ ํดํ๋ค๊ณ ํ์ ํ๋ค๋ ๊ฑฐ์ผ.
(Sap et al., 2019, ACL โ "The Risk of Racial Bias in Hate Speech Detection")
ํ๊ตญ์ด์์๋ ๋๊ฐ์ ์ผ์ด ์๊ฒจ.
ํน์ ์ปค๋ฎค๋ํฐ ๋งํฌ, ํน์ ์ง์ญ ๋ฐฉ์ธ, ํน์ ์ฐ๋ น๋ ํํ์ด ๊ณผ์ ์ฐจ๋จ๋ ์ ์์ด.
๋์ ๋ฐฉ๋ฒ
โ ๊ทธ๋ฃน๋ณ ์ฑ๋ฅ ๋ถ๋ฆฌ ์ธก์ โ ์ ์ฒด F1 ๋ง๊ณ , ํ์ ์ง๋จ๋ณ FPR์ ๋ฐ๋ก ๋ด๋ผ.
โก Counterfactual ํ
์คํธ โ ๋ฌธ์ฅ์์ ์ง๋จ ๋ช
์นญ๋ง ๋ฐ๊ฟ๊ฐ๋ฉฐ ์ ์ ๋ณํ๋ฅผ ๊ด์ฐฐ. ์ ์๊ฐ ํฌ๊ฒ ํ๋ฉด ํธํฅ ์ ํธ.
โข ๋ผ๋ฒจ๋ฌ ๋ค์์ฑ ํ๋ณด โ ๋ผ๋ฒจ๋งํ๋ ์ฌ๋์ด ํธํฅ๋๋ฉด ๋ชจ๋ธ๋ ๊ทธ๋๋ก ํธํฅ๋๋ค.
# Counterfactual ํธํฅ ํ
์คํธ ์์
TEMPLATE = "๋๋ {} ์ฌ๋๋ค์ด๋ ๊ฐ์ด ์ผํ๋ ๊ฒ ์ข์"
GROUPS = ["์์ธ", "๋ถ์ฐ", "์ค๊ตญ", "์ผ๋ณธ", "์ฌ์ฑ", "๋จ์ฑ", "๋
ธ์ธ", "์ฒญ๋
"]
for g in GROUPS:
s = moderator.moderate(TEMPLATE.format(g))
print(f"{g:6s} โ hate={s['scores']['hate']:.4f}")
# ์ ์ ํธ์ฐจ๊ฐ ํฌ๋ค๋ฉด โ ํ์ต ๋ฐ์ดํฐ์ ํธํฅ์ด ์๋ค๋ ๊ฐ๋ ฅํ ์ ํธ
ํจ์ 2. ์ ๋์ ํํผ (Adversarial Evasion)
ํํฐ๋ฅผ ๋ง๋ค๋ฉด, ์ฌ๋๋ค์ ๋ฐ๋์ ๋ซ์ผ๋ ค๊ณ ํด. ๊ทธ๊ฒ๋ ์์ฃผ ์ฐฝ์์ ์ผ๋ก.
๊ทธ๋์ ํ์ต ๋จ๊ณ์์ ๋ฏธ๋ฆฌ ์ฐํ ํจํด์ ๋ง๋ค์ด ๋ฃ๋ ๋ฐ์ดํฐ ์ฆ๊ฐ์ด ํ์์ผ.
import random
def augment_evasion(text: str) -> list:
"""ํ์ต ๋ฐ์ดํฐ์ ์ฐํ ๋ณํ์ ์ธ์์ ์ผ๋ก ์ถ๊ฐ"""
variants = [text]
# 1) ๊ธ์ ์ฌ์ด ํน์๋ฌธ์ ์ฝ์
variants.append('.'.join(text))
# 2) ์ด์ฑ๋ง ๋จ๊ธฐ๊ธฐ
variants.append(''.join(
decompose_hangul(c)[0] if '๊ฐ' <= c <= 'ํฃ' else c
for c in text
))
# 3) ๋ฌด์์ ๊ณต๋ฐฑ ์ฝ์
chars = list(text)
for _ in range(max(1, len(chars) // 5)):
if len(chars) > 1:
chars.insert(random.randint(1, len(chars) - 1), ' ')
variants.append(''.join(chars))
# 4) ์ซ์ ์นํ
table = str.maketrans({'ใ
': '0', 'ใ
ฃ': '1', 'ใ
': 'ใ
'})
variants.append(decompose_hangul(text).translate(table))
return list(set(variants))
์ด๊ฑธ ํ์ต ๋ฐ์ดํฐ์ ์์ด์ฃผ๋ฉด ์ฐํ ์ ํญ์ฑ์ด ๋์ ๋๊ฒ ์ฌ๋ผ๊ฐ.
ํจ์ 3. ๊ฐ๋ ํ๋ฅ (Concept Drift)
3๊ฐ์ ์ ์ ๋ง๋ ๋ชจ๋ธ์ด ์ค๋์ ์ ๋จนํ.
์? ์ธํฐ๋ท ์ธ์ด๊ฐ ๋ฏธ์น ์๋๋ก ๋ณํ๊ฑฐ๋ .
์ด์ ๊น์ง ์๋ ์ ์กฐ์ด, ์ด์ ๊น์ง ํ๋ฒํ๋๋ฐ ์ค๋ ๋ฐ์ด ๋์ด ๋ชจ์์ด๊ฐ ๋ ๋จ์ด...
ํํฐ๋ง์ ํ ๋ฒ ๋ง๋ค๊ณ ๋๋๋ ํ๋ก์ ํธ๊ฐ ์๋๋ผ ๊ณ์ ๋๋ฆฌ๋ ์ด์์ด์ผ.
๐ ์ง์ ์ด์ ๋ฃจํ
1. ์ ์ ์ ๊ณ + ๋ชจ๋๋ ์ดํฐ ํ์ ๊ฒฐ๊ณผ๋ฅผ ๋งค์ผ ์์ง
2. ๋ชจ๋ธ ์ ์๊ฐ 0.4~0.6์ธ ์ ๋งคํ ์ํ์ ์ฐ์ ๋ผ๋ฒจ๋ง (Active Learning)
3. ์ฃผ 1ํ ๋๋ ์ 1ํ ์ฌํ์ต
4. ๊ณ ์ ๋ ๊ณจ๋ ํ
์คํธ์
์ผ๋ก ์ฑ๋ฅ ํ๊ท ์ฌ๋ถ ๊ฒ์ฆ
5. ์นด๋๋ฆฌ ๋ฐฐํฌ โ ๋ฌธ์ ์์ผ๋ฉด ์ ์ฒด ๋กค์์
ํจ์ 4. ๋ฒ์ ์๋ฌด์ ํฌ๋ช ์ฑ
๊ธฐ์ ๋ง ์ํ๋ค๊ณ ๋์ด ์๋์ผ. ๋ฒ์ด ๋ฐ๋ผ์.
ํ๊ตญ์ ์ ๋ณดํต์ ๋ง๋ฒ์ ๋ช
์ํผ์ ๋ฑ ๊ถ๋ฆฌ์นจํด ์ ๋ณด์ ๋ํ ์์์กฐ์น ์ ๋๋ฅผ ๋๊ณ ์์ด.
EU์ ๋์งํธ์๋น์ค๋ฒ(DSA)์ ์ฝํ
์ธ ์กฐ์น ์ ์ด์ ๋ฅผ ๋ช
์์ ์ผ๋ก ๊ณ ์งํ๊ณ
์ด์์ ๊ธฐ ์ ์ฐจ๋ฅผ ์ ๊ณตํ๋๋ก ์๊ตฌํ์ง.
์ฆ, ์ฝ๋ ๋ ๋ฒจ์์๋ ์ด๋ฐ ๊ฒ ํ์ํด:
@dataclass
class AuditLog:
content_id: str
action: str
model_version: str # ์ด๋ค ๋ชจ๋ธ์ด ํ๋จํ๋๊ฐ
scores: dict # ์ ์ ๊ทผ๊ฑฐ
policy_version: str # ์ด๋ค ์ ์ฑ
๊ธฐ์ค์ด์๋
timestamp: float
appealable: bool = True # ์ด์์ ๊ธฐ ๊ฐ๋ฅ ์ฌ๋ถ
reviewer_id: str = "" # ์ฌ๋์ด ๊ฒํ ํ๋ค๋ฉด ๋๊ฐ
"์ ์ฐจ๋จ๋๋์ง ์ค๋ช ํ ์ ์๋ ์์คํ "์ ๊ธฐ์ ์ ์ผ๋ก๋, ๋ฒ์ ์ผ๋ก๋ ์ํํด.
๐ฐ ๋น์ฉ ์ต์ ํ: ํ์ค์ ์ธ ์ด์ผ๊ธฐ
๋ชจ๋ ์์ฒญ์ GPU ํธ๋์คํฌ๋จธ์ ํ์ฐ๋ฉด? ํต์ฅ์ด ๋ น์.
๊ทธ๋์ ๊ณ์ธตํ(Cascade) ๊ตฌ์กฐ๊ฐ ๋ต์ด์ผ.
| ๊ณ์ธต | ์ฒ๋ฆฌ ๋น์ค | ๋น์ฉ | ๋ด๋น |
|---|---|---|---|
| L1 ์ ๊ทํ + ์บ์ | ~30% | ๊ฑฐ์ 0 | ์ค๋ณต/๋๋ฐฐ ์ฆ์ ์ฐจ๋จ |
| L2 ๊ท์น + ๊ฒฝ๋ ML | ~55% | ๋งค์ฐ ๋ฎ์ | ๋ช ๋ฐฑํ ์ผ์ด์ค ํ์ |
| L3 ํธ๋์คํฌ๋จธ | ~14% | ์ค๊ฐ | ์ ๋งคํ ์ผ์ด์ค ์ ๋ฐ ํ์ |
| L4 ์ฌ๋ ๊ฒํ | ~1% | ๋์ | ์ต์ข ํ๋จ + ๋ผ๋ฒจ ํ๋ณด |
import hashlib
class CascadeModerator:
def __init__(self, moderator, cache):
self.m = moderator
self.cache = cache # Redis ๋ฑ
def moderate(self, text: str):
# L1: ์บ์ ํ์ธ
key = "mod:" + hashlib.sha256(text.encode()).hexdigest()[:32]
cached = self.cache.get(key)
if cached:
return {'cached': True, **json.loads(cached)}
result = self.m.moderate(text)
# ํ์ ๊ฒฐ๊ณผ๋ง ์บ์ฑ (review๋ ์ ์ฑ
๋ณ๊ฒฝ ์ฌ์ง๊ฐ ์์ผ๋ ์ ์ธ)
if result['action'] in ('allow', 'block'):
self.cache.setex(key, 86400, json.dumps(result))
return result
๐ก ๊ฒฝ๋ํ ์ต์ ์ ๋ฆฌ
์ง์ ์ฆ๋ฅ(Distillation) โ ํฐ ๋ชจ๋ธ์ ์ถ๋ ฅ์ ์์ ๋ชจ๋ธ์๊ฒ ๊ฐ๋ฅด์น๋ค. ํฌ๊ธฐ 1/3, ์ฑ๋ฅ ์์ค ์ต์.
์์ํ(Quantization) โ FP32 โ INT8. ๋ชจ๋ธ ํฌ๊ธฐ ์ฝ 1/4, CPU ์ถ๋ก ๋ํญ ๊ฐ์.
DistilBERT ๊ณ์ด โ ์ฒ์๋ถํฐ ๊ฒฝ๋ ์ํคํ
์ฒ ์ ํ.
๐งญ ๋ง๋ฌด๋ฆฌ: ํํฐ๋ ๊ธฐ์ ์ด ์๋๋ผ 'ํ๋'๋ค
์ฌ๊ธฐ๊น์ง ์์ผ๋ฉด ๋๋ ์ด์ ์ด๋ฐ ๊ฑธ ๋ง๋ค ์ ์์ด:
โ
์ ๋์ฝ๋ยท์๋ชจ ์ฐํ๋ฅผ ๋ฌด๋ ฅํํ๋ ์ ๊ทํ ํ์ดํ๋ผ์ธ
โ
Aho-Corasick ๊ธฐ๋ฐ์ ์ด๊ณ ์ ๊ธ์ง์ด ๋งค์นญ
โ
ํ๊ตญ์ด ํธ๋์คํฌ๋จธ ๋ฉํฐ๋ผ๋ฒจ ์ ํด์ฑ ๋ถ๋ฅ๊ธฐ
โ
์นดํ
๊ณ ๋ฆฌ๋ณ ์๊ณ๊ฐ ์ ์ฑ
์์ง
โ
CLIP ๊ธฐ๋ฐ ์ ๋ก์ท ์ด๋ฏธ์ง ํํฐ
โ
์บ์์ ๊ณ์ธต ๊ตฌ์กฐ๋ก ๋น์ฉ์ ํต์ ํ๋ ์๋น ์ค๊ณ
๊ทผ๋ฐ ๋ง์ง๋ง์ผ๋ก ์ด ์๊ธฐ๋ ๊ผญ ํ๊ณ ์ถ์ด.
์๋ฒฝํ ํํฐ๋ ์กด์ฌํ์ง ์๋๋ค.
๊ฐ์ ๋ฌธ์ฅ๋ ๋๊ฐ ๋๊ตฌ์๊ฒ ์ด๋ค ๋งฅ๋ฝ์์ ๋งํ๋๋์ ๋ฐ๋ผ
๋๋ด์ด ๋๊ธฐ๋ ํ๊ณ ํญ๋ ฅ์ด ๋๊ธฐ๋ ํด.
์ฌ๋๋ ํท๊ฐ๋ฆฌ๋ ๊ฑธ ๋ชจ๋ธ์ด ์๋ฒฝํ๊ฒ ๋ง์ถ ๋ฆฌ ์์ง.
๊ทธ๋์ ์ข์ ํํฐ๋ง ์์คํ
์ ์กฐ๊ฑด์ '์ ๋ ์ ํ๋ฆฌ๋ ๊ฒ'์ด ์๋๋ผ
'ํ๋ ธ์ ๋ ๋น ๋ฅด๊ฒ ๋๋๋ฆด ์ ์๋ ๊ฒ'์ด์ผ.
์ด์์ ๊ธฐ ์ฐฝ๊ตฌ, ๊ฐ์ฌ ๋ก๊ทธ, ์ฌ๋ ๊ฒํ ํ, ์ฌํ์ต ๋ฃจํ.
์ด ๋ค ๊ฐ์ง๊ฐ ๋ชจ๋ธ ์ ํ๋๋ณด๋ค ํจ์ฌ ์ค์ํด.
๊ธฐ์ ์ ์ผ๋ก๋ ์๊ณ๊ฐ ํ๋ ์กฐ์ ํ๋ ์ผ์ด์ง๋ง,
๊ทธ ์ซ์ ํ๋๊ฐ ๋๊ตฐ๊ฐ์ ๋ฐ์ธ์ ์ง์ฐ๊ธฐ๋ ํ๊ณ , ๋๊ตฐ๊ฐ๋ฅผ ์ง์ผ์ฃผ๊ธฐ๋ ํด.
๊ทธ๋์ ์ฝํ
์ธ ํํฐ๋ง ๊ฐ๋ฐ์ ์์ง๋์ด๋ง์ธ ๋์์ ์ค๋ฆฌ์ ์ค๊ณ์ผ.
ํน์ ์ด๋ฐ ์์คํ
์ ์ง์ ๊ตฌ์ถํด์ผ ํ๋๋ฐ ๋ง๋งํ๋ค๋ฉด,
์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๋จธ์ ๋ฌ๋ ์์ง๋์ด์ ํ์
ํด๋ณด๋ ๊ฒ๋ ๋ฐฉ๋ฒ์ด์ผ.
ํนํ ๋ผ๋ฒจ๋ง ๋ฐ์ดํฐ ๊ตฌ์ถ์ด๋ ๋๋ฉ์ธ๋ณ ์๊ณ๊ฐ ํ๋์ฒ๋ผ
๊ฒฝํ์น๊ฐ ๊ฒฐ์ ์ ์ธ ์์ญ์ ํผ์๋ณด๋ค ๊ฐ์ด ํ๋ ๊ฒ ํจ์ฌ ๋น ๋ฅด๊ฑฐ๋ .
์, ์ด์ pip install transformers ์น๊ณ ์์ํด๋ณด์. ๐
์ฒซ ๋ชจ๋ธ์ ๋ถ๋ช
์๋ง์ผ ๊ฑฐ์ผ. ๊ทผ๋ฐ ๊ด์ฐฎ์. ๋ค๋ค ๊ทธ๋ฌ์ด.
์ค์ํ ๊ฑด ์ฒซ ๋ฒ์ ์ ๋ฐฐํฌํ๊ณ , ๋ก๊ทธ๋ฅผ ๋ณด๊ณ , ๋ค์ ๊ณ ์น๋ ๊ทธ ๋ฃจํ๋ฅผ ๋๋ ๊ฑฐ์ผ.
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.