Upload 7 files
Browse files- MODEL_CARD.md +46 -0
- README.md +63 -7
- adapter_config.json +48 -0
- adapter_model.safetensors +3 -0
- app.py +279 -0
- tokenizer.json +0 -0
- tokenizer_config.json +15 -0
MODEL_CARD.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model Card — AI 신약 개발 분석 모델
|
| 2 |
+
|
| 3 |
+
## 1. 모델 개요
|
| 4 |
+
- 모델명: AI 신약 개발 분석 모델
|
| 5 |
+
- 목적: AI 신약 개발 관련 뉴스가 진짜뉴스인지 가짜뉴스인지 판단하기 위한 보조 모델
|
| 6 |
+
- 팀명: Team 3
|
| 7 |
+
- 일자: 2026-08-13
|
| 8 |
+
|
| 9 |
+
## 2. 의도된 사용
|
| 10 |
+
- 사용 가능: AI 신약개발 관련 뉴스가 진짜인지 가짜인지 구분하는 보조 판별
|
| 11 |
+
- 사용 금지: 가짜뉴스를 배포하기 위해 모델을 우회할 수 있을 정도로 교묘한 가짜뉴스 생성에 사용하는 경우
|
| 12 |
+
|
| 13 |
+
## 3. 학습 데이터
|
| 14 |
+
- 출처: 동아사이언스 DB
|
| 15 |
+
- 건수: 200건(정상 100건 + 위험 100건)
|
| 16 |
+
- 기간: 2024년~2026년
|
| 17 |
+
- 검수: CDA 200건
|
| 18 |
+
|
| 19 |
+
## 4. 성능 지표
|
| 20 |
+
Model Card에 기재된 평가 결과:
|
| 21 |
+
- Accuracy: 100
|
| 22 |
+
- F1: 1.0
|
| 23 |
+
- TP: 20
|
| 24 |
+
- TN: 20
|
| 25 |
+
- FP: 0
|
| 26 |
+
- FN: 0
|
| 27 |
+
- 가장 약한 유형: 우회 공격
|
| 28 |
+
|
| 29 |
+
> 위 수치는 Model Card에 기록된 해당 평가 세트의 결과이며, 실제 서비스 환경에서 동일한 성능을 보장하지 않습니다.
|
| 30 |
+
|
| 31 |
+
## 5. 한계 및 위험
|
| 32 |
+
스트레스 테스트에서 다음 유형의 오판 가능성이 기록되어 있습니다.
|
| 33 |
+
1. 공신력 있는 기관의 명칭을 도용하였을 때 오판 가능 — 위험 수준: 높음
|
| 34 |
+
2. 의약품과 관련 없는 공신력 있는 기관의 명칭을 가져올 때 오판 가능 — 위험 수준: 중간
|
| 35 |
+
3. 거짓된 정보를 구체적 수치로 작성하였을 때 오판 가능 — 위험 수준: 높음
|
| 36 |
+
|
| 37 |
+
예시로 우회적·비꼬기 표현에서 오판 가능성이 제시되어 있습니다. 따라서 모델의 확률값이나 attention 강조 토큰을 사실검증의 근거로 단독 사용하면 안 됩니다.
|
| 38 |
+
|
| 39 |
+
## 6. 개발자 책임 선언
|
| 40 |
+
- 이 AI가 가짜뉴스를 완벽히 판별하지 못한다는 한계를 인정합니다.
|
| 41 |
+
- 사용자에게 화면에서 한계를 고지합니다.
|
| 42 |
+
- 이의 제기 통로는 개발자 이메일을 사용하도록 설계하되, 원본 Model Card에는 실제 이메일 주소가 기재되어 있지 않아 배포자가 `CONTACT_EMAIL`을 설정해야 합니다.
|
| 43 |
+
- 투명성(근거 공개), 책임성(근거 공개), 피해 최소화(고지), 공정성(현황 인식)을 고려합니다.
|
| 44 |
+
|
| 45 |
+
## XAI 표시 주의
|
| 46 |
+
웹 인터페이스의 Highlight Word는 마지막 Transformer attention 층의 `[CLS]`→토큰 attention을 평균한 참고 신호입니다. Attention은 인과적 설명이나 외부 사실검증 결과가 아닙니다.
|
README.md
CHANGED
|
@@ -1,13 +1,69 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AI 신약개발 뉴스 판별
|
| 3 |
+
description: KoELECTRA + LoRA 기반 AI 신약개발 뉴스 위험 판별과 attention 참고 표시
|
| 4 |
+
emoji: 🧪
|
| 5 |
+
colorFrom: purple
|
| 6 |
+
colorTo: green
|
| 7 |
sdk: gradio
|
| 8 |
+
sdk_version: 4.19.2
|
|
|
|
| 9 |
app_file: app.py
|
| 10 |
+
python_version: "3.10"
|
| 11 |
+
models:
|
| 12 |
+
- monologg/koelectra-small-v3-discriminator
|
| 13 |
pinned: false
|
| 14 |
---
|
| 15 |
|
| 16 |
+
# AI 신약개발 뉴스 판별 Space
|
| 17 |
+
|
| 18 |
+
첨부된 알고리즘 흐름도와 UI 스케치를 Gradio로 구현한 Hugging Face Spaces용 프로젝트입니다.
|
| 19 |
+
|
| 20 |
+
## 실행 흐름
|
| 21 |
+
1. 사용자 뉴스 텍스트 입력
|
| 22 |
+
2. 최소 전처리(공백 정리)
|
| 23 |
+
3. KoELECTRA tokenizer로 토크나이징
|
| 24 |
+
4. `monologg/koelectra-small-v3-discriminator` + LoRA adapter 추론
|
| 25 |
+
5. 마지막 attention 층을 이용한 참고용 Highlight Word 추출
|
| 26 |
+
6. 정상/가짜뉴스 위험 확률 표시
|
| 27 |
+
7. Model Card 한계를 화면 상단과 하단에 상시 노출
|
| 28 |
+
|
| 29 |
+
## 런타임 오류 방지 조치
|
| 30 |
+
- Python을 `3.10`으로 고정했습니다.
|
| 31 |
+
- `requirements.txt`는 지정 버전을 그대로 사용합니다.
|
| 32 |
+
- 업로드된 adapter의 원본 `adapter_config.json`은 PEFT 0.20.0에서 생성되어 `alora_invocation_tokens` 등 PEFT 0.9.0이 모르는 필드를 포함하고 있었습니다.
|
| 33 |
+
- 그래서 `lora_climate_misinfo/adapter_config.json`을 PEFT 0.9.0 호환 필드만 남긴 버전으로 교체했고, 원본은 `adapter_config.original_peft020.json`에 보관했습니다.
|
| 34 |
+
- `app.py`도 `LoraConfig`를 명시적으로 생성해 `PEFT 0.9.0`이 새 필드를 파싱하지 않도록 이중으로 방지합니다.
|
| 35 |
+
- LoRA 로드 실패 시 base model만으로 임의 판정하지 않고 UI에 오류를 표시합니다.
|
| 36 |
+
|
| 37 |
+
## Space 업로드
|
| 38 |
+
이 폴더의 **내용 전체**를 새 Hugging Face Space 저장소 루트에 업로드하면 됩니다. `README.md`, `app.py`, `requirements.txt`, `lora_climate_misinfo/`가 같은 루트 구조를 유지해야 합니다.
|
| 39 |
+
|
| 40 |
+
## 이의 제기 이메일
|
| 41 |
+
원본 Model Card에는 개발자 이메일 주소가 실제로 적혀 있지 않습니다. Space 설정의 Variables에 `CONTACT_EMAIL`을 추가하면 UI의 **이의 제기 안내** 버튼에 해당 주소가 표시됩니다.
|
| 42 |
+
|
| 43 |
+
## 파일 구조
|
| 44 |
+
```text
|
| 45 |
+
.
|
| 46 |
+
├── app.py
|
| 47 |
+
├── README.md
|
| 48 |
+
├── requirements.txt
|
| 49 |
+
├── MODEL_CARD.md
|
| 50 |
+
├── lora_climate_misinfo/
|
| 51 |
+
│ ├── adapter_config.json
|
| 52 |
+
│ ├── adapter_config.original_peft020.json
|
| 53 |
+
│ ├── adapter_model.safetensors
|
| 54 |
+
│ ├── tokenizer.json
|
| 55 |
+
│ ├── tokenizer_config.json
|
| 56 |
+
│ └── README.md
|
| 57 |
+
└── assets/
|
| 58 |
+
├── algorithm_flow.png
|
| 59 |
+
├── ui_sketch.png
|
| 60 |
+
└── model_card_source.pdf
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Model Card 핵심 한계
|
| 64 |
+
- 공신력 있는 기관명을 도용한 문장: 오판 가능
|
| 65 |
+
- 의약품과 무관한 공신력 기관명을 가져온 문장: 오판 가능
|
| 66 |
+
- 거짓 정보를 구체적 수치로 작성한 문장: 오판 가능
|
| 67 |
+
- 우회적·비꼬기 표현: 취약 가능
|
| 68 |
+
|
| 69 |
+
자세한 내용은 `MODEL_CARD.md`를 확인하세요.
|
adapter_config.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"alora_invocation_tokens": null,
|
| 3 |
+
"alpha_pattern": {},
|
| 4 |
+
"arrow_config": null,
|
| 5 |
+
"auto_mapping": null,
|
| 6 |
+
"base_model_name_or_path": "monologg/koelectra-small-v3-discriminator",
|
| 7 |
+
"bias": "none",
|
| 8 |
+
"corda_config": null,
|
| 9 |
+
"ensure_weight_tying": false,
|
| 10 |
+
"eva_config": null,
|
| 11 |
+
"exclude_modules": null,
|
| 12 |
+
"fan_in_fan_out": false,
|
| 13 |
+
"inference_mode": true,
|
| 14 |
+
"init_lora_weights": true,
|
| 15 |
+
"layer_replication": null,
|
| 16 |
+
"layers_pattern": null,
|
| 17 |
+
"layers_to_transform": null,
|
| 18 |
+
"loftq_config": {},
|
| 19 |
+
"lora_alpha": 16,
|
| 20 |
+
"lora_bias": false,
|
| 21 |
+
"lora_dropout": 0.1,
|
| 22 |
+
"lora_ga_config": null,
|
| 23 |
+
"megatron_config": null,
|
| 24 |
+
"megatron_core": "megatron.core",
|
| 25 |
+
"modules_to_save": [
|
| 26 |
+
"classifier",
|
| 27 |
+
"score"
|
| 28 |
+
],
|
| 29 |
+
"monteclora_config": null,
|
| 30 |
+
"peft_type": "LORA",
|
| 31 |
+
"peft_version": "0.20.0",
|
| 32 |
+
"qalora_group_size": 16,
|
| 33 |
+
"r": 8,
|
| 34 |
+
"rank_pattern": {},
|
| 35 |
+
"revision": null,
|
| 36 |
+
"target_modules": [
|
| 37 |
+
"query",
|
| 38 |
+
"value"
|
| 39 |
+
],
|
| 40 |
+
"target_parameters": null,
|
| 41 |
+
"task_type": "SEQ_CLS",
|
| 42 |
+
"trainable_token_indices": null,
|
| 43 |
+
"use_bdlora": null,
|
| 44 |
+
"use_dora": false,
|
| 45 |
+
"use_qalora": false,
|
| 46 |
+
"use_rslora": false,
|
| 47 |
+
"velora_config": null
|
| 48 |
+
}
|
adapter_model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:244409276fe68018fc658d111ecf24f29568385d4475ad1cf22a82da7d341e8e
|
| 3 |
+
size 665672
|
app.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import html
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import torch
|
| 8 |
+
from peft import LoraConfig, PeftModel, TaskType
|
| 9 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 10 |
+
|
| 11 |
+
BASE_MODEL = "monologg/koelectra-small-v3-discriminator"
|
| 12 |
+
LORA_DIR = Path(__file__).parent / "lora_climate_misinfo"
|
| 13 |
+
MAX_LENGTH = 512
|
| 14 |
+
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "").strip()
|
| 15 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 16 |
+
|
| 17 |
+
# 학습 데이터의 라벨 정의: 0=정상, 1=위험(가짜뉴스)
|
| 18 |
+
LABELS = {0: "정상", 1: "가짜뉴스 위험"}
|
| 19 |
+
|
| 20 |
+
LIMITATION_TEXT = (
|
| 21 |
+
"이 모델은 AI 신약개발 관련 뉴스의 진위 판별을 돕는 보조 도구이며, 완벽한 사실검증기가 아닙니다. "
|
| 22 |
+
"Model Card의 스트레스 테스트에서는 공신력 있는 기관명을 도용한 문장, 의약품과 무관한 공신력 기관명을 "
|
| 23 |
+
"가져온 문장, 거짓 정보를 구체적 수치로 작성한 문장에서 오판 가능성이 확인되었습니다. "
|
| 24 |
+
"특히 우회적·비꼬기 표현도 취약할 수 있으므로 최종 판단은 원문 출처와 논문·기관 자료를 함께 확인하세요."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
CUSTOM_CSS = r"""
|
| 28 |
+
.gradio-container {max-width: 980px !important; margin: 0 auto !important;}
|
| 29 |
+
#hero {text-align:center; margin-bottom: 8px;}
|
| 30 |
+
.warning-box {border: 1px solid #e5b94f; background:#fff8df; border-radius:14px; padding:14px 16px; margin:8px 0 18px 0;}
|
| 31 |
+
.result-card {border:2px solid #222; border-radius:16px; padding:16px; background:white; min-height:132px;}
|
| 32 |
+
.result-head {font-size:14px; color:#555; margin-bottom:8px;}
|
| 33 |
+
.result-main {display:flex; align-items:center; gap:10px; font-size:24px; font-weight:700;}
|
| 34 |
+
.dot {width:18px; height:18px; border-radius:50%; display:inline-block; flex:0 0 auto;}
|
| 35 |
+
.dot-risk {background:#c95b4b;}
|
| 36 |
+
.dot-safe {background:#4e9f6a;}
|
| 37 |
+
.score-line {margin-top:10px; font-size:16px;}
|
| 38 |
+
.xai-box {border:2px solid #222; border-radius:20px; padding:14px; background:#fff;}
|
| 39 |
+
.xai-title {display:inline-block; border:2px solid #222; border-radius:20px; padding:2px 12px; font-weight:700; margin-bottom:12px;}
|
| 40 |
+
.token-wrap {display:flex; flex-wrap:wrap; gap:8px;}
|
| 41 |
+
.token-chip {border:1px solid #777; border-radius:12px; padding:6px 9px; background:#f7f7f7;}
|
| 42 |
+
.token-chip strong {font-weight:700;}
|
| 43 |
+
.small-note {font-size:13px; color:#666; margin-top:10px;}
|
| 44 |
+
.disclaimer {border:1px solid #999; border-radius:14px; padding:12px 14px; background:#f7f7f7; font-size:14px;}
|
| 45 |
+
.error-card {border:2px solid #a33; border-radius:14px; padding:14px; background:#fff3f3; color:#7b1f1f;}
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def preprocess_text(text: str) -> str:
|
| 50 |
+
"""흐름도의 '텍스트 전처리' 단계. 의미를 훼손하지 않는 범위에서 공백만 정리합니다."""
|
| 51 |
+
text = (text or "").strip()
|
| 52 |
+
text = re.sub(r"\s+", " ", text)
|
| 53 |
+
return text
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def build_peft_config() -> LoraConfig:
|
| 57 |
+
"""PEFT 0.9.0과 호환되는 LoRA 설정을 코드에서 명시적으로 생성합니다.
|
| 58 |
+
|
| 59 |
+
업로드된 어댑터는 PEFT 0.20.0에서 저장되어 새 필드가 포함되어 있었기 때문에,
|
| 60 |
+
구버전에서 'unexpected keyword'가 나지 않도록 필요한 설정만 사용합니다.
|
| 61 |
+
"""
|
| 62 |
+
return LoraConfig(
|
| 63 |
+
r=8,
|
| 64 |
+
lora_alpha=16,
|
| 65 |
+
target_modules=["query", "value"],
|
| 66 |
+
lora_dropout=0.1,
|
| 67 |
+
bias="none",
|
| 68 |
+
task_type=TaskType.SEQ_CLS,
|
| 69 |
+
modules_to_save=["classifier", "score"],
|
| 70 |
+
inference_mode=True,
|
| 71 |
+
use_rslora=False,
|
| 72 |
+
use_dora=False,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_model_bundle():
|
| 77 |
+
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, use_fast=True)
|
| 78 |
+
base_model = AutoModelForSequenceClassification.from_pretrained(
|
| 79 |
+
BASE_MODEL,
|
| 80 |
+
num_labels=2,
|
| 81 |
+
)
|
| 82 |
+
peft_config = build_peft_config()
|
| 83 |
+
model = PeftModel.from_pretrained(
|
| 84 |
+
base_model,
|
| 85 |
+
str(LORA_DIR),
|
| 86 |
+
config=peft_config,
|
| 87 |
+
is_trainable=False,
|
| 88 |
+
)
|
| 89 |
+
model.to(DEVICE)
|
| 90 |
+
model.eval()
|
| 91 |
+
return tokenizer, model
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
TOKENIZER = None
|
| 95 |
+
MODEL = None
|
| 96 |
+
MODEL_LOAD_ERROR = None
|
| 97 |
+
try:
|
| 98 |
+
TOKENIZER, MODEL = load_model_bundle()
|
| 99 |
+
except Exception as exc: # Space 자체가 죽지 않고 UI에서 원인을 확인할 수 있게 함
|
| 100 |
+
MODEL_LOAD_ERROR = f"{type(exc).__name__}: {exc}"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def merge_wordpieces(tokens, scores):
|
| 104 |
+
"""WordPiece 토큰을 사람이 읽기 쉬운 단위로 묶어 상위 항목을 반환합니다."""
|
| 105 |
+
merged = []
|
| 106 |
+
for token, score in zip(tokens, scores):
|
| 107 |
+
if token in {"[CLS]", "[SEP]", "[PAD]"}:
|
| 108 |
+
continue
|
| 109 |
+
if token.startswith("##") and merged:
|
| 110 |
+
prev_token, prev_score = merged[-1]
|
| 111 |
+
merged[-1] = (prev_token + token[2:], max(prev_score, float(score)))
|
| 112 |
+
else:
|
| 113 |
+
merged.append((token, float(score)))
|
| 114 |
+
# 같은 표면형이 반복되면 가장 높은 점수만 유지
|
| 115 |
+
best = {}
|
| 116 |
+
for token, score in merged:
|
| 117 |
+
token = token.strip()
|
| 118 |
+
if not token or token in {"[UNK]"}:
|
| 119 |
+
continue
|
| 120 |
+
best[token] = max(score, best.get(token, -1.0))
|
| 121 |
+
return sorted(best.items(), key=lambda x: x[1], reverse=True)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def extract_attention_guide(encoded, attentions, top_k=6):
|
| 125 |
+
"""마지막 층의 [CLS]→토큰 평균 attention을 간단한 참고 신호로 사용합니다.
|
| 126 |
+
|
| 127 |
+
Attention은 인과적 설명이 아니므로 UI에도 그 한계를 명시합니다.
|
| 128 |
+
"""
|
| 129 |
+
if not attentions:
|
| 130 |
+
return []
|
| 131 |
+
last = attentions[-1][0] # [heads, seq, seq]
|
| 132 |
+
cls_to_tokens = last[:, 0, :].mean(dim=0).detach().cpu().tolist()
|
| 133 |
+
ids = encoded["input_ids"][0].detach().cpu().tolist()
|
| 134 |
+
tokens = TOKENIZER.convert_ids_to_tokens(ids)
|
| 135 |
+
merged = merge_wordpieces(tokens, cls_to_tokens)
|
| 136 |
+
return merged[:top_k]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def result_html(pred_label: int, risk_prob: float, confidence: float):
|
| 140 |
+
is_risk = pred_label == 1
|
| 141 |
+
dot_class = "dot-risk" if is_risk else "dot-safe"
|
| 142 |
+
title = "가짜뉴스 위험" if is_risk else "정상 가능성 높음"
|
| 143 |
+
return f"""
|
| 144 |
+
<div class='result-card'>
|
| 145 |
+
<div class='result-head'>판정결과</div>
|
| 146 |
+
<div class='result-main'><span class='dot {dot_class}'></span>{html.escape(title)} · {confidence:.3f}</div>
|
| 147 |
+
<div class='score-line'><b>위험도 점수</b> {risk_prob * 100:.2f}%</div>
|
| 148 |
+
<div class='small-note'>0=정상, 1=위험 라벨 기준의 모델 확률입니다. 확률값 자체가 사실의 증명은 아닙니다.</div>
|
| 149 |
+
</div>
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def xai_html(guide):
|
| 154 |
+
if not guide:
|
| 155 |
+
chips = "<span class='token-chip'>추출된 토큰 없음</span>"
|
| 156 |
+
else:
|
| 157 |
+
max_score = max(score for _, score in guide) or 1.0
|
| 158 |
+
chunks = []
|
| 159 |
+
for token, score in guide:
|
| 160 |
+
relative = score / max_score
|
| 161 |
+
chunks.append(
|
| 162 |
+
f"<span class='token-chip'><strong>{html.escape(token)}</strong> · attention {relative:.2f}</span>"
|
| 163 |
+
)
|
| 164 |
+
chips = "".join(chunks)
|
| 165 |
+
return f"""
|
| 166 |
+
<div class='xai-box'>
|
| 167 |
+
<div class='xai-title'>Highlight Word · Attention Analysis</div>
|
| 168 |
+
<div class='token-wrap'>{chips}</div>
|
| 169 |
+
<div class='small-note'>위 토큰은 마지막 attention 층에서 상대적으로 크게 주목된 항목입니다. 인과적 근거나 사실검증 근거로 해석하면 안 됩니다.</div>
|
| 170 |
+
</div>
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def analyze(text):
|
| 175 |
+
cleaned = preprocess_text(text)
|
| 176 |
+
if not cleaned:
|
| 177 |
+
return (
|
| 178 |
+
"<div class='error-card'>분석할 뉴스 문장을 입력하세요.</div>",
|
| 179 |
+
xai_html([]),
|
| 180 |
+
"입력 없음",
|
| 181 |
+
)
|
| 182 |
+
if MODEL_LOAD_ERROR:
|
| 183 |
+
return (
|
| 184 |
+
"<div class='error-card'><b>모델 로드 실패</b><br>Base model로 임의 판정하지 않습니다.<br>"
|
| 185 |
+
+ html.escape(MODEL_LOAD_ERROR)
|
| 186 |
+
+ "</div>",
|
| 187 |
+
xai_html([]),
|
| 188 |
+
"모델 로드 오류 — README의 런타임 점검 항목을 확인하세요.",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
encoded = TOKENIZER(
|
| 192 |
+
cleaned,
|
| 193 |
+
return_tensors="pt",
|
| 194 |
+
truncation=True,
|
| 195 |
+
max_length=MAX_LENGTH,
|
| 196 |
+
)
|
| 197 |
+
encoded = {k: v.to(DEVICE) for k, v in encoded.items()}
|
| 198 |
+
|
| 199 |
+
with torch.inference_mode():
|
| 200 |
+
outputs = MODEL(
|
| 201 |
+
**encoded,
|
| 202 |
+
output_attentions=True,
|
| 203 |
+
return_dict=True,
|
| 204 |
+
)
|
| 205 |
+
probs = torch.softmax(outputs.logits, dim=-1)[0].detach().cpu()
|
| 206 |
+
|
| 207 |
+
pred = int(torch.argmax(probs).item())
|
| 208 |
+
confidence = float(probs[pred].item())
|
| 209 |
+
risk_prob = float(probs[1].item())
|
| 210 |
+
guide = extract_attention_guide(encoded, outputs.attentions, top_k=6)
|
| 211 |
+
|
| 212 |
+
status = (
|
| 213 |
+
f"전처리 → 토크나이징 → KoELECTRA + LoRA 추론 → attention 기반 XAI 참고 → 결과 생성 완료. "
|
| 214 |
+
f"판정: {LABELS[pred]}"
|
| 215 |
+
)
|
| 216 |
+
return result_html(pred, risk_prob, confidence), xai_html(guide), status
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def appeal_message():
|
| 220 |
+
if CONTACT_EMAIL:
|
| 221 |
+
return f"이의 제기/오판 신고 연락처: {CONTACT_EMAIL}"
|
| 222 |
+
return (
|
| 223 |
+
"Model Card에는 개발자 이메일로 이의를 제기한다고 되어 있지만 실제 이메일 주소는 기재되어 있지 않습니다. "
|
| 224 |
+
"배포 전 Hugging Face Space의 CONTACT_EMAIL 변수에 개발자 이메일을 등록하세요."
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
with gr.Blocks(css=CUSTOM_CSS, title="AI 신약개발 뉴스 판별") as demo:
|
| 229 |
+
gr.Markdown(
|
| 230 |
+
"# AI 신약개발 뉴스 판별 모델\n"
|
| 231 |
+
"AI 신약개발 관련 뉴스 문장을 입력하면 LoRA 분류 모델이 **정상/위험** 가능성을 표시하고, "
|
| 232 |
+
"attention 기반 참고 토큰을 함께 보여줍니다.",
|
| 233 |
+
elem_id="hero",
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
gr.HTML(f"<div class='warning-box'><b>⚠ Model Card 한계 고지</b><br>{html.escape(LIMITATION_TEXT)}</div>")
|
| 237 |
+
|
| 238 |
+
with gr.Row():
|
| 239 |
+
with gr.Column(scale=3):
|
| 240 |
+
news_input = gr.Textbox(
|
| 241 |
+
label="뉴스 텍스트",
|
| 242 |
+
placeholder="AI 신약개발 관련 뉴스 문장 또는 기사 일부를 입력하세요.",
|
| 243 |
+
lines=10,
|
| 244 |
+
)
|
| 245 |
+
analyze_btn = gr.Button("분석하기", variant="primary")
|
| 246 |
+
gr.Examples(
|
| 247 |
+
examples=[
|
| 248 |
+
["연구진은 AI를 활용해 신약 후보 물질을 선별했으며, 실제 임상 효과는 추가 검증이 필요하다고 밝혔다."],
|
| 249 |
+
["AI가 분석한 결과 이 치료제는 모든 암을 100% 치료하며 이미 세계 최고 기관이 효과를 보증했다."],
|
| 250 |
+
],
|
| 251 |
+
inputs=news_input,
|
| 252 |
+
)
|
| 253 |
+
with gr.Column(scale=2):
|
| 254 |
+
result = gr.HTML("<div class='result-card'><div class='result-head'>판정결과</div><div>분석 전</div></div>")
|
| 255 |
+
status = gr.Textbox(label="처리 단계", value="대기 중", interactive=False)
|
| 256 |
+
|
| 257 |
+
xai = gr.HTML(xai_html([]))
|
| 258 |
+
|
| 259 |
+
gr.HTML(f"<div class='disclaimer'><b>최종 결과 및 한계 고지</b><br>{html.escape(LIMITATION_TEXT)}</div>")
|
| 260 |
+
with gr.Row():
|
| 261 |
+
appeal_btn = gr.Button("이의 제기 안내")
|
| 262 |
+
appeal_output = gr.Textbox(label="이의 제기", interactive=False)
|
| 263 |
+
|
| 264 |
+
with gr.Accordion("Model Card 요약", open=False):
|
| 265 |
+
gr.Markdown(
|
| 266 |
+
"- **모델명:** AI 신약 개발 분석 모델\n"
|
| 267 |
+
"- **목적:** AI 신약개발 관련 뉴스가 진짜뉴스인지 가짜뉴스인지 구분하기 위한 보조 판별\n"
|
| 268 |
+
"- **학습 데이터:** 동아사이언스 DB, 2024~2026년, 200건(정상 100 + 위험 100)\n"
|
| 269 |
+
"- **Model Card 기재 성능:** Accuracy 100, F1 1.0, TP 20, TN 20, FP 0, FN 0\n"
|
| 270 |
+
"- **가장 약한 유형:** 우회 공격\n"
|
| 271 |
+
"- **금지된 사용:** 가짜뉴스 생성을 고도화하거나 모델을 우회하기 위한 용도"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
analyze_btn.click(analyze, inputs=news_input, outputs=[result, xai, status])
|
| 275 |
+
news_input.submit(analyze, inputs=news_input, outputs=[result, xai, status])
|
| 276 |
+
appeal_btn.click(appeal_message, outputs=appeal_output)
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
demo.queue().launch()
|
tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backend": "tokenizers",
|
| 3 |
+
"cls_token": "[CLS]",
|
| 4 |
+
"do_lower_case": false,
|
| 5 |
+
"is_local": false,
|
| 6 |
+
"local_files_only": false,
|
| 7 |
+
"mask_token": "[MASK]",
|
| 8 |
+
"model_max_length": 512,
|
| 9 |
+
"pad_token": "[PAD]",
|
| 10 |
+
"sep_token": "[SEP]",
|
| 11 |
+
"strip_accents": null,
|
| 12 |
+
"tokenize_chinese_chars": true,
|
| 13 |
+
"tokenizer_class": "BertTokenizer",
|
| 14 |
+
"unk_token": "[UNK]"
|
| 15 |
+
}
|