์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿ›ก๏ธ Python์œผ๋กœ AI ์ฝ˜ํ…์ธ  ํ•„ํ„ฐ๋ง ์‹œ์Šคํ…œ ๋งŒ๋“ค๊ธฐ โ€” ์š•์„คยท์ŠคํŒธยท์œ ํ•ด ์ด๋ฏธ์ง€๊นŒ์ง€ ๊ฑธ๋Ÿฌ๋‚ด๋Š” ์‹ค์ „ ํŒŒ์ดํ”„๋ผ์ธ ๐Ÿ
AI / ๋จธ์‹ ๋Ÿฌ๋‹ ๊ฐœ๋ฐœ

๐Ÿ›ก๏ธ Python์œผ๋กœ AI ์ฝ˜ํ…์ธ  ํ•„ํ„ฐ๋ง ์‹œ์Šคํ…œ ๋งŒ๋“ค๊ธฐ โ€” ์š•์„คยท์ŠคํŒธยท์œ ํ•ด ์ด๋ฏธ์ง€๊นŒ์ง€ ๊ฑธ๋Ÿฌ๋‚ด๋Š” ์‹ค์ „ ํŒŒ์ดํ”„๋ผ์ธ ๐Ÿ

ํ‚ค์›Œ๋“œ ๊ธˆ์ง€์–ด ๋ชฉ๋ก ํ•˜๋‚˜๋กœ ๋ฒ„ํ‹ฐ๋˜ ์‹œ๋Œ€๋Š” ๋๋‚ฌ์–ด.
์ด์ œ๋Š” ์ž„๋ฒ ๋”ฉ, ํŠธ๋žœ์Šคํฌ๋จธ, ๊ทธ๋ฆฌ๊ณ  ์•ฝ๊ฐ„์˜ ์ •๊ทœ์‹ ์„ผ์Šค๊ฐ€ ํ•„์š”ํ•ด.

๐Ÿœ ๋“ค์–ด๊ฐ€๋ฉฐ: "ใ…….ใ…‚" ์€ ์™œ ์•ˆ ๊ฑธ๋Ÿฌ์งˆ๊นŒ?

์นœ๊ตฌ์•ผ, ํ˜น์‹œ ์ปค๋ฎค๋‹ˆํ‹ฐ ์šด์˜ํ•ด๋ณธ ์  ์žˆ์–ด? ์—†๋‹ค๊ณ ? ๊ทธ๋Ÿผ ์ƒ์ƒ๋งŒ ํ•ด๋ด.

์ƒˆ๋ฒฝ 3์‹œ์— ์•Œ๋ฆผ์ด ์šธ๋ ค. ๋ˆ„๊ตฐ๊ฐ€ ๊ฒŒ์‹œํŒ์— ๋„๋ฐฐ๋ฅผ ์‹œ์ž‘ํ–ˆ๊ฑฐ๋“ .
๋„ˆ๋Š” ๋ถ€๋žด๋ถ€๋žด ๋…ธํŠธ๋ถ์„ ์—ด๊ณ  ๊ธˆ์ง€์–ด ๋ชฉ๋ก์— ๋‹จ์–ด ํ•˜๋‚˜๋ฅผ ์ถ”๊ฐ€ํ•ด.
๊ทธ๋ฆฌ๊ณ  10๋ถ„ ๋’ค, ๊ทธ ์‚ฌ๋žŒ์€ ใ…… ใ…‚, ใ…….ใ…‚, ์‹œ1๋ฐœ, ใ…†ใ…ฃ๋ฐœ ์ด๋Ÿฐ ์‹์œผ๋กœ ์šฐํšŒํ•ด์„œ ๋‹ค์‹œ ์˜ฌ๋ ค.

์ด๊ฒŒ ๋ฐ”๋กœ ๊ทœ์น™ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง์˜ ํ•œ๊ณ„์•ผ.
์‚ฌ๋žŒ์€ ์ฐฝ์˜์ ์ด๊ฑฐ๋“ . ํŠนํžˆ ์š•ํ•  ๋•Œ๋Š” ๋”๋”์šฑ.

๋ฐ˜๋Œ€๋กœ ์ด๋Ÿฐ ๊ฒฝ์šฐ๋„ ์žˆ์–ด.
"์ด ๊น€์น˜์ฐŒ๊ฐœ ์ง„์งœ ๋ฏธ์นœ ๋ง›์ง‘์ด๋‹ค"๋ผ๋Š” ๋ฌธ์žฅ์ด ๊ธˆ์ง€์–ด ๋ฏธ์นœ ๋•Œ๋ฌธ์— ์ฐจ๋‹จ๋ผ.
์‚ฌ์žฅ๋‹˜์€ ์–ต์šธํ•˜๊ณ , ์œ ์ €๋Š” ๋– ๋‚˜๊ณ , ๋„ˆ๋Š” CS ์ง€์˜ฅ์— ๋น ์ง€์ง€.

ํ•ต์‹ฌ ๋”œ๋ ˆ๋งˆ ๋‘ ๊ฐ€์ง€

โ‘  False Negative(๋†“์นจ) โ€” ๋‚˜์œ ๊ฑธ ๋ชป ๊ฑธ๋Ÿฌ๋ƒ„ โ†’ ์ปค๋ฎค๋‹ˆํ‹ฐ ๋งํ•จ
โ‘ก False Positive(์˜คํƒ) โ€” ๋ฉ€์ฉกํ•œ ๊ฑธ ์ฐจ๋‹จํ•จ โ†’ ์œ ์ € ๋– ๋‚จ

AI ์ฝ˜ํ…์ธ  ํ•„ํ„ฐ๋ง์€ ์ด ๋‘˜ ์‚ฌ์ด์—์„œ ์ตœ์ ์˜ ์ง€์ ์„ ์ฐพ๋Š” ๊ธฐ์ˆ ์ด์•ผ.
100% ์ •ํ™•ํ•œ ํ•„ํ„ฐ๋Š” ์„ธ์ƒ์— ์—†์–ด. ์ง„์งœ๋กœ. OpenAI๋„, Google๋„ ๋ชป ๋งŒ๋“ค์—ˆ์–ด.

์˜ค๋Š˜ ์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค ๊ฑด ์ด๊ฑฐ์•ผ. ๐Ÿ‘‡

4๋‹จ๊ณ„ ์ฝ˜ํ…์ธ  ํ•„ํ„ฐ๋ง ํŒŒ์ดํ”„๋ผ์ธ STEP 1 ์ •๊ทœํ™” Normalize STEP 2 ๊ทœ์น™ ํ•„ํ„ฐ Rule / Regex STEP 3 ML ๋ถ„๋ฅ˜๊ธฐ Transformer STEP 4 ์ •์ฑ… ์—”์ง„ Policy ๊ฒฐ์ • ๊ฐ ๋‹จ๊ณ„์˜ ์ฒ˜๋ฆฌ ์†๋„ (์ฐธ๊ณ ์น˜) ~0.05 ms ~0.3 ms ~15 ms (GPU) ~0.1 ms ์•ž ๋‹จ๊ณ„์—์„œ ํ™•์‹คํžˆ ๊ฑธ๋Ÿฌ์ง€๋ฉด ๋’ท ๋‹จ๊ณ„๋Š” ๊ฑด๋„ˆ๋›ด๋‹ค (Early Exit) ์ „์ฒด ํŠธ๋ž˜ํ”ฝ์˜ 80~90%๋Š” STEP 1~2์—์„œ ์ข…๋ฃŒ โ†’ GPU ๋น„์šฉ ๋Œ€ํญ ์ ˆ๊ฐ $ โšก

์ž, ์ด์ œ ํ•˜๋‚˜์”ฉ ๋ถ€์ˆด๋ณด์ž. ์ฝ”๋“œ๋Š” ์ „๋ถ€ ์‹ค์ œ๋กœ ๋Œ์•„๊ฐ€๋Š” ํŒŒ์ด์ฌ์ด์•ผ.

๐Ÿงผ 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์ž ๊ธฐ์ค€, ์ฐธ๊ณ ์น˜)

๋ฐฉ์‹์‹œ๊ฐ„ ๋ณต์žก๋„์ฒด๊ฐ ์†๋„
๋‹จ์ˆœ ๋ฐ˜๋ณต inO(Nร—M)๋А๋ฆผ (์ˆ˜์‹ญ ms)
์ •๊ทœ์‹ | ๊ฒฐํ•ฉ๋ฐฑํŠธ๋ž˜ํ‚น ์œ„ํ—˜๋ถˆ์•ˆ์ •
Aho-CorasickO(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-baseKLUE ๋ฒค์น˜๋งˆํฌ ๊ธฐ์ค€ ํ•œ๊ตญ์–ด ๋ฒ”์šฉ ์„ฑ๋Šฅ ์šฐ์ˆ˜์ผ๋ฐ˜์ ์ธ ๋ถ„๋ฅ˜ ์ž‘์—…
beomi/KcELECTRA-base๋Œ“๊ธ€ยท๊ตฌ์–ด์ฒด ๋ฐ์ดํ„ฐ๋กœ ํ•™์Šต, ์‹ ์กฐ์–ด์— ๊ฐ•ํ•จ๋Œ“๊ธ€/์ปค๋ฎค๋‹ˆํ‹ฐ ํ•„ํ„ฐ๋ง
monologg/koelectra-base-v3ELECTRA ๊ตฌ์กฐ, ๊ฒฝ๋Ÿ‰ ๋Œ€๋น„ ์„ฑ๋Šฅ ์–‘ํ˜ธ์ถ”๋ก  ์†๋„ ์ค‘์š”ํ•  ๋•Œ
xlm-roberta-base100๊ฐœ ์–ธ์–ด ์ง€์›๋‹ค๊ตญ์–ด ์„œ๋น„์Šค

๋Œ“๊ธ€ ํ•„ํ„ฐ๋ง์ด๋ฉด 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์ด๋ผ๋Š” ์ ์ˆ˜๋ฅผ ๋ฑ‰์—ˆ์–ด. ๊ทธ๋ž˜์„œ ์ฐจ๋‹จํ•  ๊ฑฐ์•ผ, ๋ง ๊ฑฐ์•ผ?

์ด ์งˆ๋ฌธ์— ๋‹ตํ•˜๋Š” ๊ฒŒ ์ •์ฑ… ์—”์ง„์ด์•ผ.
๊ทธ๋ฆฌ๊ณ  ์—ฌ๊ธฐ๊ฐ€ ์—”์ง€๋‹ˆ์–ด๋ง์ด ์•„๋‹ˆ๋ผ '์„œ๋น„์Šค ์ฒ ํ•™'์˜ ์˜์—ญ์ด์ง€.

์ž„๊ณ„๊ฐ’์— ๋”ฐ๋ฅธ 3๊ตฌ๊ฐ„ ์ •์ฑ… ์„ค๊ณ„ ์ž๋™ ํ†ต๊ณผ ์‚ฌ๋žŒ ๊ฒ€ํ†  ์ž๋™ ์ฐจ๋‹จ 0.0 0.50 0.85 1.0 LOW ์ฆ‰์‹œ ๊ฒŒ์‹œ ๋กœ๊ทธ๋งŒ ๊ธฐ๋ก ์•ฝ 85% ํŠธ๋ž˜ํ”ฝ MEDIUM ๊ฒŒ์‹œ + ํ ๋“ฑ๋ก ๋˜๋Š” ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ ์•ฝ 12% ํŠธ๋ž˜ํ”ฝ HIGH ๊ฒŒ์‹œ ์ฐจ๋‹จ ์ด์˜์ œ๊ธฐ ์•ˆ๋‚ด ์•ฝ 3% ํŠธ๋ž˜ํ”ฝ ์ž„๊ณ„๊ฐ’์€ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๋‹ฌ๋ผ์•ผ ํ•œ๋‹ค ์žํ•ดยท์•„๋™์•ˆ์ „ = ๋‚ฎ๊ฒŒ(๋ฏผ๊ฐํ•˜๊ฒŒ) / ๊ฒฝ๋ฏธํ•œ ๋น„์†์–ด = ๋†’๊ฒŒ(๊ด€๋Œ€ํ•˜๊ฒŒ)

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-AUCPR ์ปค๋ธŒ ์•„๋ž˜ ๋ฉด์ ๋ถˆ๊ท ํ˜• ๋ฐ์ดํ„ฐ์˜ ํ‘œ์ค€ ์ง€ํ‘œ
FPR@95TPR์žฌํ˜„์œจ 95% ์ง€์ ์˜ ์˜คํƒ๋ฅ ์šด์˜ ๋ชฉํ‘œ ๊ธฐ์ค€ ์„ฑ๋Šฅ ํ™•์ธ
์ž„๊ณ„๊ฐ’์„ ์˜ฌ๋ฆฌ๋ฉด ๋ฌด์Šจ ์ผ์ด ์ƒ๊ธฐ๋‚˜ ์ฐจ๋‹จ ์ž„๊ณ„๊ฐ’ (Threshold) ์ง€ํ‘œ ๊ฐ’ 0.1 0.3 0.5 0.7 0.9 0.5 0.8 1.0 Precision โ†‘ Recall โ†“ ๊ต์ฐจ์  โ‰ˆ ๊ท ํ˜•

์˜ค๋ฅ˜ ๋ถ„์„: ์ง„์งœ ๊ฐœ์„ ์€ ์—ฌ๊ธฐ์„œ ๋‚˜์˜จ๋‹ค

๋ชจ๋ธ ์„ฑ๋Šฅ์ด ์•ˆ ๋‚˜์˜ฌ ๋•Œ ํ•˜์ดํผํŒŒ๋ผ๋ฏธํ„ฐ๋ถ€ํ„ฐ ๋งŒ์ง€์ง€ ๋งˆ.
ํ‹€๋ฆฐ ์ผ€์ด์Šค๋ฅผ ๋ˆˆ์œผ๋กœ ์ง์ ‘ ๋ด. ์ด๊ฒŒ ํ›จ์”ฌ ๋นจ๋ผ.

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
๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค.