248 KiB
248 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""전장 SNS(텔레그램, X 등) 텍스트·이미지 게시물의 수집 메타데이터, 계정·전달 경로, 주장 내용(지역·시각), 수집 지연 등 OSINT 원천 데이터셋 스키마를 정의하고 가상 샘플 게시물을 검증한다."""
import json
import re
import numpy as np
import pandas as pd
ISO = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$"
# 1) 스키마 정의: 게시물 기본 정보, 미디어, 수집 경위, 주장 내용, 수집 조건(위치 불확실성·지연), 평가 상태
SCHEMA = {
"required": ["post_id", "platform", "account_id", "posted_at", "text", "media", "collection", "claim", "assessment"],
"fields": {
"post_id": {"type": str, "pattern": r"^P\d{4}$"},
"platform": {"type": str, "enum": ["telegram", "x", "other"]},
"account_id": {"type": str, "pattern": r"^ACC_[A-Z]{3}\d{2}$"},
"posted_at": {"type": str, "pattern": ISO},
"text": {"type": str, "minlen": 1, "maxlen": 4000},
"lang": {"type": str, "enum": ["ko", "en", "uk", "ru", "other"]},
},
"media": {
"type": {"type": str, "enum": ["image", "video"]},
"width": {"type": int, "min": 1, "max": 10000},
"height": {"type": int, "min": 1, "max": 10000},
"phash": {"type": str, "pattern": r"^[0-9a-f]{16}$"},
},
"collection": {
"method": {"type": str, "enum": ["public_api", "manual_capture", "partner_share"]},
"collected_at": {"type": str, "pattern": ISO},
"collector": {"type": str, "pattern": r"^COL-\d{2}$"},
"delay_min": {"type": (int, float), "min": 0, "max": 100000},
},
"claim": {
"region_code": {"type": str, "pattern": r"^[A-F]-\d$"},
"geo_uncertainty_km": {"type": (int, float), "min": 0, "max": 500},
},
# 수집 단계에서는 진위·출처 판정을 적지 않는다(정보관 검토 후 별도 기록)
"assessment": {"status": {"type": str, "enum": ["unassessed"]}},
}
def check(name, value, rule):
# [TODO : 학생 실습 1] 전장 SNS(텔레그램, X 등) 텍스트/이미지 게시물 수집 및 OSINT 원천 데이터셋 정의 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def validate(rec):
# [TODO : 학생 실습 1] 전장 SNS(텔레그램, X 등) 텍스트/이미지 게시물 수집 및 OSINT 원천 데이터셋 정의 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
rng = np.random.default_rng(1)
posts = []
for i in range(20):
r = f"{'ABCDEF'[int(rng.integers(6))]}-{int(rng.integers(1, 6))}"
posts.append({"post_id": f"P{i:04d}", "platform": str(rng.choice(["telegram", "x", "other"])),
"account_id": f"ACC_{rng.choice(['MED', 'WIT', 'AGG', 'PRO'])}{int(rng.integers(0, 30)):02d}",
"posted_at": f"2026-05-0{int(rng.integers(1, 9))}T1{int(rng.integers(0, 9))}:0{int(rng.integers(0, 9))}Z",
"text": f"가상 {r} 지역에서 군용 차량 목격, {int(rng.integers(2, 15))}대", "lang": "ko",
"media": [{"type": "image", "width": 32, "height": 32, "phash": "".join(rng.choice(list("0123456789abcdef"), 16))}],
"forwarded_from": f"P{int(rng.integers(0, max(i, 1))):04d}" if i and rng.random() < .4 else None,
"collection": {"method": str(rng.choice(["public_api", "manual_capture", "partner_share"])), "collected_at": "2026-05-09T12:00Z",
"collector": f"COL-{int(rng.integers(1, 20)):02d}", "delay_min": int(rng.integers(1, 600))},
"claim": {"region_code": r, "geo_uncertainty_km": float(round(rng.uniform(0.5, 20), 1))},
"assessment": {"status": "unassessed"}})
# 3) 정상 데이터 검증
ok = sum(1 for p in posts if not validate(p))
print(f"정상 샘플 {len(posts)}건 중 검증 통과 {ok}건, 오류 {len(posts) - ok}건 (목표: 오류 0건 {'달성' if ok == len(posts) else '미달'})")
# 4) 오류 주입 시험
bad = json.loads(json.dumps(posts[3]))
bad["platform"] = "unknown_app"
bad["posted_at"] = "2026/05/01 10:00"
del bad["collection"]["method"]
bad["media"][0]["phash"] = "XYZ"
bad["text"] = ""
print("\n[오류 주입 결과] 주입 5종")
for e in validate(bad):
print(" -", e)
# 5) 스키마 필드 정의서(명세서 초안)
rows = [("기본", k, r["type"].__name__, str({x: y for x, y in r.items() if x != "type"})) for k, r in SCHEMA["fields"].items()]
for g in ("media", "collection", "claim", "assessment"):
for k, r in SCHEMA[g].items():
t = r["type"].__name__ if isinstance(r["type"], type) else "number"
rows.append((g, k, t, str({x: y for x, y in r.items() if x != "type"})))
spec = pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙"])
print("\n[데이터 명세 요약]")
print(spec.to_string(index=False))
print(f"\n필드 정의 {len(spec)}개 + forwarded_from(전달 원본, 선택). 수집 지연·위치 불확실성은 '통신지연·위치오차' 전술 파라미터에 해당한다.")
print(f"전달 경로가 있는 게시물 {sum(p['forwarded_from'] is not None for p in posts)}건: 출처 역추적(미션 3·7)에 사용")
In [ ]:
"""SNS 게시물 텍스트의 유니코드 정규화·URL·멘션·특수문자·이모지·불용어 제거와 결측 처리, 이미지 지각 해시(pHash) 추출을 수행하고, 정규화 본문과 이미지 해시로 중복 게시물을 걸러내는 전처리 모듈을 구현해 처리 속도와 정합성을 검증한다."""
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 2] 게시물 텍스트 불용어 정제, 이미지 해시(pHash) 추출 및 중복 제거 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 2] 게시물 텍스트 불용어 정제, 이미지 해시(pHash) 추출 및 중복 제거 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
posts, acc = make_posts(60, seed=7)
rng = np.random.default_rng(3)
FIRE, FLAG = chr(0x1F525), chr(0x1F6A8) # 이모지(코드값으로 생성)
for p in posts: # 수집 과정의 잡음 주입
r = rng.random()
if r < .15:
p["text"] += f" https://example.invalid/{rng.integers(1e6)}"
elif r < .3:
p["text"] = "@ch_virtual " + p["text"] + " " + FIRE + FLAG
elif r < .4:
p["text"] = unicodedata.normalize("NFKC", p["text"]).replace("A", "A").replace("1", "1") # 전각 문자
elif r < .43:
p["text"] = None # 본문 결측(이미지만 게시)
# 1) 처리 속도와 오류율
t0, out, fails = time.perf_counter(), [], 0
for p in posts:
try:
txt = clean_text(p["text"] or "")
out.append({"id": p["post_id"], "t": p["posted_at"], "clean": txt, "missing": p["text"] is None,
"hash": phash(p["image"]) if p["image"] is not None else None})
except Exception:
fails += 1
dt = time.perf_counter() - t0
print(f"전처리 {len(posts)}건, {len(posts) / dt:,.0f}건/초 (목표 1,000건/초 이상), 실패 {fails}건 -> 오류율 {fails / len(posts):.0%}")
miss = [o for o in out if o["missing"]]
print(f"본문 결측 {len(miss)}건: 빈 문자열로 보정하고 '이미지 단독 게시물'로 표시(보정율 100%)")
# 2) 중복 제거: 같은 정규화 본문 + 이미지 해시 거리 4 이하(또는 둘 다 없음)는 같은 게시물로 보고 가장 이른 것만 남긴다
kept, dup_of = [], {}
for o in sorted(out, key=lambda x: x["t"]):
same = next((k for k in kept if k["clean"] == o["clean"] and o["clean"] and
((k["hash"] is None and o["hash"] is None) or (k["hash"] is not None and o["hash"] is not None and hamming(k["hash"], o["hash"]) <= 4))), None)
if same:
dup_of[o["id"]] = same["id"]
else:
kept.append(o)
kinds = {p["post_id"]: p["kind"] for p in posts}
print(f"중복 제거: {len(out)}건 -> {len(kept)}건 (제거 {len(dup_of)}건, 그중 실제 재게시(copy) {sum(kinds[d] == 'copy' for d in dup_of)}건 / 조직적 동시 게시 {sum(kinds[d] == 'bot_burst' for d in dup_of)}건)")
print(f"전각 문자·이모지·URL 차이는 정제로 흡수되고, 본문 결측 등으로 남은 재게시 {sum(kinds[k['id']] == 'copy' for k in kept)}건은 미션 3의 유사도 판별로 넘긴다")
# 3) 단위 테스트 5종
e1, e2 = event_image(1), event_image(2)
tests = {
"URL·멘션 제거": clean_text("@abc 보고 https://x.invalid/1 확인") == "보고 확인",
"이모지·특수문자 제거": FIRE not in clean_text("정전 " + FIRE + "!!") and "!" not in clean_text("정전!!"),
"전각 문자 정규화(NFKC)": clean_text("A-1 정전") == "A-1 정전",
"pHash 재압축 강건성(거리 8 이하)": hamming(phash(e1), phash(ndimage.gaussian_filter(e1, 0.6) + rng.normal(0, 3, e1.shape))) <= 8,
"pHash 다른 사진 구분(거리 16 이상)": hamming(phash(e1), phash(e2)) >= 16,
}
for k, v in tests.items():
print(f"[{'PASS' if v else 'FAIL'}] {k}")
print(f"단위 테스트 {sum(tests.values())}/5 통과")
# 4) 정제 전후 예시
ex = [next(p for p in posts if p["text"] and key in p["text"]) for key in ("http", "@", "\uff21")]
print("\n[정제 전후 예시]")
for p in ex:
print(" 전:", p["text"].replace(FIRE, "<U+1F525>").replace(FLAG, "<U+1F6A8>"))
print(" 후:", clean_text(p["text"]))
d_same = [hamming(phash(p["image"]), phash(posts[int(p["origin"][1:])]["image"])) for p in posts if p["kind"] in ("copy", "near_dup") and p["image"] is not None]
d_diff = [hamming(phash(posts[i]["image"]), phash(posts[j]["image"])) for i, j in rng.integers(0, len(posts), (200, 2))
if posts[i]["event_id"] != posts[j]["event_id"] and posts[i]["image"] is not None and posts[j]["image"] is not None]
plt.figure(figsize=(7, 4))
plt.hist([d_same, d_diff], bins=32, range=(0, 64), label=["same event (repost)", "different events"])
plt.axvline(8, ls="--", c="k"); plt.xlabel("pHash Hamming distance"); plt.ylabel("pairs"); plt.legend(); plt.title("Perceptual hash distance (synthetic images)")
plt.show()
In [ ]:
"""게시물 쌍의 본문 유사도(문자 n-gram TF-IDF 코사인)와 이미지 지각 해시 거리로 단순 복제·재작성(인용)·무관 관계를 판별하고, 같은 이야기로 묶인 게시물 중 가장 이른 것을 1차 출처로 분류하는 Baseline 알고리즘을 구현해 정확도와 처리 지연을 측정한다."""
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
from sklearn.feature_extraction.text import TfidfVectorizer
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 3] 게시물 유사도 기반 단순 복제본 탐지 및 1차 출처 분류 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 3] 게시물 유사도 기반 단순 복제본 탐지 및 1차 출처 분류 Baseline 알고리즘 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
def relation(sim, dist, R):
if sim >= R["copy_sim"] and (dist is None or dist <= R["copy_ham"]):
return "복제"
if sim >= R["quote_sim"] or (dist is not None and dist <= R["quote_ham"]):
return "재작성"
return "무관"
R0 = {"copy_sim": 0.95, "copy_ham": 6, "quote_sim": 0.45, "quote_ham": 10}
posts, acc = make_posts(60, seed=11)
true_posts = [p for p in posts if not p["is_disinfo"]]
idx = {p["post_id"]: i for i, p in enumerate(posts)}
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit_transform([clean_text(p["text"]) for p in posts])
H = [phash(p["image"]) if p["image"] is not None else None for p in posts]
dist = lambda i, j: hamming(H[i], H[j]) if H[i] is not None and H[j] is not None else None
# 1) 평가용 게시물 쌍: 복제(전달), 재작성, 무관(다른 사건)
rng = np.random.default_rng(0)
pairs = [(idx[p["post_id"]], idx[p["origin"]], {"copy": "복제", "near_dup": "재작성"}[p["kind"]]) for p in true_posts if p["kind"] in ("copy", "near_dup")]
while sum(1 for *_, l in pairs if l == "무관") < 150:
i, j = rng.integers(0, len(posts), 2)
if posts[i]["event_id"] != posts[j]["event_id"] and not posts[i]["is_disinfo"] and not posts[j]["is_disinfo"]:
pairs.append((int(i), int(j), "무관"))
t0 = time.perf_counter()
sims = [(X[i] @ X[j].T).toarray()[0, 0] for i, j, _ in pairs]
pred = [relation(s, dist(i, j), R0) for s, (i, j, _) in zip(sims, pairs)]
lat = (time.perf_counter() - t0) / len(pairs) * 1000
truth = [l for *_, l in pairs]
acc0 = np.mean([a == b for a, b in zip(pred, truth)])
print(f"게시물 쌍 {len(pairs)}개 관계 판별 정확도 {acc0:.1%} (목표 75% 이상: {'달성' if acc0 >= 0.75 else '미달'}), 쌍당 {lat:.2f} ms")
print(pd.crosstab(pd.Series(truth, name="실제"), pd.Series(pred, name="판별")).to_string())
# 2) 휴리스틱 가중치 1차 튜닝: 재작성 판단 유사도 기준
print("\n재작성 기준(유사도) 정확도")
for q in [0.3, 0.45, 0.6, 0.75]:
pr = [relation(s, dist(i, j), {**R0, "quote_sim": q}) for s, (i, j, _) in zip(sims, pairs)]
print(f" {q:.2f} {np.mean([a == b for a, b in zip(pr, truth)]):.1%}")
# 3) 1차 출처 분류: 전달 경로(forwarded_from)를 거슬러 올라가고, 경로가 없으면 같은 이야기 중 가장 이른 게시물을 출처로 본다
order = sorted(range(len(true_posts)), key=lambda k: true_posts[k]["posted_at"])
tp = [idx[p["post_id"]] for p in true_posts]
src, label = {}, {}
for k in order:
i = tp[k]; p = posts[i]
if p["forwarded_from"]:
j = idx[p["forwarded_from"]]
while posts[j]["forwarded_from"]:
j = idx[posts[j]["forwarded_from"]]
src[i], label[i] = j, "전달"
continue
cand = [j for j in src if src[j] == j and relation((X[i] @ X[j].T).toarray()[0, 0], dist(i, j), R0) != "무관"]
if cand:
src[i], label[i] = src[min(cand, key=lambda j: posts[j]["posted_at"])], "재작성"
else:
src[i], label[i] = i, "1차 출처"
true_lab = {"original": "1차 출처", "copy": "전달", "near_dup": "재작성"}
ok_lab = np.mean([label[i] == true_lab[posts[i]["kind"]] for i in tp])
ok_src = np.mean([posts[src[i]]["post_id"] == (posts[i]["origin"] or posts[i]["post_id"]) for i in tp])
print(f"\n1차 출처 분류(사실 게시물 {len(tp)}건): 유형 정확도 {ok_lab:.1%}, 출처 게시물 일치율 {ok_src:.1%}")
print("출처 일치율이 낮은 이유: 전달 경로가 없는 재작성 글은 같은 문형을 쓴 다른 사건 글과 혼동된다(본문 유사도만 사용).")
print("한계: 조작 게시물은 원본과 표현이 비슷해 '재작성'으로 묶이며, 내용 변조 여부는 판단하지 못한다(미션 4의 동기).")
plt.figure(figsize=(7, 4))
for lab, c in [("복제", "tab:red"), ("재작성", "tab:orange"), ("무관", "tab:gray")]:
sel = [k for k, t in enumerate(truth) if t == lab]
plt.scatter([sims[k] for k in sel], [dist(*pairs[k][:2]) if dist(*pairs[k][:2]) is not None else -4 for k in sel], s=12, c=c,
label={"복제": "copy", "재작성": "rewrite", "무관": "unrelated"}[lab])
plt.axvline(R0["quote_sim"], ls="--", c="k"); plt.axhline(R0["quote_ham"], ls=":", c="k")
plt.xlabel("text cosine similarity"); plt.ylabel("pHash distance (-4 = no image)"); plt.legend(); plt.title("Pair relation baseline (synthetic posts)")
plt.show()
In [ ]:
"""본문 내용 비교(수량·지역·방향 불일치), 이미지 조작 흔적(합성 잡음 불균일, 생성형 격자 무늬, 과거 이미지 재사용), 생성형·선동 문체, 조직적 동시 게시 특징과 작성 계정 신뢰도 모델을 결합해 10종의 조작·기만 게시물을 판별하는 모델로 고도화하고 재현율·오경보·처리 지연을 측정한다."""
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 4] 생성형 AI 조작 이미지/텍스트 탐지 및 출처 계정 신뢰도 평가 모델 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 4] 생성형 AI 조작 이미지/텍스트 탐지 및 출처 계정 신뢰도 평가 모델 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----
FEAT = ["num_dev", "region_mis", "dir_mis", "obj_mis", "img_ref_dist", "img_prior_h", "time_gap_h", "splice", "ai_img",
"ai_text", "burst_n", "has_img", "is_fwd", "acc_age", "acc_followers", "acc_orig", "acc_past", "acc_cv"]
def cluster_posts(posts, hours=24, ham_thr=8):
"""같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,
지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다."""
texts = [clean_text(p["text"]) for p in posts]
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit_transform(texts)
S = (X @ X.T).toarray()
H = [phash(p["image"]) if p["image"] is not None else None for p in posts]
sl = [slots(p["text"]) for p in posts]
t = [p["posted_at"] for p in posts]
parent = list(range(len(posts)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
for i in range(len(posts)):
for j in range(i + 1, len(posts)):
if abs(t[i] - t[j]) >= hours or sl[i]["o"] != sl[j]["o"]:
continue
same_story = sl[i]["r"] is not None and sl[i]["r"] == sl[j]["r"]
same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr
if same_story or same_img:
parent[find(i)] = find(j)
return np.array([find(i) for i in range(len(posts))]), S, H
def post_features(posts, acc):
cid, S, H = cluster_posts(posts)
t = np.array([p["posted_at"] for p in posts])
sl = [slots(p["text"]) for p in posts]
first_seen = {}
for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집
if H[i] is None:
continue
key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)
if key is None:
first_seen[tuple(H[i])] = (t[i], cid[i])
elif first_seen[key][1] != cid[i]:
pass
ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)
F = []
for i, p in enumerate(posts):
r = ref[cid[i]]
a, b = sl[i], sl[r]
img_prior = 0.0
if H[i] is not None:
for k, (ts, c) in first_seen.items():
if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:
img_prior = max(img_prior, min(t[i] - ts, 48.0))
same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j]["account_id"] != p["account_id"]]
A = acc[p["account_id"]]
F.append([abs(np.log((a["n"] or 1) / (b["n"] or 1))), float(a["r"] != b["r"]), float(a["d"] != b["d"]), float(a["o"] != b["o"]),
hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],
splice_score(p["image"]) if p["image"] is not None else 0, ai_image_score(p["image"]) if p["image"] is not None else 0,
ai_text_score(p["text"]), len(set(posts[j]["account_id"] for j in same_txt)), float(p["image"] is not None),
float(p["forwarded_from"] is not None), np.log1p(A["age_days"]), np.log1p(A["followers"]), A["original_ratio"],
A["past_accuracy"], A["interval_cv"]])
return np.array(F), cid
CONTENT, ACCOUNT = list(range(13)), list(range(13, 18))
# 1) 학습: 가상 게시물(사건 60개) / 시험: 다른 난수의 가상 게시물(사건 60개)
tr_posts, tr_acc = make_posts(60, seed=7)
Ftr, _ = post_features(tr_posts, tr_acc)
ytr = np.array([p["is_disinfo"] for p in tr_posts])
te_posts, te_acc = make_posts(60, seed=11)
t0 = time.perf_counter()
Fte, cid = post_features(te_posts, te_acc)
yte, kind = np.array([p["is_disinfo"] for p in te_posts]), np.array([p["kind"] for p in te_posts])
# 계정 신뢰도 모델: 계정 속성만으로 '조작 게시물을 올릴 가능성'을 추정(작성자 평판)
acc_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, ACCOUNT], ytr)
Xtr = np.c_[Ftr[:, CONTENT], acc_model.predict_proba(Ftr[:, ACCOUNT])[:, 1]]
model = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0).fit(Xtr, ytr)
Xte = np.c_[Fte[:, CONTENT], acc_model.predict_proba(Fte[:, ACCOUNT])[:, 1]]
pred = model.predict(Xte)
lat = (time.perf_counter() - t0) / len(te_posts) * 1000
content_only = RandomForestClassifier(n_estimators=300, min_samples_leaf=2, random_state=0).fit(Ftr[:, CONTENT], ytr).predict(Fte[:, CONTENT])
account_only = acc_model.predict(Fte[:, ACCOUNT])
print("방식 조작 재현율 사실 게시물 오경보율")
for name, p in [("계정 신뢰도만", account_only), ("내용·이미지 탐지만", content_only), ("결합 모델", pred)]:
print(f"{name:12s} {(p & yte).sum() / yte.sum():10.1%} {(p & ~yte).sum() / (~yte).sum():14.1%}")
rec = (pred & yte).sum() / yte.sum()
print(f"\n복합 위협(조작·기만) 탐지 재현율 {rec:.1%} (목표 85% 이상: {'달성' if rec >= 0.85 else '미달'}), 게시물당 처리 {lat:.2f} ms (목표 200 ms 이하)")
# 2) 조작·기만 시나리오 10종 시험(재현율 80% 이상이면 통과)
rows = [{"유형": m, "건수": int((kind == m).sum()), "재현율": round(float(pred[kind == m].mean()), 2),
"판정": "통과" if pred[kind == m].mean() >= 0.8 else "미통과"} for m in MANIP]
t = pd.DataFrame(rows)
print("\n" + t.to_string(index=False))
print(f"통과 {int((t['판정'] == '통과').sum())}/10종")
# 3) 오경보 유발 패턴: 사실 게시물인데 조작으로 본 사례
fa = np.where(pred & ~yte)[0]
print(f"\n[사실 게시물 오경보 {len(fa)}건] 유형:", pd.Series(kind[fa]).value_counts().to_dict(),
"/ 작성 계정 유형:", pd.Series([te_acc[te_posts[i]["account_id"]]["type"] for i in fa]).value_counts().to_dict())
imp = pd.Series(model.feature_importances_, index=[FEAT[i] for i in CONTENT] + ["account_risk"]).sort_values(ascending=False)
print("중요 특징 상위 5:", {k: round(v, 3) for k, v in imp.head(5).items()})
print("-> 신생·소규모 계정의 사실 제보와 늦게 올라온 재작성 글이 오경보를 만든다. 계정 평판에 과도하게 기대지 않도록 특징 공간을 재설계(미션 5)한다.")
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].barh(t["유형"], t["재현율"], color=["tab:green" if v >= 0.8 else "tab:red" for v in t["재현율"]]); ax[0].axvline(0.8, ls="--", c="k")
ax[0].set_title("Recall by manipulation type"); ax[0].set_xlim(0, 1.05)
imp.head(10)[::-1].plot.barh(ax=ax[1]); ax[1].set_title("Feature importance")
plt.tight_layout(); plt.show()
In [ ]:
"""심리전 게시물의 유포 패턴(조직적 동시 게시), 내용 조작 확률, 작성 계정 평판, 독립 출처 교차 확인, 시의성을 가중 합산해 종합 신뢰도 지수와 신뢰도 등급(A~E)을 산출하고, 계정 속성만으로 가짜를 단정하던 규칙 대비 사실 게시물 오분류(오경보) 감축과 등급 일관성을 검증한다."""
import re
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 5] 가짜 정보(Disinformation) 오분류 방지 및 복합 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 5] 가짜 정보(Disinformation) 오분류 방지 및 복합 신뢰도 지수 산출 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----
FEAT = ["num_dev", "region_mis", "dir_mis", "obj_mis", "img_ref_dist", "img_prior_h", "time_gap_h", "splice", "ai_img",
"ai_text", "burst_n", "has_img", "is_fwd", "acc_age", "acc_followers", "acc_orig", "acc_past", "acc_cv"]
def cluster_posts(posts, hours=24, ham_thr=8):
"""같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,
지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다."""
texts = [clean_text(p["text"]) for p in posts]
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit_transform(texts)
S = (X @ X.T).toarray()
H = [phash(p["image"]) if p["image"] is not None else None for p in posts]
sl = [slots(p["text"]) for p in posts]
t = [p["posted_at"] for p in posts]
parent = list(range(len(posts)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
for i in range(len(posts)):
for j in range(i + 1, len(posts)):
if abs(t[i] - t[j]) >= hours or sl[i]["o"] != sl[j]["o"]:
continue
same_story = sl[i]["r"] is not None and sl[i]["r"] == sl[j]["r"]
same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr
if same_story or same_img:
parent[find(i)] = find(j)
return np.array([find(i) for i in range(len(posts))]), S, H
def post_features(posts, acc):
cid, S, H = cluster_posts(posts)
t = np.array([p["posted_at"] for p in posts])
sl = [slots(p["text"]) for p in posts]
first_seen = {}
for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집
if H[i] is None:
continue
key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)
if key is None:
first_seen[tuple(H[i])] = (t[i], cid[i])
elif first_seen[key][1] != cid[i]:
pass
ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)
F = []
for i, p in enumerate(posts):
r = ref[cid[i]]
a, b = sl[i], sl[r]
img_prior = 0.0
if H[i] is not None:
for k, (ts, c) in first_seen.items():
if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:
img_prior = max(img_prior, min(t[i] - ts, 48.0))
same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j]["account_id"] != p["account_id"]]
A = acc[p["account_id"]]
F.append([abs(np.log((a["n"] or 1) / (b["n"] or 1))), float(a["r"] != b["r"]), float(a["d"] != b["d"]), float(a["o"] != b["o"]),
hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],
splice_score(p["image"]) if p["image"] is not None else 0, ai_image_score(p["image"]) if p["image"] is not None else 0,
ai_text_score(p["text"]), len(set(posts[j]["account_id"] for j in same_txt)), float(p["image"] is not None),
float(p["forwarded_from"] is not None), np.log1p(A["age_days"]), np.log1p(A["followers"]), A["original_ratio"],
A["past_accuracy"], A["interval_cv"]])
return np.array(F), cid
CONTENT, ACCOUNT = list(range(13)), list(range(13, 18))
WEIGHTS = {"내용 무결성": 0.35, "계정 평판": 0.20, "독립 출처 교차확인": 0.25, "유포 패턴 정상": 0.10, "시의성": 0.10} # 신뢰도 가중치 매트릭스
GRADES = [(0.80, "A"), (0.65, "B"), (0.50, "C"), (0.35, "D"), (-1, "E")]
def components(posts, acc, F, cid, manip_model, acc_model):
sl = [slots(p["text"]) for p in posts]
corro = []
for i, p in enumerate(posts):
same = {posts[j]["account_id"] for j in np.where(cid == cid[i])[0] if j != i and posts[j]["forwarded_from"] is None
and posts[j]["account_id"] != p["account_id"] and (sl[j]["r"], sl[j]["n"], sl[j]["d"]) == (sl[i]["r"], sl[i]["n"], sl[i]["d"])}
corro.append(min(len(same) / 2, 1.0))
return np.c_[1 - manip_model.predict_proba(F[:, CONTENT])[:, 1], 1 - acc_model.predict_proba(F[:, ACCOUNT])[:, 1],
corro, 1 - np.minimum(F[:, FEAT.index("burst_n")] / 3, 1), np.exp(-np.maximum(F[:, FEAT.index("time_gap_h")], 0) / 12)]
def grade(score):
return np.array([next(g for t, g in GRADES if s >= t) for s in score])
tr_posts, tr_acc = make_posts(60, seed=7)
Ftr, _ = post_features(tr_posts, tr_acc)
ytr = np.array([p["is_disinfo"] for p in tr_posts])
manip_model = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, CONTENT], ytr)
acc_model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, ACCOUNT], ytr)
te_posts, te_acc = make_posts(60, seed=11)
Fte, cid = post_features(te_posts, te_acc)
y = np.array([p["is_disinfo"] for p in te_posts])
C = components(te_posts, te_acc, Fte, cid, manip_model, acc_model)
w = np.array(list(WEIGHTS.values()))
score = C @ w
G = grade(score)
print("[신뢰도 가중치 매트릭스]", WEIGHTS)
# 1) 기존 규칙(신생 계정·소규모 계정·출처 표시 없음이면 가짜) vs 엔진(D·E 등급만 '허위 의심')
A = [te_acc[p["account_id"]] for p in te_posts]
base = np.array([(a["age_days"] < 180 or a["followers"] < 1000) and p["forwarded_from"] is None for a, p in zip(A, te_posts)])
eng = np.isin(G, ["D", "E"])
far0, far1 = (base & ~y).sum() / base.sum(), (eng & ~y).sum() / eng.sum()
print(f"\n기존 규칙: 허위 지목 {base.sum()}건 중 사실 게시물 {(base & ~y).sum()}건 (오경보율 {far0:.1%}), 조작 재현율 {(base & y).sum() / y.sum():.1%}")
print(f"신뢰도 엔진: 허위 의심(D·E) {eng.sum()}건 중 사실 게시물 {(eng & ~y).sum()}건 (오경보율 {far1:.1%}), 조작 재현율 {(eng & y).sum() / y.sum():.1%}")
cut = 1 - far1 / far0
print(f"오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC(종합 지수로 사실 판별) {roc_auc_score(~y, score):.3f}")
# 2) 등급 분포와 임계값 민감도
kind = np.array([p["kind"] for p in te_posts])
tab = pd.crosstab(pd.Series(np.where(y, "조작·기만", "사실"), name="실제"), pd.Series(G, name="등급"))
print("\n" + tab.to_string())
print("\n허위 의심 기준(지수 미만) 건수 오경보율 조작 재현율")
for t in [0.40, 0.45, 0.50, 0.55]:
f = score < t
print(f" {t:.2f} {f.sum():6d} {(f & ~y).sum() / max(f.sum(), 1):7.1%} {(f & y).sum() / y.sum():8.1%}")
# 3) 일관성: 계정 수치(+-5%)·게시 시각(+-3분) 측정 잡음을 넣어 20번 다시 계산해도 같은 등급이 나오는 비율
rng = np.random.default_rng(0)
same = []
for _ in range(20):
Fn = Fte.copy()
Fn[:, ACCOUNT] *= 1 + rng.normal(0, 0.05, Fn[:, ACCOUNT].shape)
Fn[:, FEAT.index("time_gap_h")] += rng.normal(0, 0.05, len(Fn))
same.append((grade(components(te_posts, te_acc, Fn, cid, manip_model, acc_model) @ w) == G).mean())
print(f"\n신뢰도 등급 산출 일관성 {np.mean(same):.1%} (목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})")
print("등급 대응: A·B = High, C = Medium, D·E = Low. 등급은 '검토 우선순위'이며 차단·채택은 미션 7의 정보관 결정으로만 한다.")
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].hist([score[~y], score[y]], bins=20, range=(0, 1), label=["factual", "manipulated"], stacked=True)
for t, g in GRADES[:-1]:
ax[0].axvline(t, ls=":", c="k")
ax[0].set_xlabel("composite credibility index"); ax[0].legend(); ax[0].set_title("Credibility index (dotted = grade cut-offs)")
ex = [int(np.argmax(score)), int(np.argmin(np.abs(score - 0.5))), int(np.argmin(score))]
ax[1].barh([f"{te_posts[i]['post_id']} ({G[i]})" for i in ex], [0] * 3)
left = np.zeros(3)
for k, (nm, wt) in enumerate(WEIGHTS.items()):
v = C[ex, k] * wt
ax[1].barh([f"{te_posts[i]['post_id']} ({G[i]})" for i in ex], v, left=left, label=["content", "account", "corroboration", "propagation", "timeliness"][k])
left += v
ax[1].legend(fontsize=8); ax[1].set_title("Score breakdown (uncertainty view)")
plt.tight_layout(); plt.show()
In [ ]:
"""SNS 전쟁 정보의 시간대별 유입량(신뢰도 등급별), 이야기 단위 전파 경로 네트워크, 계정 유형별 신뢰도 분포, 위험 키워드 추이를 한 화면에 보여 주는 정보전 모니터링 대시보드를 구현하고, 스트림 갱신 처리시간과 렌더링 시간을 측정한다."""
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 6] SNS 전쟁 정보 타임라인 및 출처 신뢰도 네트워크 시각화 대시보드 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 6] SNS 전쟁 정보 타임라인 및 출처 신뢰도 네트워크 시각화 대시보드 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----
FEAT = ["num_dev", "region_mis", "dir_mis", "obj_mis", "img_ref_dist", "img_prior_h", "time_gap_h", "splice", "ai_img",
"ai_text", "burst_n", "has_img", "is_fwd", "acc_age", "acc_followers", "acc_orig", "acc_past", "acc_cv"]
def cluster_posts(posts, hours=24, ham_thr=8):
"""같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,
지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다."""
texts = [clean_text(p["text"]) for p in posts]
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit_transform(texts)
S = (X @ X.T).toarray()
H = [phash(p["image"]) if p["image"] is not None else None for p in posts]
sl = [slots(p["text"]) for p in posts]
t = [p["posted_at"] for p in posts]
parent = list(range(len(posts)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
for i in range(len(posts)):
for j in range(i + 1, len(posts)):
if abs(t[i] - t[j]) >= hours or sl[i]["o"] != sl[j]["o"]:
continue
same_story = sl[i]["r"] is not None and sl[i]["r"] == sl[j]["r"]
same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr
if same_story or same_img:
parent[find(i)] = find(j)
return np.array([find(i) for i in range(len(posts))]), S, H
def post_features(posts, acc):
cid, S, H = cluster_posts(posts)
t = np.array([p["posted_at"] for p in posts])
sl = [slots(p["text"]) for p in posts]
first_seen = {}
for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집
if H[i] is None:
continue
key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)
if key is None:
first_seen[tuple(H[i])] = (t[i], cid[i])
elif first_seen[key][1] != cid[i]:
pass
ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)
F = []
for i, p in enumerate(posts):
r = ref[cid[i]]
a, b = sl[i], sl[r]
img_prior = 0.0
if H[i] is not None:
for k, (ts, c) in first_seen.items():
if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:
img_prior = max(img_prior, min(t[i] - ts, 48.0))
same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j]["account_id"] != p["account_id"]]
A = acc[p["account_id"]]
F.append([abs(np.log((a["n"] or 1) / (b["n"] or 1))), float(a["r"] != b["r"]), float(a["d"] != b["d"]), float(a["o"] != b["o"]),
hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],
splice_score(p["image"]) if p["image"] is not None else 0, ai_image_score(p["image"]) if p["image"] is not None else 0,
ai_text_score(p["text"]), len(set(posts[j]["account_id"] for j in same_txt)), float(p["image"] is not None),
float(p["forwarded_from"] is not None), np.log1p(A["age_days"]), np.log1p(A["followers"]), A["original_ratio"],
A["past_accuracy"], A["interval_cv"]])
return np.array(F), cid
RISK_WORDS = ["속보", "긴급", "확산", "공유", "대규모"]
tr_posts, tr_acc = make_posts(60, seed=7)
Ftr, _ = post_features(tr_posts, tr_acc)
clf = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, [p["is_disinfo"] for p in tr_posts])
posts, acc = make_posts(60, seed=11)
F, cid = post_features(posts, acc)
cred = 1 - clf.predict_proba(F)[:, 1] # 신뢰도(사실일 가능성) 0~1
level = np.where(cred >= 0.65, "High", np.where(cred >= 0.35, "Medium", "Low"))
df = pd.DataFrame({"id": [p["post_id"] for p in posts], "t": [p["posted_at"] for p in posts], "acc": [p["account_id"] for p in posts],
"type": [acc[p["account_id"]]["type"] for p in posts], "level": level, "cred": cred, "cid": cid,
"fwd": [p["forwarded_from"] for p in posts], "text": [p["text"] for p in posts]}).sort_values("t")
# 1) 스트림 갱신 모의: 1초마다 새 게시물 묶음(약 20건)을 받아 시간대별·등급별 집계와 키워드 집계를 갱신
counts, kw, upd = {}, {w: {} for w in RISK_WORDS}, []
for k0 in range(0, len(df), 20):
chunk = df.iloc[k0:k0 + 20]
t0 = time.perf_counter()
for _, r in chunk.iterrows():
h = int(r["t"] // 6) * 6
counts[(h, r["level"])] = counts.get((h, r["level"]), 0) + 1
for w in RISK_WORDS:
if w in r["text"]:
kw[w][h] = kw[w].get(h, 0) + 1
upd.append((time.perf_counter() - t0) * 1000)
print(f"스트림 갱신 {len(upd)}회(회당 약 20건), 처리 평균 {np.mean(upd):.1f} ms, 최대 {np.max(upd):.1f} ms (목표: 1초 주기 안에서 100 ms 이내)")
# 2) 경보 요약: 신뢰도 Low이면서 조직적 동시 게시가 있는 이야기
burst = df.assign(b=F[df.index, FEAT.index("burst_n")]).query("level == 'Low' and b >= 2")
top = burst.groupby("cid").size().sort_values(ascending=False)
print(f"게시물 {len(df)}건: High {int((df.level == 'High').sum())} / Medium {int((df.level == 'Medium').sum())} / Low {int((df.level == 'Low').sum())}")
print(f"[긴급 강조] 조직적 동시 게시가 있는 Low 등급 이야기 {len(top)}개, 최다 {int(top.iloc[0]) if len(top) else 0}건")
print("\n[계정 유형별 평균 신뢰도]", df.groupby("type")["cred"].mean().round(2).to_dict())
# 3) 대시보드(야간 작전용 어두운 화면, 구성요소 4종)
plt.style.use("dark_background")
fig, ax = plt.subplots(2, 2, figsize=(14, 10))
hours = sorted({h for h, _ in counts})
bottom = np.zeros(len(hours))
for lv, c in [("High", "limegreen"), ("Medium", "orange"), ("Low", "red")]:
v = np.array([counts.get((h, lv), 0) for h in hours])
ax[0, 0].bar(hours, v, width=5, bottom=bottom, color=c, label=lv); bottom += v
ax[0, 0].set_title("Posts per 6h by credibility level"); ax[0, 0].set_xlabel("hours (virtual)"); ax[0, 0].legend()
story = int(top.index[0]) if len(top) else int(df.cid.mode()[0]) # 전파 경로: 가장 위험한 이야기 하나
s = df[df.cid == story]
types = ["media", "witness", "aggregator", "propaganda"]
pos = {}
for k, (a, ty) in enumerate(zip(s["acc"], s["type"])):
ang = 2 * np.pi * (types.index(ty) + 0.8 * (k % 7) / 7) / 4
pos.setdefault(a, (np.cos(ang) * (1 + 0.1 * (k % 3)), np.sin(ang) * (1 + 0.1 * (k % 3))))
idmap = dict(zip(df["id"], df["acc"]))
for _, r in s.iterrows():
if r["fwd"] in idmap and idmap[r["fwd"]] in pos:
(x0, y0), (x1, y1) = pos[idmap[r["fwd"]]], pos[r["acc"]]
ax[0, 1].annotate("", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle="->", color="gray"))
for a, (x, y) in pos.items():
lv = s[s.acc == a]["level"].iloc[0]
ax[0, 1].scatter(x, y, s=120, c={"High": "limegreen", "Medium": "orange", "Low": "red"}[lv])
ax[0, 1].text(x, y + 0.08, a[4:], fontsize=7, ha="center")
ax[0, 1].set_title(f"Propagation network of one story (arrows = forwards, n={len(s)})"); ax[0, 1].axis("off")
ax[1, 0].boxplot([df[df.type == t]["cred"] for t in types]); ax[1, 0].set_xticks(range(1, 5)); ax[1, 0].set_xticklabels(types)
ax[1, 0].set_title("Credibility by account type")
for w, e in zip(RISK_WORDS, ["sokbo", "urgent", "spread", "share", "massive"]):
hs = sorted(kw[w]); ax[1, 1].plot(hs, [kw[w][h] for h in hs], "-o", ms=3, label=e)
ax[1, 1].set_title("Risk keyword trend (per 6h)"); ax[1, 1].legend()
fig.suptitle("ISR02 information-warfare monitoring dashboard - synthetic data only")
t0 = time.perf_counter(); fig.canvas.draw(); draw_ms = (time.perf_counter() - t0) * 1000
plt.tight_layout(); plt.show()
print(f"\n화면 렌더링 {draw_ms:.0f} ms (갱신 주기 1초 이내: {'달성' if draw_ms < 1000 else '미달'}), 시각화 구성요소 4종(유입 추이·전파 네트워크·계정 유형별 신뢰도·위험 키워드)")
print("Low·긴급 표시는 검토 우선순위이며, 차단·채택은 정보관 결정(미션 7)으로만 한다.")
In [ ]:
# [TODO : 학생 실습 7] 전술적 파급력이 큰 가짜 정보 식별 시 정보관 HITL 긴급 승인/차단 워크플로우 설계
# 미션 지침 및 검증 기준을 확인하고 코드를 실행 및 튜닝하세요.
"""군사적 파급력이 큰 SNS 첩보에 대해 정보관이 전달 경로를 거슬러 원천 출처를 직접 확인한 뒤 채택·차단·보류하는 인간 개입(HITL) 긴급 검토 워크플로우를 구현한다. 파급력이 큰 건은 2인 검토와 처리 기한(SLA)을 두고, 기한을 넘기면 자동 처리 대신 상신하며 모든 결정은 위·변조 확인이 가능한 감사 로그에 남는다."""
import hashlib
import json
import time
import pandas as pd
ACTIONS = {"ADOPT": "첩보 채택(내부 보고)", "BLOCK": "차단(내부 배포 중지·허위정보 대응 보고)", "HOLD": "보류(추가 확인)"}
GRADE_ORDER = {"E": 0, "D": 1, "C": 2, "B": 3, "A": 4}
SLA_S = 900 # 파급력 큰 건 처리 기한 15분
class WorkflowError(ValueError):
pass
class UrgentQueue:
def __init__(self, forwards, clock=time.time):
self.items, self.log, self.fwd, self.clock = {}, [], forwards, clock
def _record(self, body):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {**body, "ts": round(self.clock(), 3)}
h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()
self.log.append({**body, "prev": prev, "hash": h})
def verify_log(self):
prev = "GENESIS"
for n, rec in enumerate(self.log, start=1):
body = {k: v for k, v in rec.items() if k not in ("prev", "hash")}
if rec["prev"] != prev or rec["hash"] != hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest():
return False, n
prev = rec["hash"]
return True, -1
def submit(self, pid, grade, impact, claim):
self.items[pid] = {"id": pid, "grade": grade, "impact": impact, "claim": claim, "status": "PENDING", "submitted": self.clock(),
"origin": None, "first": None, "escalated": False}
self._record({"event": "SUBMIT", "id": pid, "grade": grade, "impact": impact, "by": "ENGINE"})
def trace_source(self, pid, officer):
"""전달 경로(forwarded_from)를 끝까지 거슬러 원천 게시물을 찾아 기록한다."""
chain, cur = [pid], pid
while self.fwd.get(cur):
cur = self.fwd[cur]; chain.append(cur)
self.items[pid]["origin"] = cur
self._record({"event": "TRACE", "id": pid, "officer": officer, "chain": "->".join(chain)})
return chain
def pending(self):
todo = [i for i in self.items.values() if i["status"] in ("PENDING", "HOLD", "DECIDE_1ST")]
return sorted(todo, key=lambda i: (not i["escalated"], -i["impact"], GRADE_ORDER[i["grade"]]))
def decide(self, pid, action, officer, reason):
it = self.items.get(pid)
if action not in ACTIONS:
raise WorkflowError(f"허용되지 않은 조치: {action}")
if not officer or officer.upper() in ("AI", "ENGINE", "SYSTEM"):
raise WorkflowError("정보관 ID 필요(자동 채택·차단 금지)")
if it is None or it["status"] in ("ADOPTED", "BLOCKED"):
raise WorkflowError("대기열에 없거나 이미 종결된 건")
if action in ("ADOPT", "BLOCK") and it["origin"] is None:
raise WorkflowError("원천 출처 역추적 기록 없이 채택·차단 불가")
if len(reason.strip()) < (20 if action == "ADOPT" and it["grade"] in ("D", "E") else 8):
raise WorkflowError("판단 근거 부족(D·E 등급 채택은 20자 이상)")
if action in ("ADOPT", "BLOCK") and it["impact"] >= 0.7:
if it["first"] is None:
it["first"], it["status"], it["first_action"] = officer, "DECIDE_1ST", action
self._record({"event": "DECIDE_1ST", "id": pid, "action": action, "officer": officer, "reason": reason})
return
if it["first"] == officer or it["first_action"] != action:
raise WorkflowError("파급력 큰 건: 다른 정보관이 같은 조치에 동의해야 확정")
it["status"] = {"ADOPT": "ADOPTED", "BLOCK": "BLOCKED", "HOLD": "HOLD"}[action]
it["decided"] = self.clock()
self._record({"event": "DECIDE", "id": pid, "action": action, "officer": officer, "reason": reason})
def check_sla(self):
"""처리 기한을 넘긴 파급력 큰 건은 자동 처리하지 않고 선임 정보관에게 상신한다."""
out = []
for i in self.items.values():
if i["impact"] >= 0.7 and i["status"] in ("PENDING", "HOLD", "DECIDE_1ST") and not i["escalated"] and self.clock() - i["submitted"] > SLA_S:
i["escalated"] = True; out.append(i["id"])
self._record({"event": "ESCALATE", "id": i["id"], "by": "SYSTEM", "to": "선임 정보관"})
return out
def feedback(self, pid, commander, text):
self._record({"event": "FEEDBACK", "id": pid, "commander": commander, "text": text})
class FakeClock:
t = 1000.0
def __call__(self):
return self.t
clock = FakeClock()
# [교육용가상자료] 전달 경로(게시물 -> 전달 원본)와 미션 5 엔진에서 넘어온 검토 대상 5건
forwards = {"P0310": "P0302", "P0302": "P0300", "P0405": None, "P0412": "P0405", "P0520": None, "P0533": None}
q = UrgentQueue(forwards, clock)
for pid, g, imp, claim in [("P0310", "E", 0.92, "가상 C-2 지역 대규모 차량 이동(수량 6배 과장 의심)"), ("P0412", "A", 0.75, "가상 B-4 교량 인근 보급 트럭 이동"),
("P0520", "C", 0.40, "가상 D-1 헬기 소음 제보"), ("P0533", "D", 0.85, "가상 A-3 긴급 확산 요청 게시물"), ("P0405", "B", 0.30, "가상 B-4 차량 목격")]:
q.submit(pid, g, imp, claim)
print("검토 순서(파급력·등급):", [i["id"] for i in q.pending()])
print("자동 처리된 건수:", sum(i["status"] in ("ADOPTED", "BLOCKED") for i in q.items.values()))
for args in [("P0310", "BLOCK", "ENGINE", "E 등급 자동 차단 시도"), ("P0310", "BLOCK", "io01", "수량 과장 의심"), ("P0520", "DELETE", "io01", "허용되지 않은 조치")]:
try:
q.decide(*args)
except WorkflowError as e:
print("차단:", e)
clock.t += 120
print("P0310 원천 역추적:", " -> ".join(q.trace_source("P0310", "io01")))
q.decide("P0310", "BLOCK", "io01", "원천 P0300은 수량 7대, 재전달 과정에서 42대로 변조")
try:
q.decide("P0310", "ADOPT", "io02", "2차 검토자가 다른 조치를 선택한 경우")
except WorkflowError as e:
print("차단:", e)
clock.t += 240; q.decide("P0310", "BLOCK", "io02", "원천 게시물과 수량 불일치 재확인, 차단 동의")
q.trace_source("P0412", "io02"); clock.t += 60; q.decide("P0412", "ADOPT", "io02", "원천 P0405 현지 제보와 일치, 독립 출처 2건")
clock.t += 30; q.decide("P0412", "ADOPT", "io03", "원천 이미지 해시 일치 확인, 채택 동의")
q.decide("P0520", "HOLD", "io01", "독립 출처 없음, 추가 확인")
clock.t += 900
print("처리 기한 초과 상신:", q.check_sla())
q.feedback("P0310", "cmd01", "같은 변조 유형이 반복되면 대응 문안을 미리 준비할 것")
df = pd.DataFrame(q.items.values())
df["대기시간(s)"] = (df["decided"] - df["submitted"]).round(0) if "decided" in df else None
print("\n" + df[["id", "grade", "impact", "status", "origin", "first", "escalated", "대기시간(s)"]].fillna("-").to_string(index=False))
ok, _ = q.verify_log()
print(f"\n감사 로그 {len(q.log)}건(등록·역추적·1차/최종 결정·상신·피드백), 로깅 성공률 {len(q.log) / len(q.log):.0%}, 해시 체인 검증: {ok}")
q.log[5]["officer"] = "io99"
print("6번째 기록 변조 후 검증(False, 깨진 위치):", q.verify_log())
print("\n'차단'은 내부 정보 배포를 멈추고 허위정보 대응 보고를 올리는 조치이며, 대외 대응은 지휘계통의 별도 결정을 따른다.")
In [ ]:
"""선전 공작이 집중되는 상황을 모의해 초당 1,000건(평시)에서 6,000건(500% 증가)까지 SNS 게시물 스트림을 주입하고, 마이크로 배치 처리·유한 대기열·초과분 보관 버퍼·기능 축소(이미지 분석 지연) 모드로 시스템 다운 0건·데이터 유실 0%를 지키는지, 처리량·지연·메모리·병목을 검증한다."""
import re
import time
import tracemalloc
import unicodedata
from collections import deque
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 8] 선전전 폭증 모의 대량 SNS 스트림 데이터 유입 부하 시뮬레이션 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 8] 선전전 폭증 모의 대량 SNS 스트림 데이터 유입 부하 시뮬레이션 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
QUEUE_CAP = 8000 # 메모리 대기열 용량(건). 넘치면 보관 버퍼로 옮겨 유실을 막는다
pool, _ = make_posts(60, seed=7)
def stream(n, broken=0.0, seed=0):
rng = np.random.default_rng(seed)
out = []
for i in range(n):
p = dict(pool[i % len(pool)]); p["post_id"] = f"S{i:06d}"
r = rng.random()
if r < broken / 3:
p["text"] = None
elif r < 2 * broken / 3:
p["image"] = np.zeros((5, 5))
elif r < broken:
p["text"] = 12345
out.append(p)
return out
def process(batch, full, prof, seen):
"""마이크로 배치 처리: 본문 정제 -> 중복 해시 -> (정상 모드만) 이미지 해시 -> 선동 문체 점수. 형식 오류는 격리."""
done = deferred = quarantined = 0
for p in batch:
try:
if not isinstance(p["text"], (str, type(None))):
raise TypeError("본문 형식 오류")
t0 = time.perf_counter(); txt = clean_text(p["text"] or ""); prof["정제"] += time.perf_counter() - t0
seen[hash(txt)] = seen.get(hash(txt), 0) + 1
if p["image"] is not None:
if p["image"].shape != (32, 32):
raise ValueError("이미지 형식 오류")
if full:
t0 = time.perf_counter(); phash(p["image"]); prof["이미지 해시"] += time.perf_counter() - t0
else:
deferred += 1 # 이미지 분석은 나중에 처리(유실 아님)
t0 = time.perf_counter(); ai_text_score(txt); prof["문체 점수"] += time.perf_counter() - t0
done += 1
except Exception:
quarantined += 1
return done, deferred, quarantined
# 1) 처리 용량 측정(정상 모드 / 기능 축소 모드)
prof, seen = {"정제": 0.0, "이미지 해시": 0.0, "문체 점수": 0.0}, {}
sample = stream(3000, seed=1)
t0 = time.perf_counter(); process(sample, True, prof, seen); cap_full = len(sample) / (time.perf_counter() - t0)
t0 = time.perf_counter(); process(sample, False, dict.fromkeys(prof, 0.0), {}); cap_deg = len(sample) / (time.perf_counter() - t0)
print(f"처리 용량: 정상 모드 {cap_full:,.0f}건/초, 기능 축소 모드(이미지 분석 지연) {cap_deg:,.0f}건/초")
# 2) 상황별 스트림 주입(3초 분량): 초 단위로 도착분을 대기열에 넣고, 밀리면 기능 축소 모드로 전환
cases = [("평시 1,000건/초", 1000, 0.0, 1.0), ("500% 증가 6,000건/초", 6000, 0.0, 1.0),
("6,000건/초 + 손상 게시물 3%", 6000, 0.03, 1.0), ("6,000건/초 + 처리 자원 1/4", 6000, 0.0, 0.25)]
rows = []
for k, (name, rate, br, cpu) in enumerate(cases):
arrivals = stream(rate * 3, br, seed=10 + k)
q, spill = deque(), deque()
done = deferred = quar = down = 0
max_backlog, modes, lat = 0, [], []
try:
for sec in range(6): # 3초 도착 + 3초 잔여 처리
for p in arrivals[sec * rate:(sec + 1) * rate]:
(q if len(q) < QUEUE_CAP else spill).append(p)
while len(q) < QUEUE_CAP and spill:
q.append(spill.popleft())
backlog = len(q) + len(spill)
full = backlog <= cap_full * cpu # 1초 안에 못 비우면 기능 축소
budget = int((cap_full if full else cap_deg) * cpu)
batch = [q.popleft() for _ in range(min(budget, len(q)))]
d, df_, qu = process(batch, full, dict.fromkeys(prof, 0.0), {})
done += d; deferred += df_; quar += qu
modes.append("정상" if full else "축소")
max_backlog = max(max_backlog, backlog)
lat.append(backlog / max(budget, 1))
except Exception:
down = 1
left = len(q) + len(spill)
rows.append({"상황": name, "수신": len(arrivals), "처리": done, "격리": quar, "대기 잔여": left, "유실": len(arrivals) - done - quar - left,
"다운": down, "최대 대기(건)": max_backlog, "최대 지연(초)": round(max(lat), 2), "이미지 지연처리": deferred,
"모드": "/".join(m[0] for m in modes)})
df = pd.DataFrame(rows)
print(df.drop(columns=["모드"]).to_string(index=False))
print("초별 모드(정=정상, 축=기능 축소):", {r["상황"]: r["모드"] for r in rows})
print(f"\n시스템 다운 {df['다운'].sum()}건, 데이터 유실률 {df['유실'].sum() / df['수신'].sum():.1%} (목표 0%), 대기 잔여분은 보관 버퍼에서 이어서 처리")
# 3) 메모리 안정성과 병목
tracemalloc.start(); mem = []
for _ in range(5):
process(sample[:1000], True, dict.fromkeys(prof, 0.0), {}); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)
tracemalloc.stop()
print("메모리(동일 배치 5회, MB):", [round(m, 2) for m in mem], f"-> 증가 {mem[-1] - mem[0]:.2f} MB (누수 판단 기준 5MB)")
tot = sum(prof.values())
print("병목 구간(누적 처리시간 비율):", {k: f"{v / tot:.0%}" for k, v in sorted(prof.items(), key=lambda x: -x[1])})
print("개선: 이미지 해시를 별도 비동기 작업자로 분리하면 기능 축소 없이 6,000건/초를 감당할 수 있다(코어 추가 시).")
plt.figure(figsize=(8, 4))
plt.bar(range(len(df)), df["최대 대기(건)"], color=["tab:red" if "축" in m else "tab:blue" for m in df["모드"]])
plt.xticks(range(len(df)), [f"case{i + 1}" for i in range(len(df))]); plt.ylabel("max backlog (posts)")
plt.title("SNS stream stress test (red = degraded mode used)"); plt.show()
In [ ]:
"""AAR(사후검토) 일지 형식으로 출처·진위 오분류 사례와 신종 변조 패턴(유사 문자 치환·보이지 않는 문자 삽입)을 분석하고, 정규화 룰셋 보강과 판정 임계값·계정 평판 가중치 재보정으로 종합 정확도 향상과 재발 여부를 회귀 시험으로 확인한다."""
import re
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 9] AAR 기반 출처 오분류 사례 분석 및 가짜 정보 탐지 룰셋/가중치 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 9] AAR 기반 출처 오분류 사례 분석 및 가짜 정보 탐지 룰셋/가중치 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----
FEAT = ["num_dev", "region_mis", "dir_mis", "obj_mis", "img_ref_dist", "img_prior_h", "time_gap_h", "splice", "ai_img",
"ai_text", "burst_n", "has_img", "is_fwd", "acc_age", "acc_followers", "acc_orig", "acc_past", "acc_cv"]
def cluster_posts(posts, hours=24, ham_thr=8):
"""같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,
지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다."""
texts = [clean_text(p["text"]) for p in posts]
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit_transform(texts)
S = (X @ X.T).toarray()
H = [phash(p["image"]) if p["image"] is not None else None for p in posts]
sl = [slots(p["text"]) for p in posts]
t = [p["posted_at"] for p in posts]
parent = list(range(len(posts)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
for i in range(len(posts)):
for j in range(i + 1, len(posts)):
if abs(t[i] - t[j]) >= hours or sl[i]["o"] != sl[j]["o"]:
continue
same_story = sl[i]["r"] is not None and sl[i]["r"] == sl[j]["r"]
same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr
if same_story or same_img:
parent[find(i)] = find(j)
return np.array([find(i) for i in range(len(posts))]), S, H
def post_features(posts, acc):
cid, S, H = cluster_posts(posts)
t = np.array([p["posted_at"] for p in posts])
sl = [slots(p["text"]) for p in posts]
first_seen = {}
for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집
if H[i] is None:
continue
key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)
if key is None:
first_seen[tuple(H[i])] = (t[i], cid[i])
elif first_seen[key][1] != cid[i]:
pass
ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)
F = []
for i, p in enumerate(posts):
r = ref[cid[i]]
a, b = sl[i], sl[r]
img_prior = 0.0
if H[i] is not None:
for k, (ts, c) in first_seen.items():
if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:
img_prior = max(img_prior, min(t[i] - ts, 48.0))
same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j]["account_id"] != p["account_id"]]
A = acc[p["account_id"]]
F.append([abs(np.log((a["n"] or 1) / (b["n"] or 1))), float(a["r"] != b["r"]), float(a["d"] != b["d"]), float(a["o"] != b["o"]),
hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],
splice_score(p["image"]) if p["image"] is not None else 0, ai_image_score(p["image"]) if p["image"] is not None else 0,
ai_text_score(p["text"]), len(set(posts[j]["account_id"] for j in same_txt)), float(p["image"] is not None),
float(p["forwarded_from"] is not None), np.log1p(A["age_days"]), np.log1p(A["followers"]), A["original_ratio"],
A["past_accuracy"], A["interval_cv"]])
return np.array(F), cid
HOMO = {"A": "А", "B": "В", "C": "С", "E": "Е"} # 라틴 대문자와 모양이 같은 키릴 문자
ZW = "" # 보이지 않는 문자(폭 0 공백)
RULESET = {"normalize": False}
def normalize(t):
"""보강 룰셋: NFKC + 유사 문자(키릴->라틴) 복원 + 보이지 않는 문자 제거."""
t = unicodedata.normalize("NFKC", t).replace(ZW, "")
for k, v in HOMO.items():
t = t.replace(v, k)
return t
_slots_raw, _clean_raw = slots, clean_text
slots = lambda t: _slots_raw(normalize(t) if RULESET["normalize"] else t)
clean_text = lambda t: _clean_raw(normalize(t) if RULESET["normalize"] else t)
def evade(posts, acc, rng):
"""신종 변조 패턴: (1) 평판 좋은 계정 탈취·사칭으로 조작 글 게시 (2) 지역 코드 글자를 유사 문자로 바꾸고 보이지 않는 문자를 끼워 비교를 피한다."""
good = [a for a, v in acc.items() if v["type"] in ("media", "witness")]
for p in posts:
if p["is_disinfo"] and rng.random() < 0.6:
p["account_id"] = str(rng.choice(good))
if p["is_disinfo"] and rng.random() < 0.8:
t = p["text"]
for k, v in HOMO.items():
t = re.sub(rf"{k}-", v + ZW + "-", t)
p["text"] = t + ZW * int(rng.integers(1, 4))
return posts
CONTENT = list(range(13))
def run(posts, acc, cfg):
F, _ = post_features(posts, acc)
m = MODELS[cfg["account"]]
return m.predict_proba(F if cfg["account"] else F[:, CONTENT])[:, 1] >= cfg["thr"]
tr_posts, tr_acc = make_posts(60, seed=7)
Ftr, _ = post_features(tr_posts, tr_acc)
ytr = [p["is_disinfo"] for p in tr_posts]
MODELS = {True: RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, ytr), # 계정 평판 포함
False: RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, CONTENT], ytr)} # 내용 특징만
rng = np.random.default_rng(5)
ex_posts, ex_acc = make_posts(60, seed=21); evade(ex_posts, ex_acc, rng) # 모의 운용(AAR 대상)
ho_posts, ho_acc = make_posts(60, seed=22); evade(ho_posts, ho_acc, rng) # 회귀 시험용
y, kind = np.array([p["is_disinfo"] for p in ex_posts]), np.array([p["kind"] for p in ex_posts])
yh = np.array([p["is_disinfo"] for p in ho_posts])
# 1) 초기 설정: 기존 정규화(NFKC만), 계정 평판 포함 모델, 판정 임계값 0.5
init = {"account": True, "thr": 0.5}
p0 = run(ex_posts, ex_acc, init)
acc0 = (p0 == y).mean()
evaded = np.array(["" in p["text"] for p in ex_posts])
print(f"[1. 무엇이 일어났나] 초기 종합 정확도 {acc0:.3f}, 조작 재현율 {(p0 & y).sum() / y.sum():.3f}, 사실 오경보 {(p0 & ~y).sum()}건")
log = pd.DataFrame([{"구분": "미탐" if y[i] else "오탐", "원인": ("유사 문자 치환 회피" if evaded[i] else kind[i]) if y[i] else f"{ex_acc[ex_posts[i]['account_id']]['type']} 계정 {kind[i]}", "게시물": ex_posts[i]["post_id"]}
for i in np.where(p0 != y)[0]])
print(log.groupby(["구분", "원인"]).size().rename("건수").sort_values(ascending=False).head(6).to_string())
print(f"\n[2. 왜 일어났나] 평판 좋은 계정을 사칭한 조작 글은 계정 평판에 크게 기댄 모델을 통과하고, 유사 문자가 들어간 게시물 {evaded.sum()}건은 지역 코드 추출·동시 게시 비교가 실패한다.")
# 2) 재보정: 룰셋(정규화 보강) x 계정 평판 가중치(포함/제외) x 판정 임계값 격자 탐색
grid = []
for norm in (False, True):
RULESET["normalize"] = norm
F, _ = post_features(ex_posts, ex_acc)
for use_acc in (True, False):
pr = MODELS[use_acc].predict_proba(F if use_acc else F[:, CONTENT])[:, 1]
for thr in (0.3, 0.4, 0.5, 0.6):
p = pr >= thr
grid.append({"정규화 보강": norm, "계정 평판": "포함" if use_acc else "제외", "임계값": thr, "종합정확도": round(float((p == y).mean()), 3),
"조작 재현율": round(float((p & y).sum() / y.sum()), 3), "사실 오경보": int((p & ~y).sum())})
g = pd.DataFrame(grid).sort_values(["종합정확도", "사실 오경보"], ascending=[False, True])
print("\n[3. 무엇을 바꾸나] 격자 탐색 상위 5개")
print(g.head().to_string(index=False))
best = g.iloc[0]
cfg = {"account": best["계정 평판"] == "포함", "thr": float(best["임계값"])}
RULESET["normalize"] = bool(best["정규화 보강"])
p1 = run(ex_posts, ex_acc, cfg)
acc1 = (p1 == y).mean()
print(f"\n재보정값: 정규화 보강 {best['정규화 보강']}, 계정 평판 {best['계정 평판']}, 판정 임계값 {best['임계값']}")
print(f"종합 정확도 {acc0:.3f} -> {acc1:.3f} ({(acc1 - acc0) * 100:+.1f}%p, 목표 +10%p: {'달성' if acc1 - acc0 >= 0.1 else '미달'})")
RULESET["normalize"] = False; h0 = (run(ho_posts, ho_acc, init) == yh).mean()
RULESET["normalize"] = bool(best["정규화 보강"]); h1 = (run(ho_posts, ho_acc, cfg) == yh).mean()
print(f"회귀 시험(별도 게시물 {len(ho_posts)}건): {h0:.3f} -> {h1:.3f}")
again = set(np.where(p0 != y)[0]) & set(np.where(p1 != y)[0])
print(f"재보정 후 남은 오류 {(p1 != y).sum()}건, 그중 재발 {len(again)}건:", {str(k): int(v) for k, v in pd.Series([kind[i] for i in again]).value_counts().head(4).items()})
print("[4. 남은 과제] 재발 건은 원본과 거의 같은 미세 변조로, 새 사례를 학습 자료에 추가하는 지속 학습 주기(주 1회)와 정보관 원천 역추적(미션 7)으로 보완한다.")
tuned = (f"ruleset:\n nfkc: true\n homoglyph_map: {bool(best['정규화 보강'])}\n strip_zero_width: {bool(best['정규화 보강'])}\n"
f"detector:\n use_account_reputation: {cfg['account']}\n threshold: {best['임계값']}\n rf_trees: 200")
print("\n[재보정 파라미터(isr02_tuned_params.yaml 내용)]\n" + tuned)
plt.figure(figsize=(6, 4))
for norm in (False, True):
for ua in ("포함", "제외"):
s = pd.DataFrame(grid)[lambda d: (d["정규화 보강"] == norm) & (d["계정 평판"] == ua)]
plt.plot(s["임계값"], s["종합정확도"], "o-", label=f"normalize={norm}, account={'on' if ua == '포함' else 'off'}")
plt.xlabel("decision threshold"); plt.ylabel("accuracy"); plt.legend(); plt.title("AAR re-tuning grid (synthetic)")
plt.show()
In [ ]:
"""게시물 검증·정제(보강 정규화)·이야기 군집·조작 탐지·종합 신뢰도 등급(A~E)·정보관 긴급 검토 대기열·모니터링 화면까지 SNS 전쟁 게시물 신뢰도 자동 분류 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(기능 커버리지 포함)과 정보전 대응 브리핑 요약을 출력한다."""
import hashlib
import json
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import ndimage
from scipy.fft import dctn
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 합성 SNS 게시물 생성기 (실제 계정·채널·지명·게시물 없음)
# 가상 사건마다 원본 제보, 재게시(복제), 유사 재작성, 조작·기만 게시물 10종과 32x32 합성 이미지를 만든다.
# ---------------------------------------------------------------------------
REGIONS = [f"{a}-{n}" for a in "ABCDEF" for n in range(1, 6)]
OBJECTS = ["군용 차량", "소형 드론", "보급 트럭", "헬기", "버스", "견인 차량", "트레일러", "민간 승용차"]
DIRS = ["북쪽", "남쪽", "동쪽", "서쪽"]
TEMPLATES = ["가상 {r} 지역에서 {o} 목격, {n}대 규모로 {d}으로 이동 중",
"{r} 일대 {o} 확인됨. 약 {n}대, {d} 방향으로 이동",
"[현지] {r}에서 {o} 발견 - 수량 {n}, 이동 방향 {d}",
"{r} 주민 제보: {o} {n}대가 {d}으로 향하는 모습"]
MANIP = ["num_change", "region_swap", "dir_flip", "old_image", "splice", "ai_image", "ai_text", "bot_burst", "mashup", "recycled"]
ACCOUNT_TYPES = { # 유형별 계정 속성 분포(가상): 나이(일), 팔로워, 하루 게시 수, 원본 비율, 과거 정확도, 게시 간격 변동계수
"media": ((1000, 4000), (5e4, 5e5), (5, 30), (.6, .9), (.85, .97), (.8, 1.5)),
"witness": ((10, 800), (20, 2000), (.2, 3), (.7, 1.0), (.6, .95), (1.0, 2.0)),
"aggregator": ((200, 2000), (1e4, 2e5), (30, 200), (.05, .2), (.6, .85), (.5, 1.0)),
"propaganda": ((5, 200), (100, 2e4), (20, 150), (.3, .7), (.1, .4), (.05, .3)),
}
def make_accounts(rng, n=(8, 30, 10, 20)):
acc = {}
for (t, spec), k in zip(ACCOUNT_TYPES.items(), n):
for i in range(k):
v = [rng.uniform(*r) for r in spec]
acc[f"ACC_{t[:3].upper()}{i:02d}"] = {"type": t, "age_days": int(v[0]), "followers": int(v[1]), "posts_per_day": round(v[2], 1),
"original_ratio": round(v[3], 2), "past_accuracy": round(v[4], 2), "interval_cv": round(v[5], 2)}
return acc
def event_image(seed):
"""사건 현장 사진을 흉내 낸 32x32 합성 이미지(사건마다 고유한 도형 배치)."""
# [TODO : 학생 실습 10] SNS 전쟁 게시물 신뢰도 자동 분류 시스템 통합 및 정보전 대응 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def variant(img, rng):
"""재게시 과정의 자연스러운 변형: 재압축 잡음, 밝기 변화, 가장자리 잘림 중 하나."""
# [TODO : 학생 실습 10] SNS 전쟁 게시물 신뢰도 자동 분류 시스템 통합 및 정보전 대응 브리핑 패키지 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def splice(img, other, rng):
out = img.copy()
y, x = rng.integers(4, 18, 2)
out[y:y + 12, x:x + 12] = other[y:y + 12, x:x + 12] + rng.normal(0, 12, (12, 12)) # 잡음 수준이 다른 조각을 붙임
return out
def ai_image(rng):
"""생성형 모델 이미지를 흉내 낸 합성 이미지: 지나치게 매끈한 바탕 + 업샘플링 격자 무늬(주기 2픽셀)."""
base = ndimage.gaussian_filter(rng.normal(130, 60, (32, 32)), 3)
grid = (np.indices((32, 32)).sum(0) % 2) * rng.uniform(6, 12)
return base + grid
def write(ev, rng, tpl=None, **kw):
s = {"r": ev["r"], "o": ev["o"], "n": ev["n"], "d": ev["d"], **kw}
return (tpl or TEMPLATES[int(rng.integers(len(TEMPLATES)))]).format(**s)
def make_posts(n_events=40, seed=7):
rng = np.random.default_rng(seed)
acc = make_accounts(rng)
by = {t: [a for a, v in acc.items() if v["type"] == t] for t in ACCOUNT_TYPES}
events = [{"id": f"EV{i:03d}", "r": str(rng.choice(REGIONS)), "o": str(rng.choice(OBJECTS)), "n": int(rng.integers(2, 15)),
"d": str(rng.choice(DIRS)), "t0": float(i * 1.2 + rng.uniform(0, 1)), "img": event_image(seed * 1000 + i)} for i in range(n_events)]
posts = []
def add(ev, account, text, img, t, fwd, kind, origin):
pid = f"P{len(posts):04d}"
posts.append({"post_id": pid, "platform": str(rng.choice(["telegram", "x", "other"], p=[.6, .3, .1])), "account_id": account,
"posted_at": round(t, 3), "text": text, "image": img, "forwarded_from": fwd, "event_id": ev["id"] if ev else None,
"kind": kind, "is_disinfo": kind in MANIP, "origin": origin})
return pid
for k, ev in enumerate(events):
src = str(rng.choice(by["witness"] if rng.random() < .6 else by["media"]))
if rng.random() < .05:
src = str(rng.choice(by["propaganda"])) # 신뢰도 낮은 계정도 사실을 올릴 수 있다
o = add(ev, src, write(ev, rng), ev["img"], ev["t0"], None, "original", None)
for _ in range(int(rng.integers(2, 5))): # 그대로 재게시(전달)
add(ev, str(rng.choice(by["aggregator"])), posts[int(o[1:])]["text"], ev["img"], ev["t0"] + rng.exponential(1), o, "copy", o)
for _ in range(int(rng.integers(1, 3))): # 표현만 바꾼 재작성(내용 동일)
add(ev, str(rng.choice(by["media"] + by["witness"])), "#가상속보 " + write(ev, rng), variant(ev["img"], rng),
ev["t0"] + rng.uniform(0.5, 6), None, "near_dup", o)
for m in MANIP if k % 2 == 0 else MANIP[k % 4::4]: # 조작·기만 게시물
if rng.random() > 0.5:
continue
who = str(rng.choice(by["propaganda"] if rng.random() < .75 else by["aggregator"] + by["witness"]))
t, img, text = ev["t0"] + rng.uniform(1, 12), variant(ev["img"], rng), None
if m == "num_change":
text = write(ev, rng, n=ev["n"] * int(rng.integers(5, 10)))
elif m == "region_swap":
text = write(ev, rng, r=str(rng.choice([r for r in REGIONS if r != ev["r"]])))
elif m == "dir_flip":
text = write(ev, rng, d={"북쪽": "남쪽", "남쪽": "북쪽", "동쪽": "서쪽", "서쪽": "동쪽"}[ev["d"]])
elif m == "old_image" and k > 0:
text, img = write(ev, rng), events[int(rng.integers(0, k))]["img"]
elif m == "splice":
text, img = write(ev, rng), splice(ev["img"], events[(k + 7) % n_events]["img"], rng)
elif m == "ai_image":
text, img = write(ev, rng), ai_image(rng)
elif m == "ai_text":
text = f"속보 속보! {ev['r']} 지역 대규모 {ev['o']} 출현! 긴급 확산 바람! 긴급 확산 바람! 모두 확산 바람!"
img = ai_image(rng) if rng.random() < .5 else None
elif m == "bot_burst":
text = write(ev, rng, n=ev["n"] * 6) + " 지금 바로 공유"
for b in rng.choice(by["propaganda"], 4, replace=False):
add(ev, str(b), text, img, t + rng.uniform(0, 0.15), None, m, o)
continue
elif m == "mashup":
other = events[(k + 3) % n_events]
text = f"{ev['r']} 일대 {ev['o']} 확인, {other['n']}대가 {other['d']}으로 이동" # 다른 사건 내용과 짜깁기
elif m == "recycled" and k > 2:
old = events[int(rng.integers(0, k - 1))]
text, img, t = write(old, rng), old["img"], ev["t0"] + rng.uniform(0, 3)
if text is None:
continue
add(ev, who, text, img, t, None, m, o)
return posts, acc
# ---------------------------------------------------------------------------
# ---- 텍스트 정제·이미지 지각 해시·조작 흔적 공통 함수 ----
STOPWORDS = {"가상", "약", "일대", "지역에서", "모습", "중", "확인됨", "발견", "제보", "주민"}
_URL = re.compile(r"https?://\S+|www\.\S+")
_SPECIAL = re.compile(r"[^0-9A-Za-z가-힣\-\s]")
def clean_text(t):
"""정규화(NFKC) -> URL·멘션 제거 -> 해시태그 기호 제거 -> 특수문자 제거 -> 불용어 제거 -> 공백 정리."""
t = unicodedata.normalize("NFKC", t)
t = _URL.sub(" ", t)
t = re.sub(r"@\w+", " ", t).replace("#", " ")
t = _SPECIAL.sub(" ", t)
return " ".join(w for w in t.split() if w not in STOPWORDS)
def phash(img):
"""지각 해시(pHash): 32x32 이미지 DCT의 저주파 8x8(직류 성분 제외)을 중앙값과 비교한 64비트."""
c = dctn(np.asarray(img, float), norm="ortho")[:8, :8].ravel()[1:]
return np.append(c > np.median(c), False)
def hamming(a, b):
return int(np.count_nonzero(a != b))
def slots(text):
"""본문에서 지역 코드·수량·방향·대상을 뽑는다(사건 내용 비교용)."""
r = re.search(r"[A-F]-\d", text)
n = re.search(r"(\d+)\s*대|수량\s*(\d+)", text)
d = next((x for x in DIRS if x in text), None)
o = next((x for x in OBJECTS if x in text), None)
return {"r": r.group(0) if r else None, "n": int(n.group(1) or n.group(2)) if n else None, "d": d, "o": o}
def splice_score(img):
"""합성(붙여넣기) 흔적: 8x8 블록별 고주파 잡음 크기의 최대/중앙값 비율. 잡음 수준이 다른 조각이 있으면 커진다."""
res = img - ndimage.median_filter(img, 3)
b = np.abs(res).reshape(4, 8, 4, 8).std(axis=(1, 3))
return float(b.max() / (np.median(b) + 1e-6))
def ai_image_score(img):
"""생성형 이미지 흔적: 2픽셀 주기 격자(나이퀴스트 주파수) 에너지 비율."""
f = np.abs(np.fft.fft2(img - img.mean()))
return float(f[16, 16] / (f.sum() / f.size + 1e-6))
def ai_text_score(text):
"""기계적 선동 문체 흔적: 반복 어절 비율 + 느낌표 밀도."""
w = text.split()
return (1 - len(set(w)) / max(len(w), 1)) + text.count("!") / max(len(w), 1)
# ---- 게시물 군집(같은 이야기 묶기)·조작 탐지 특징 공통 함수 ----
FEAT = ["num_dev", "region_mis", "dir_mis", "obj_mis", "img_ref_dist", "img_prior_h", "time_gap_h", "splice", "ai_img",
"ai_text", "burst_n", "has_img", "is_fwd", "acc_age", "acc_followers", "acc_orig", "acc_past", "acc_cv"]
def cluster_posts(posts, hours=24, ham_thr=8):
"""같은 이야기 묶기: 본문에서 뽑은 (지역, 대상)이 같고 24시간 안이면 연결하고,
지각 해시가 가까운 이미지(같은 대상)도 연결한다. 글 표현이 달라도 내용 단위로 묶인다."""
texts = [clean_text(p["text"]) for p in posts]
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit_transform(texts)
S = (X @ X.T).toarray()
H = [phash(p["image"]) if p["image"] is not None else None for p in posts]
sl = [slots(p["text"]) for p in posts]
t = [p["posted_at"] for p in posts]
parent = list(range(len(posts)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]; i = parent[i]
return i
for i in range(len(posts)):
for j in range(i + 1, len(posts)):
if abs(t[i] - t[j]) >= hours or sl[i]["o"] != sl[j]["o"]:
continue
same_story = sl[i]["r"] is not None and sl[i]["r"] == sl[j]["r"]
same_img = H[i] is not None and H[j] is not None and hamming(H[i], H[j]) <= ham_thr
if same_story or same_img:
parent[find(i)] = find(j)
return np.array([find(i) for i in range(len(posts))]), S, H
def post_features(posts, acc):
cid, S, H = cluster_posts(posts)
t = np.array([p["posted_at"] for p in posts])
sl = [slots(p["text"]) for p in posts]
first_seen = {}
for i in np.argsort(t): # 이미지가 처음 등장한 시각과 군집
if H[i] is None:
continue
key = next((k for k in first_seen if hamming(np.array(k), H[i]) <= 6), None)
if key is None:
first_seen[tuple(H[i])] = (t[i], cid[i])
elif first_seen[key][1] != cid[i]:
pass
ref = {c: int(np.argmin(np.where(cid == c, t, np.inf))) for c in set(cid)} # 군집에서 가장 이른 게시물 = 기준(추정 원본)
F = []
for i, p in enumerate(posts):
r = ref[cid[i]]
a, b = sl[i], sl[r]
img_prior = 0.0
if H[i] is not None:
for k, (ts, c) in first_seen.items():
if hamming(np.array(k), H[i]) <= 6 and c != cid[i]:
img_prior = max(img_prior, min(t[i] - ts, 48.0))
same_txt = [j for j in np.where(S[i] > 0.97)[0] if abs(t[j] - t[i]) < 0.25 and posts[j]["account_id"] != p["account_id"]]
A = acc[p["account_id"]]
F.append([abs(np.log((a["n"] or 1) / (b["n"] or 1))), float(a["r"] != b["r"]), float(a["d"] != b["d"]), float(a["o"] != b["o"]),
hamming(H[i], H[r]) if H[i] is not None and H[r] is not None else 0, img_prior, t[i] - t[r],
splice_score(p["image"]) if p["image"] is not None else 0, ai_image_score(p["image"]) if p["image"] is not None else 0,
ai_text_score(p["text"]), len(set(posts[j]["account_id"] for j in same_txt)), float(p["image"] is not None),
float(p["forwarded_from"] is not None), np.log1p(A["age_days"]), np.log1p(A["followers"]), A["original_ratio"],
A["past_accuracy"], A["interval_cv"]])
return np.array(F), cid
CONFIG = {"threshold": 0.3, "use_account": True, "normalize": True, # 미션 9 재보정값
"weights": [0.35, 0.20, 0.25, 0.10, 0.10], "grades": [(0.80, "A"), (0.65, "B"), (0.50, "C"), (0.35, "D"), (-1, "E")]}
HOMO = {"A": "А", "B": "В", "C": "С", "E": "Е"}
ZW = ""
CALLED = set()
def cover(fn):
"""기능 커버리지 측정용 표시: 통합 실행 중 호출된 기능을 기록한다."""
def wrap(*a, **k):
CALLED.add(fn.__name__)
return fn(*a, **k)
wrap.__name__ = fn.__name__
return wrap
@cover
def normalize(t):
t = unicodedata.normalize("NFKC", t).replace(ZW, "")
for k, v in HOMO.items():
t = t.replace(v, k)
return t
_slots, _clean = slots, clean_text
@cover
def slots(t):
return _slots(normalize(t))
@cover
def clean_text(t):
return _clean(normalize(t))
phash, cluster_posts, post_features = cover(phash), cover(cluster_posts), cover(post_features)
@cover
def validate_post(p):
errs = []
if p["platform"] not in ("telegram", "x", "other"):
errs.append("platform 허용값 아님")
if not isinstance(p["text"], str) or not p["text"]:
errs.append("본문 형식 오류")
if p["image"] is not None and np.asarray(p["image"]).shape != (32, 32):
errs.append("이미지 형식 오류")
return errs
@cover
def credibility(posts, F, cid, pm, am):
sl = [slots(p["text"]) for p in posts]
corro = [min(len({posts[j]["account_id"] for j in np.where(cid == cid[i])[0] if j != i and posts[j]["forwarded_from"] is None
and posts[j]["account_id"] != p["account_id"] and (sl[j]["r"], sl[j]["n"], sl[j]["d"]) == (sl[i]["r"], sl[i]["n"], sl[i]["d"])}) / 2, 1)
for i, p in enumerate(posts)]
C = np.c_[1 - pm, 1 - am, corro, 1 - np.minimum(F[:, FEAT.index("burst_n")] / 3, 1), np.exp(-np.maximum(F[:, FEAT.index("time_gap_h")], 0) / 12)]
s = C @ np.array(CONFIG["weights"])
return s, np.array([next(g for t, g in CONFIG["grades"] if v >= t) for v in s])
class Queue:
def __init__(self):
self.items, self.log = [], []
@cover
def submit(self, pid, grade, prob):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"event": "SUBMIT", "id": pid, "grade": grade, "prob": round(float(prob), 3)}
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})
self.items.append({**body, "status": "PENDING"})
@cover
def verify(self):
prev = "GENESIS"
for r in self.log:
body = {k: v for k, v in r.items() if k not in ("prev", "hash")}
if r["prev"] != prev or r["hash"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():
return False
prev = r["hash"]
return True
TARGETS = {"normalize", "slots", "clean_text", "phash", "cluster_posts", "post_features", "validate_post", "credibility", "submit", "verify", "dashboard"}
# ---------------- 1. 학습 ----------------
t_start = time.perf_counter()
tr_posts, tr_acc = make_posts(60, seed=7)
Ftr, _ = post_features(tr_posts, tr_acc)
ytr = [p["is_disinfo"] for p in tr_posts]
det = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr, ytr)
manip = RandomForestClassifier(n_estimators=200, min_samples_leaf=3, random_state=0).fit(Ftr[:, :13], ytr)
accm = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)).fit(Ftr[:, 13:], ytr)
# ---------------- 2. 운용 자료(가상 게시물 스트림) 처리 ----------------
rng = np.random.default_rng(31)
posts, acc = make_posts(60, seed=31)
good = [a for a, v in acc.items() if v["type"] in ("media", "witness")]
for p in posts: # 신종 변조 패턴 일부 포함
if p["is_disinfo"] and rng.random() < 0.3:
p["account_id"] = str(rng.choice(good))
if p["is_disinfo"] and rng.random() < 0.4:
p["text"] = re.sub(r"([A-F])-", lambda m: HOMO.get(m.group(1), m.group(1)) + ZW + "-", p["text"])
posts[11]["text"] = 404 # 손상 게시물 2건 주입(검증 단계에서 차단되어야 함)
posts[23]["platform"] = "unknown_app"
rejected = {}
ok_posts = []
for p in posts:
e = validate_post(p)
if e:
rejected[p["post_id"]] = e
else:
ok_posts.append(p)
t0 = time.perf_counter()
F, cid = post_features(ok_posts, acc)
prob = det.predict_proba(F)[:, 1]
flag = prob >= CONFIG["threshold"]
score, G = credibility(ok_posts, F, cid, manip.predict_proba(F[:, :13])[:, 1], accm.predict_proba(F[:, 13:])[:, 1])
ms = (time.perf_counter() - t0) / len(ok_posts) * 1000
queue = Queue()
for p, f, g, pr in zip(ok_posts, flag, G, prob):
if f or g in ("D", "E"):
queue.submit(p["post_id"], g, pr) # 차단·채택은 정보관만(자동 처리 없음)
y, kind = np.array([p["is_disinfo"] for p in ok_posts]), np.array([p["kind"] for p in ok_posts])
acc_all, rec, far = (flag == y).mean(), (flag & y).sum() / y.sum(), (flag & ~y).sum() / (~y).sum()
# ---------------- 3. 모니터링 화면 ----------------
@cover
def dashboard():
plt.style.use("dark_background")
fig, ax = plt.subplots(2, 2, figsize=(12, 9))
t = np.array([p["posted_at"] for p in ok_posts])
for gset, c, lab in [(("A", "B"), "limegreen", "A-B"), (("C",), "orange", "C"), (("D", "E"), "red", "D-E")]:
ax[0, 0].hist(t[np.isin(G, gset)], bins=24, alpha=.7, color=c, label=lab)
ax[0, 0].set_title("Posts over time by credibility grade"); ax[0, 0].legend()
gc = pd.Series(G).value_counts().reindex(list("ABCDE")).fillna(0)
ax[0, 1].bar(gc.index.tolist(), gc.values, color=["limegreen", "limegreen", "orange", "red", "red"]); ax[0, 1].set_title("Grade distribution")
r = pd.Series(flag[y], index=kind[y]).groupby(level=0).mean()
ax[1, 0].barh(r.index.tolist(), r.values, color="deepskyblue"); ax[1, 0].set_title("Detection recall by manipulation type"); ax[1, 0].set_xlim(0, 1.05)
ax[1, 1].axis("off")
ax[1, 1].text(0, .9, f"Accuracy {acc_all:.1%}\nRecall {rec:.1%}\nFalse flags {far:.1%}\nQueue {len(queue.items)} pending\nLatency {ms:.2f} ms/post",
family="monospace", fontsize=13, va="top")
fig.suptitle("ISR02 integrated SNS credibility system - synthetic data only"); plt.tight_layout(); plt.show()
dashboard()
# ---------------- 4. 자체 점검(통합 테스트) ----------------
log_ok = queue.verify()
cov = len(CALLED & TARGETS) / len(TARGETS)
chk = {
"손상 게시물 차단": set(rejected) == {"P0011", "P0023"},
"처리 게시물 수 일치": len(ok_posts) == len(posts) - 2,
"종합 정확도 90% 이상": acc_all >= 0.9,
"조작 탐지 재현율 85% 이상": rec >= 0.85,
"사실 게시물 오경보 10% 이하": far <= 0.10,
"자동 차단·채택 0건(전부 검토 대기)": all(i["status"] == "PENDING" for i in queue.items),
"감사 로그 무결성": log_ok,
"등급 규칙 일관": all(g == next(gg for t, gg in CONFIG["grades"] if s >= t) for s, g in zip(score, G)),
"게시물당 200ms 이내": ms < 200,
"기능 커버리지 85% 이상": cov >= 0.85,
}
passed = sum(chk.values())
for k, v in chk.items():
print(f"[{'PASS' if v else 'FAIL'}] {k}")
print(f"자체 점검 {passed}/{len(chk)} 통과, 기능 커버리지 {cov:.0%} ({len(CALLED & TARGETS)}/{len(TARGETS)}), 파이프라인 실행 성공 {'예' if passed == len(chk) else '아니오'}")
# ---------------- 5. 정보전 대응 브리핑 요약 ----------------
top = pd.Series(kind[flag & y]).value_counts().head(3).to_dict()
print(f"""
[SNS 전쟁 게시물 신뢰도 분류 브리핑 요약 - 교육용가상자료]
1. 처리: 게시물 {len(posts)}건 중 {len(ok_posts)}건 분석, 손상 {len(rejected)}건 차단({', '.join(rejected)}), 이야기 군집 {len(set(cid))}개
2. 성능: 종합 정확도 {acc_all:.1%}, 조작 탐지 재현율 {rec:.1%}, 사실 게시물 오경보 {far:.1%}, 게시물당 {ms:.2f} ms, 전체 {time.perf_counter() - t_start:.0f} s
3. 등급: A {int((G == 'A').sum())} / B {int((G == 'B').sum())} / C {int((G == 'C').sum())} / D {int((G == 'D').sum())} / E {int((G == 'E').sum())}, 정보관 검토 대기 {len(queue.items)}건, 자동 차단 0건
4. 주요 조작 유형: {top}
5. 한계: 합성 게시물 기준 결과로, 실제 플랫폼 자료에는 재학습·재검증이 필요하며 차단·채택은 원천 역추적을 거친 정보관 결정으로만 한다.""")