Instructions to use GOSHUNCLE/pii-masking-zh-tw with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use GOSHUNCLE/pii-masking-zh-tw with PEFT:
from peft import PeftModel from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained("C:\Users\chris.ma_cycraft\.cache\huggingface\hub\models--Qwen--Qwen2.5-1.5B-Instruct\snapshots\989aa7980e4cf806f80c7fef2b1adb7bc71aa306") model = PeftModel.from_pretrained(base_model, "GOSHUNCLE/pii-masking-zh-tw") - Notebooks
- Google Colab
- Kaggle
| #!/usr/bin/env python3 | |
| """ | |
| PII Masking ZH-TW — inference module | |
| Detects and masks 8 types of Taiwan-specific personal information (PII) | |
| using a fine-tuned Qwen2.5-1.5B-Instruct + LoRA adapter, returning structured | |
| JSON output. | |
| Supported types: NAME, ROC_ID, PHONE, EMAIL, ADDRESS, NHI_ID, BANK_ACCOUNT, | |
| CREDIT_CARD. | |
| Quick start: | |
| from inference import PIIDetector | |
| detector = PIIDetector() | |
| result = detector.detect_and_replace( | |
| "客戶王小明來電諮詢,身分證A123456789,手機0912345678" | |
| ) | |
| print(result["masked_text"]) | |
| Environment variables (optional, for local development): | |
| PII_BASE_MODEL Override base model id or path | |
| PII_ADAPTER_REPO Override adapter repo id or local path | |
| """ | |
| import json | |
| import os | |
| import torch | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| from peft import PeftModel | |
| # ============================================================ | |
| # Configuration | |
| # ============================================================ | |
| BASE_MODEL = os.environ.get("PII_BASE_MODEL", "Qwen/Qwen2.5-1.5B-Instruct") | |
| ADAPTER_REPO = os.environ.get("PII_ADAPTER_REPO", "GOSHUNCLE/pii-masking-zh-tw") | |
| SYSTEM_PROMPT = """你是一個台灣個人資料(PII)偵測專家。分析輸入文本,找出所有個人可識別資訊(PII),以嚴格 JSON 格式回傳。 | |
| 偵測類型與遮罩規則: | |
| - ROC_ID:身分證字號(格式:1英文字母+9數字)→ 保留首2碼與末2碼,其餘以*替代 | |
| - NAME:姓名(依語意判斷,或依特徵:2-4個中文字且首字為常見姓氏)→ 保留姓氏,名字以O替代 | |
| - PHONE:手機號碼(09開頭,10碼)→ 保留前4碼,第5碼起全部以*替代 | |
| - EMAIL:電子郵件 → 保留首2字元與@後域名,中間以*替代 | |
| - ADDRESS:完整地址(須包含「號」或「樓」才視為完整地址)→ 保留至「區」,區之後以*替代 | |
| - NHI_ID:健保卡號(12碼數字)→ 保留前4碼,其餘以*替代 | |
| - BANK_ACCOUNT:銀行帳號 → 保留前4碼,其餘以*替代 | |
| - CREDIT_CARD:信用卡號(16碼)→ 保留前4碼,其餘以*替代 | |
| 輸出格式: | |
| {"pii_found": true/false, "entities": [{"type": "類型", "value": "原始值", "masked": "遮罩值"}]} | |
| 規則: | |
| 1. 無PII時回傳 {"pii_found": false, "entities": []} | |
| 2. 同一文本中有多個PII須全部列出 | |
| 3. 只回傳JSON,不加任何說明文字""" | |
| # ============================================================ | |
| # JSON parsing (minimal defensive) | |
| # ============================================================ | |
| def _extract_json(text): | |
| text = text.strip() | |
| start = text.find("{") | |
| end = text.rfind("}") + 1 | |
| if start < 0 or end <= start: | |
| return None | |
| try: | |
| return json.loads(text[start:end]) | |
| except json.JSONDecodeError: | |
| return None | |
| # ============================================================ | |
| # Digit-character detection (halfwidth / fullwidth / Chinese numerals) | |
| # ============================================================ | |
| _CHINESE_DIGITS = "零一二三四五六七八九" | |
| def _is_digit_char(c): | |
| return c.isdigit() or c in _CHINESE_DIGITS | |
| # ============================================================ | |
| # Filter 1: structural validation | |
| # ------------------------------------------------------------ | |
| # Verify the entity's value matches its claimed type's structural pattern. | |
| # Filters out misclassifications produced by the model. | |
| # ============================================================ | |
| def _is_valid_entity(entity): | |
| etype = entity.get("type", "") | |
| value = entity.get("value", "") | |
| if not value: | |
| return False | |
| if etype == "NAME": | |
| return 2 <= len(value) <= 4 | |
| if etype == "EMAIL": | |
| if "@" not in value: | |
| return False | |
| _, _, domain = value.partition("@") | |
| return "." in domain | |
| if etype == "ADDRESS": | |
| # System prompt requires "號" or "樓" for valid full address | |
| return "號" in value or "樓" in value | |
| raw = value.replace("-", "").replace(" ", "") | |
| if etype == "ROC_ID": | |
| if len(raw) != 10: | |
| return False | |
| return raw[0].isalpha() and all(_is_digit_char(c) for c in raw[1:]) | |
| if etype == "PHONE": | |
| return len(raw) == 10 and all(_is_digit_char(c) for c in raw) | |
| if etype == "NHI_ID": | |
| return len(raw) == 12 and all(_is_digit_char(c) for c in raw) | |
| if etype == "BANK_ACCOUNT": | |
| return 10 <= len(raw) <= 16 and all(_is_digit_char(c) for c in raw) | |
| if etype == "CREDIT_CARD": | |
| return len(raw) == 16 and all(_is_digit_char(c) for c in raw) | |
| return True | |
| # ============================================================ | |
| # Filter 2: provenance check (guards against hallucination) | |
| # ------------------------------------------------------------ | |
| # The value must verbatim appear in the input text. Tolerant of | |
| # whitespace/separator differences. | |
| # ============================================================ | |
| def _value_in_text(value, text): | |
| if not value: | |
| return False | |
| if value in text: | |
| return True | |
| norm = lambda s: s.replace(" ", "").replace("-", "").replace(" ", "") | |
| return norm(value) in norm(text) | |
| # ============================================================ | |
| # Mask validation and correction | |
| # ============================================================ | |
| def _validate_and_fix_mask(entity): | |
| """Recompute the masked field from the value field using deterministic | |
| rules. Compensates for the model's occasional miscount in star quantity.""" | |
| etype = entity.get("type", "") | |
| value = entity.get("value", "") | |
| masked = entity.get("masked", "") | |
| correct_mask = None | |
| if etype == "ROC_ID" and len(value) == 10: | |
| correct_mask = value[:2] + "*" * 6 + value[-2:] | |
| elif etype == "NAME" and len(value) >= 2: | |
| correct_mask = value[0] + "O" * (len(value) - 1) | |
| elif etype == "PHONE": | |
| raw = value.replace("-", "").replace(" ", "") | |
| if len(raw) == 10: | |
| correct_mask = raw[:4] + "*" * 6 | |
| elif etype == "EMAIL" and "@" in value: | |
| local, domain = value.split("@", 1) | |
| if len(local) >= 2: | |
| correct_mask = local[:2] + "*" * (len(local) - 2) + "@" + domain | |
| elif etype == "ADDRESS": | |
| # Find the deepest admin division as the cut point. | |
| # Priority: 區 / 鎮 / 鄉 first, fallback to 市 / 縣. | |
| prefix = None | |
| best_idx = -1 | |
| for keyword in ["區", "鎮", "鄉"]: | |
| idx = value.find(keyword) | |
| if idx >= 0 and idx > best_idx: | |
| best_idx = idx | |
| prefix = value[:idx + 1] | |
| if prefix is None: | |
| for keyword in ["市", "縣"]: | |
| idx = value.find(keyword) | |
| if idx >= 0 and idx > best_idx: | |
| best_idx = idx | |
| prefix = value[:idx + 1] | |
| # Guard: prefix must be strictly shorter than value | |
| # (avoids cases like "社區" where 區 is at the end) | |
| if prefix and len(prefix) < len(value): | |
| correct_mask = prefix + "*" * (len(value) - len(prefix)) | |
| elif etype in ("NHI_ID", "BANK_ACCOUNT", "CREDIT_CARD"): | |
| if len(value) >= 4: | |
| correct_mask = value[:4] + "*" * (len(value) - 4) | |
| if correct_mask and correct_mask != masked: | |
| entity["masked"] = correct_mask | |
| entity["mask_corrected"] = True | |
| return entity | |
| # ============================================================ | |
| # PIIDetector | |
| # ============================================================ | |
| class PIIDetector: | |
| def __init__(self, base_model=BASE_MODEL, adapter_repo=ADAPTER_REPO): | |
| print("Loading model...") | |
| self.tokenizer = AutoTokenizer.from_pretrained( | |
| base_model, | |
| trust_remote_code=True, | |
| padding_side="left", | |
| ) | |
| if self.tokenizer.pad_token is None: | |
| self.tokenizer.pad_token = self.tokenizer.eos_token | |
| base = AutoModelForCausalLM.from_pretrained( | |
| base_model, | |
| dtype=torch.float32, | |
| device_map="cpu", | |
| trust_remote_code=True, | |
| ) | |
| self.model = PeftModel.from_pretrained(base, adapter_repo) | |
| self.model.eval() | |
| print("Model loaded.") | |
| def detect(self, text, fix_masks=True): | |
| """Detect PII in `text`. | |
| Pipeline: | |
| 1. Model inference | |
| 2. JSON parse | |
| 3. Filter 1: structural validation | |
| 4. Filter 2: provenance check (rejects hallucinations) | |
| 5. validate_and_fix_mask: deterministic mask recomputation | |
| 6. Recompute pii_found (False if all entities filtered out) | |
| Returns: | |
| dict: {"pii_found": bool, "entities": list, "raw_response": str, | |
| "parse_error": bool} | |
| """ | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": text}, | |
| ] | |
| prompt = self.tokenizer.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True, | |
| ) | |
| inputs = self.tokenizer(prompt, return_tensors="pt") | |
| with torch.no_grad(): | |
| outputs = self.model.generate( | |
| **inputs, | |
| max_new_tokens=512, | |
| do_sample=False, | |
| temperature=1.0, | |
| pad_token_id=self.tokenizer.pad_token_id, | |
| ) | |
| new_tokens = outputs[0][inputs["input_ids"].shape[1]:] | |
| raw_response = self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip() | |
| parsed = _extract_json(raw_response) | |
| if parsed is None: | |
| return { | |
| "pii_found": False, | |
| "entities": [], | |
| "raw_response": raw_response, | |
| "parse_error": True, | |
| } | |
| # ===== Two-stage filtering ===== | |
| entities = parsed.get("entities", []) | |
| if entities: | |
| # Filter 1: structural validation | |
| entities = [e for e in entities if _is_valid_entity(e)] | |
| # Filter 2: provenance check (rejects hallucinated/translated values) | |
| entities = [e for e in entities if _value_in_text(e.get("value", ""), text)] | |
| # Mask correction (only for entities passing both filters) | |
| if fix_masks: | |
| entities = [_validate_and_fix_mask(e) for e in entities] | |
| parsed["entities"] = entities | |
| parsed["pii_found"] = bool(entities) | |
| parsed["raw_response"] = raw_response | |
| parsed["parse_error"] = False | |
| return parsed | |
| def detect_and_replace(self, text): | |
| """Detect PII and return the input text with all detected PII replaced | |
| by their masked forms.""" | |
| result = self.detect(text) | |
| masked_text = text | |
| if result.get("entities"): | |
| sorted_ents = sorted( | |
| result["entities"], | |
| key=lambda e: len(e.get("value", "")), | |
| reverse=True, | |
| ) | |
| for e in sorted_ents: | |
| value = e.get("value", "") | |
| masked = e.get("masked", "") | |
| if value and masked and value in masked_text: | |
| # Replace all occurrences | |
| masked_text = masked_text.replace(value, masked) | |
| return { | |
| "original": text, | |
| "masked_text": masked_text, | |
| "entities": result.get("entities", []), | |
| "pii_found": result.get("pii_found", False), | |
| } | |
| # ============================================================ | |
| # CLI | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| detector = PIIDetector() | |
| print("\n" + "=" * 60) | |
| print("PII Masking ZH-TW — interactive mode") | |
| print("Type text and press Enter to detect; type 'q' to exit.") | |
| print("=" * 60) | |
| while True: | |
| print() | |
| try: | |
| user_input = input("> ").strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print("\nBye.") | |
| break | |
| if user_input.lower() in ("q", "quit", "exit"): | |
| print("Bye.") | |
| break | |
| if not user_input: | |
| continue | |
| result = detector.detect_and_replace(user_input) | |
| print(f"\n original: {result['original']}") | |
| print(f" masked : {result['masked_text']}") | |
| if result["entities"]: | |
| print(f" found {len(result['entities'])} PII:") | |
| for e in result["entities"]: | |
| corrected = " (corrected)" if e.get("mask_corrected") else "" | |
| print(f" [{e['type']}] {e['value']} -> {e['masked']}{corrected}") | |
| else: | |
| print(" no PII detected") | |