Qoamo-PII-Decision
A routing model that catches personal data in Korean and English requests before they leave the device. Built on skt/A.X-Encoder-base (ModernBERT, 149M parameters), it was fine-tuned as a decision model that answers questions about a text with a probability for every option.
It can be asked four things about a text (whether it contains personal data, whether it contains a strong identifier, its tier, and which of 33 types it holds), and the answers decide whether the text is sent as is, masked and then sent, or blocked. The decision is made from the first three questions; the types are asked only when needed. Locating the values is not its job. For a text it marks as containing personal data, atonlee/koelectra-ko-pii-ner, built on the same tier1 and tier2 criteria, finds and masks the values. This keeps the fast filtering decision separate from precise masking by NER.
On the official KDPII test split, it scored an F1 of 0.951 at telling whether a sentence contains personal data, the highest among the compared models. The weights are a single safetensors file (bf16). If you need ONNX, use one of the four in onnx/ (float32 ยท FP16 ยท INT8 ยท UINT8).
News
๐ฅ 2026.10 Update
- English: it now judges English text as well as Korean.
- Types 23 โ 33: passwords, API keys or tokens, and verification codes or OTPs are asked about separately. Phone numbers are split into regular, business or toll-free, and short public numbers, and personal numbers into employee, member or customer, student or exam, and patient numbers, among others.
- Decision and tier together:
decidereturns the tier (tier1 ยท tier2 ยท none) along with the decision (๋ณด๋ ยท ํ์ธ ยท ๋ง์). Withwith_types=Trueit returns the types as well. - Asking with different criteria: ask about several types as one (
has), leave some types out (has_pii_except), or regroup the tiers into up to 4 levels (tier). - Long texts: with
document=True, a text of up to 2,000 characters is judged in one go. A longer one is split on line breaks and the strictest decision is returned. - Ready-to-use code: load it with
AutoModel.from_pretrained(..., trust_remote_code=True)and use it in one line withmodel.decide(text). - Accuracy: KDPII F1 0.896 โ 0.951 (10 types 0.918 โ 0.955). Every sentence holding a tier1 value was caught.
Questions
The model can be asked four things about a text and gives a probability for every option.
pii_anyโ contains personal data? (์ yes ยท ์๋์ค no)pii_t1โ contains a strong identifier? (์ ยท ์๋์ค)pii_tierโ tier (tier1 ยท tier2 ยท none): picks the single highest tier in the text.type_*โ 33 types (์ ยท ์๋์ค for each type)
decide asks only the first three; with with_types=True it also asks the 33 types. The questions never see each other, so the answers are the same however many are asked together, and asking fewer is faster.
Tiers and types
| tier | meaning | recommended handling | types |
|---|---|---|---|
| tier1 | the value alone identifies a person or can cause harm | do not send off-device | resident registration number (์ฃผ๋ฏผ๋ฑ๋ก๋ฒํธ), alien registration number (์ธ๊ตญ์ธ๋ฑ๋ก๋ฒํธ), passport number (์ฌ๊ถ๋ฒํธ), driver's licence number (์ด์ ๋ฉดํ๋ฒํธ), i-PIN (์์ดํ), domestic ID number (๊ตญ๋ด ์ ๋ถ๋ฒํธ), foreign ID number (์ธ๊ตญ ์ ๋ถ๋ฒํธ), card number (์นด๋๋ฒํธ), card expiry date (์นด๋ ์ ํจ๊ธฐ๊ฐ), CVC, account number (๊ณ์ข๋ฒํธ), password (๋น๋ฐ๋ฒํธ), API key or token (API ํคยทํ ํฐ), verification code or OTP (์ธ์ฆ๋ฒํธยทOTP) |
| tier2 | identifies a person when combined with other information | find and mask it with NER, then send | name (์ด๋ฆ), email (์ด๋ฉ์ผ), regular phone number (์ผ๋ฐ ์ ํ๋ฒํธ), business or toll-free number (๋ํ๋ฒํธ), short public number (ํน์๋ฒํธ), address (์ฃผ์), postal code (์ฐํธ๋ฒํธ), date of birth (์๋ ์์ผ), age (๋์ด), gender (์ฑ๋ณ), employee number (์ฌ๋ฒ), member or customer number (ํ์ยท๊ณ ๊ฐ๋ฒํธ), student or exam number (ํ์ยท์์๋ฒํธ), patient number (ํ์๋ฒํธ), other personal number (๊ทธ ๋ฐ์ ์ฌ๋ ๋ฒํธ), user ID (์ฌ์ฉ์ ID), URL, IP address (IP ์ฃผ์), vehicle number (์ฐจ๋๋ฒํธ) |
| none | no personal data | send as is | โ |
In code, use the Korean names in parentheses (CVC and URL as written); they are the names in model.types.
Four answers per text
| text | contains personal data? (yes) | strong identifier (yes) | tier | types (yes โฅ 0.5) | decision |
|---|---|---|---|---|---|
| ๋ฐ์งํ ๋๋ฆฌํํ 010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค | 0.999 | 0.000 | tier2 (0.999) | name 0.995 ยท regular phone number 0.994 | ํ์ธ |
| ๋ด ์ฃผ๋ฏผ๋ฒํธ 901023-1234567 ๋ก ๋ฑ๋ก | 0.999 | 0.999 | tier1 (0.998) | resident registration number 0.950 | ๋ง์ |
| ์ด๋ฒ ์ฃผ๋ง์ ๋น ์ค๋์ง ์๋ ค ์ค | 0.002 | 0.000 | none (0.997) | none | ๋ณด๋ |
| Send the signed contract to david.kim@acme.io | 0.996 | 0.000 | tier2 (0.999) | email 0.997 | ํ์ธ |
Asking with different criteria
The tiers above are the default, and the ๋ณด๋ ยท ํ์ธ ยท ๋ง์ decision was tuned with them. The best results come from using the default tiers as they are. If your policy differs, there are three ways to change the criteria.
- Grouping (
has): ask in one go whether any of several types, such as phone number, email and address, is present. On groups it had not seen in training, it was right 99% of the time. To treat some types more strictly, ask with this and raise the tier in your code. - Excluding (
has_pii_except, or leaving types out intier): leave some types out, so that, for example, a text with only a name does not count as personal data. On texts holding the left-out types it was right 97% of the time or more. - Changing the tiers (
tier): move types between tiers. Use up to 4 levels, named tier1, tier2, tier3, tier4 and none. It is less accurate on texts that hold the moved types. In particular, a type moved up to tier1 tends to stay in its original tier when another tier2 value is present, so use grouping to raise a type.
The fourth and fifth examples under Usage show how. The question wording (the instructions in pii_config.json) can be changed too, but this is not recommended.
Usage
Load it with trust_remote_code=True. Only the files it needs are downloaded, not onnx/. decide gives the decision and the tier from three questions.
pip install "transformers>=5.1" torch
from transformers import AutoModel
model = AutoModel.from_pretrained("atonlee/Qoamo-PII-Decision", trust_remote_code=True)
for text in ["๋ด์ผ ์คํ ์ธ ์์ ํ ํ์ ์ก์ ์ค",
"๋ฐ์งํ ๋๋ฆฌํํ
010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค",
"๋ด ์ฃผ๋ฏผ๋ฒํธ 901023-1234567 ๋ก ๋ฑ๋ก",
"Send the signed contract to david.kim@acme.io"]:
r = model.decide(text)
print(f"{r.route} | {r.tier} | {text}")
Output:
๋ณด๋ | none | ๋ด์ผ ์คํ ์ธ ์์ ํ ํ์ ์ก์ ์ค
ํ์ธ | tier2 | ๋ฐ์งํ ๋๋ฆฌํํ
010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค
๋ง์ | tier1 | ๋ด ์ฃผ๋ฏผ๋ฒํธ 901023-1234567 ๋ก ๋ฑ๋ก
ํ์ธ | tier2 | Send the signed contract to david.kim@acme.io
To see the types as well, pass with_types=True. It asks 36 questions and is several times slower.
r = model.decide("๋ฐ์งํ ๋๋ฆฌํํ
010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค", with_types=True)
print(r.route, r.tier, r.types)
Output:
ํ์ธ tier2 ['์ด๋ฆ', '์ผ๋ฐ ์ ํ๋ฒํธ']
Judge a long text with document=True. A single corporate card number in the middle of the email is enough to block the whole email.
mail = """ํ์ฅ๋, ์ง๋์ฃผ ๋ถ์ฐ ์ถ์ฅ ์ ์ฐ ๊ฑด์ผ๋ก ๋ฉ์ผ ๋๋ฆฝ๋๋ค.
์ผ์ ์ 10์ 6์ผ๋ถํฐ 8์ผ๊น์ง 2๋ฐ 3์ผ์ด์๊ณ , ํ๋ ฅ์ฌ ๋ ๊ณณ๊ณผ ๋ฏธํ
์ ์งํํ์ต๋๋ค.
๊ตํต๋น๋ KTX ์๋ณต 119,600์, ์๋ฐ๋น๋ 2๋ฐ 236,000์, ์๋๋ 84,000์์
๋๋ค.
์์์ฆ ์๋ณธ์ ๋ด์ผ ์ค์ ์ค์ ๊ฒฝ์์ง์ํ ์ ์ฐํจ์ ๋ฃ์ด ๋๊ฒ ์ต๋๋ค.
์๋ฐ๋น๋ ๋ฒ์ธ์นด๋ 4518-3207-6614-2093 ์ผ๋ก ๊ฒฐ์ ํ๊ณ , ๋๋จธ์ง๋ ๊ฐ์ธ ์นด๋๋ก ๋์ต๋๋ค.
๊ฐ์ธ ๊ฒฐ์ ๋ถ์ ์ด๋ฒ ๋ฌ ๊ธ์ฌ์ ํจ๊ป ๋ฃ์ด ์ฃผ์๋ฉด ๋ฉ๋๋ค.
๋ฏธํ
๊ฒฐ๊ณผ๋ ๋ฐ๋ก ์ ๋ฆฌํด์ ๊ธ์์ผ ํ์ ๋ ๊ณต์ ๋๋ฆฌ๊ฒ ์ต๋๋ค.
๊ฐ์ฌํฉ๋๋ค."""
r = model.decide(mail, document=True, with_types=True)
print(r.route, r.tier, r.types)
Output:
๋ง์ tier1 ['์นด๋๋ฒํธ']
To treat contact details more strictly, ask whether there are any and raise the tier if so. Only the second, third and fourth texts, which hold contact details, move up to tier1; the text with only a name and the text with an account number stay as they were.
texts = ["๊น๋ฏผ์ ํ์ฅ๋๊ป ์ค๋ ํ์๋ก ๋ณด๋ด ๋๋ ค",
"๋ฐ์งํ ๋๋ฆฌํํ
010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค",
"๊ฒฌ์ ์ jiyoung.park@gmail.com ์ผ๋ก ๋ณด๋ด ์ค",
"์์ธ ๊ฐ๋จ๊ตฌ ํ
ํค๋๋ก 152 12์ธต์ผ๋ก ์๋ฅ ๋ณด๋์ด",
"์ ํ 110-482-339201 ๋ก ์์ธ 55๋ง ์ ๋ณด๋ด ์ค",
"๋ด์ผ ์คํ ์ธ ์์ ํ ํ์ ์ก์ ์ค"]
contact_types = ["์ผ๋ฐ ์ ํ๋ฒํธ", "์ด๋ฉ์ผ", "์ฃผ์"]
for text in texts:
before = model.decide(text).tier
after = "tier1" if model.has(text, contact_types, name="์ฐ๋ฝ์ฒ") else before
print(f"{before} โ {after} | {text}")
Output:
tier2 โ tier2 | ๊น๋ฏผ์ ํ์ฅ๋๊ป ์ค๋ ํ์๋ก ๋ณด๋ด ๋๋ ค
tier2 โ tier1 | ๋ฐ์งํ ๋๋ฆฌํํ
010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค
tier2 โ tier1 | ๊ฒฌ์ ์ jiyoung.park@gmail.com ์ผ๋ก ๋ณด๋ด ์ค
tier2 โ tier1 | ์์ธ ๊ฐ๋จ๊ตฌ ํ
ํค๋๋ก 152 12์ธต์ผ๋ก ์๋ฅ ๋ณด๋์ด
tier1 โ tier1 | ์ ํ 110-482-339201 ๋ก ์์ธ 55๋ง ์ ๋ณด๋ด ์ค
none โ none | ๋ด์ผ ์คํ ์ธ ์์ ํ ํ์ ์ก์ ์ค
To stop counting names as personal data, ask with the default tiers minus names. The first text, which holds only a name, becomes none, and the second, with a name and a phone number, stays tier2 because of the phone number.
custom_tiers = {"tier1": model.tiers["tier1"],
"tier2": [t for t in model.tiers["tier2"] if t != "์ด๋ฆ"]}
for text in texts:
print(f"{model.decide(text).tier} โ {model.tier(text, custom_tiers)} | {text}")
Output:
tier2 โ none | ๊น๋ฏผ์ ํ์ฅ๋๊ป ์ค๋ ํ์๋ก ๋ณด๋ด ๋๋ ค
tier2 โ tier2 | ๋ฐ์งํ ๋๋ฆฌํํ
010-4821-3307 ๋ก ๋ฌธ์ ๋ณด๋ด ์ค
tier2 โ tier2 | ๊ฒฌ์ ์ jiyoung.park@gmail.com ์ผ๋ก ๋ณด๋ด ์ค
tier2 โ tier2 | ์์ธ ๊ฐ๋จ๊ตฌ ํ
ํค๋๋ก 152 12์ธต์ผ๋ก ์๋ฅ ๋ณด๋์ด
tier1 โ tier1 | ์ ํ 110-482-339201 ๋ก ์์ธ 55๋ง ์ ๋ณด๋ด ์ค
none โ none | ๋ด์ผ ์คํ ์ธ ์์ ํ ํ์ ์ก์ ์ค
r.scoresholds the probability of every option for the questions asked.- When the decision is ํ์ธ, find and mask the personal data with NER, then send.
- With
document=True, a text over 2,000 characters is split on line breaks, each piece is judged, and the strictest decision and tier are returned (the pieces are inr.pieces). - You can ask only the questions you need, like
model.ask(text, ["pii_any", "pii_t1"]). Thedeciderule then does not apply. - To leave ONNX out when downloading the whole repository, use
snapshot_download(..., ignore_patterns=["onnx/*"]).
Evaluation
We measured whether a sentence contains personal data on the official test split of KDPII (CC BY 4.0). Sentences were selected based on our types and the 10 common types (3,998 sentences for our types, 3,737 for the 10 types). KDPII was not used to train this model. Widely used PII detection, decision and guard models on Hugging Face were run on the same sentences and scored the same way.
| model | size | precision | recall | F1 (our types) | F1 (10 types) |
|---|---|---|---|---|---|
| Qoamo-PII-Decision | 149M | 0.954 | 0.947 | 0.951 | 0.955 |
| perplexity-ai/PII-Tracer | about 600M | 0.665 | 0.783 | 0.719 | 0.739 |
| LiquidAI/LFM2.5-Encoder-350M-PII-Detector | 350M | 0.940 | 0.503 | 0.655 | 0.738 |
| convaiinnovations/laya (Korean question) | 322M | 0.205 | 0.998 | 0.340 | 0.259 |
| Qwen/Qwen3Guard-Gen-0.6B | 0.6B | 0.556 | 0.231 | 0.326 | 0.357 |
| SupersonicLabs/Julia-1 (English question) | 144M | 0.178 | 0.523 | 0.266 | 0.205 |
| fastino/GLiNER2.5-multi-Decide (English question) | 287M | 0.279 | 0.217 | 0.244 | 0.214 |
| openai/privacy-filter (reference) | 1.4B | 0.813 | 0.612 | 0.698 | 0.775 |
| OpenMed/privacy-filter-multilingual (reference) | 1.4B | 0.530 | 0.869 | 0.658 | 0.602 |
- F1 (our types): name ยท phone ยท mobile ยท email ยท vehicle number ยท IP ยท user ID ยท date of birth ยท age ยท gender ยท resident registration number ยท alien registration number ยท passport number ยท driver's licence number ยท card number ยท account number
- F1 (10 types): name ยท phone ยท mobile ยท email ยท resident registration number ยท alien registration number ยท passport number ยท driver's licence number ยท card number ยท account number
Data
Training data includes the following.
- BCCard/privacy-filter-openpii-masking (CC BY 4.0)
- nvidia/Nemotron-PII (CC BY 4.0)
- clinc/clinc_oos (CC BY 3.0)
License
Apache-2.0