Spaces:
Running on Zero
Running on Zero
| """Inference and evaluation for the encoder model.""" | |
| import argparse | |
| import json | |
| import torch | |
| from pathlib import Path | |
| from transformers import AutoTokenizer | |
| from tqdm import tqdm | |
| import sys | |
| sys.path.insert(0, str(Path(__file__).parent.parent.parent)) | |
| from src.encoder_model import EmojinizeEncoderModel | |
| from src.encoder_dataset import load_encoder_datasets | |
| from src import emoji_vocab | |
| from src.validators import validate_annotation_json | |
| def _load_model(checkpoint_path: str, device): | |
| """Load model using saved model_config.json so architecture is reconstructed exactly.""" | |
| ckpt = Path(checkpoint_path) | |
| config_path = ckpt / "model_config.json" | |
| if config_path.exists(): | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| else: | |
| # Fallback for checkpoints saved before model_config.json was introduced | |
| cfg = {} | |
| model = EmojinizeEncoderModel(**cfg) | |
| model.load_state_dict(torch.load(ckpt / "model.pt", map_location=device, weights_only=True)) | |
| return model | |
| def _extract_word_level_spans(bio_labels, word_ids, offset_mapping, sentence): | |
| """Extract spans at word granularity, fixing both subword-splitting and trailing spaces. | |
| Strategy: | |
| - Group tokens by word_id (word_ids() from the tokenizer). | |
| - For each word, use only the FIRST subword's BIO prediction. | |
| Non-first subwords were trained with ignore_index=-100, so their | |
| predictions are meaningless and must be skipped. | |
| - Each word's char range = first_subword.char_start … last_subword.char_end, | |
| so spans are clean word-aligned boundaries with no trailing spaces. | |
| Returns: | |
| list of (tok_start, tok_end, char_start, char_end, span_text) | |
| where tok_start/end are token indices for span pooling. | |
| """ | |
| from collections import defaultdict | |
| word_tokens = defaultdict(list) | |
| for tok_idx, wid in enumerate(word_ids): | |
| if wid is not None: | |
| word_tokens[wid].append(tok_idx) | |
| # Build word-level entries: (bio_label, first_tok, one_past_last_tok, char_s, char_e) | |
| words = [] | |
| for wid in sorted(word_tokens.keys()): | |
| toks = word_tokens[wid] | |
| first_tok, last_tok = toks[0], toks[-1] | |
| label = bio_labels[first_tok] | |
| char_s = offset_mapping[first_tok][0] | |
| char_e = offset_mapping[last_tok][1] | |
| if char_s == -1 or char_e == -1: | |
| continue | |
| words.append((label, first_tok, last_tok + 1, char_s, char_e)) | |
| # BIO span extraction on word-level labels; fix illegal I-without-B → B | |
| spans = [] | |
| in_span = False | |
| s_tok = s_char = e_tok = e_char = None | |
| def _close(): | |
| if s_tok is not None and s_char < e_char: | |
| span_text = sentence[s_char:e_char] | |
| # Strip trailing sentence punctuation (commas, periods, etc.) that tokenizer attaches | |
| # This ensures spans like "design," become "design" in output | |
| end_punct = ".,!?;:" | |
| trimmed_end = len(span_text) | |
| while trimmed_end > 0 and span_text[trimmed_end - 1] in end_punct: | |
| trimmed_end -= 1 | |
| if trimmed_end > 0: | |
| # Adjust char boundaries to exclude trailing punctuation | |
| adjusted_e_char = s_char + trimmed_end | |
| spans.append((s_tok, e_tok, s_char, adjusted_e_char, span_text[:trimmed_end])) | |
| for label, tok_s, tok_e, char_s, char_e in words: | |
| if label == 1: # B | |
| _close() | |
| s_tok, s_char = tok_s, char_s | |
| e_tok, e_char = tok_e, char_e | |
| in_span = True | |
| elif label == 2: # I | |
| if not in_span: # promote illegal I→B | |
| _close() | |
| s_tok, s_char = tok_s, char_s | |
| in_span = True | |
| e_tok, e_char = tok_e, char_e | |
| else: # O | |
| _close() | |
| s_tok = None | |
| in_span = False | |
| _close() | |
| return spans | |
| def infer_sentence(model, tokenizer, sentence, device): | |
| """Run the full pipeline on one sentence. | |
| Returns: {"marked": str, "annotations": list[{"span": str, "emojis": str}]} | |
| """ | |
| enc = tokenizer( | |
| sentence, | |
| return_offsets_mapping=True, | |
| truncation=True, | |
| max_length=512, | |
| ) | |
| input_ids = torch.tensor([enc["input_ids"]], dtype=torch.long, device=device) | |
| attn_mask = torch.tensor([enc["attention_mask"]], dtype=torch.long, device=device) | |
| offsets = enc["offset_mapping"] | |
| word_ids = enc.word_ids() # list[int|None], one per token | |
| # ── Encoder + BIO ──────────────────────────────────────────────────────── | |
| encoder_out = model.encoder(input_ids, attn_mask) | |
| hidden = encoder_out.last_hidden_state # (1, seq_len, hidden) | |
| bio_logits = model.bio_head(hidden) # (1, seq_len, 3) | |
| # Viterbi decoding enforces valid B-I-O transitions | |
| decoded_tags = model.bio_head.decode(bio_logits, attn_mask) # list[list[int]] | |
| seq_len = bio_logits.shape[1] | |
| # Pad back to seq_len with O (0) in case any padding positions were excluded | |
| raw = decoded_tags[0] | |
| bio_labels = raw + [0] * (seq_len - len(raw)) | |
| # Word-level span extraction: fixes subword splitting and trailing spaces | |
| spans = _extract_word_level_spans(bio_labels, word_ids, offsets, sentence) | |
| if not spans: | |
| return {"marked": sentence, "annotations": []} | |
| # ── Batch all spans in ONE decoder call ────────────────────────────────── | |
| span_info_local = [(tok_s, tok_e) for tok_s, tok_e, *_ in spans] | |
| span_embs, enc_hidden, enc_mask = model._pool_spans( | |
| hidden, attn_mask, [span_info_local] | |
| ) | |
| _, decoded_seqs = model.emoji_decoder(span_embs, enc_hidden, enc_mask) | |
| # ── Reconstruct output ─────────────────────────────────────────────────── | |
| annotations = [] | |
| for (_, _, cs, ce, span_text), token_ids in zip(spans, decoded_seqs): | |
| emoji_str = emoji_vocab.decode(token_ids) | |
| annotations.append({"span": span_text, "emojis": emoji_str}) | |
| # Insert <span> tags in reverse order to preserve char indices | |
| marked = sentence | |
| for _, _, cs, ce, span_text in reversed(spans): | |
| marked = marked[:cs] + f"<span>{span_text}</span>" + marked[ce:] | |
| return {"marked": marked, "annotations": annotations} | |
| def evaluate_dataset(model, tokenizer, device, dataset, num_samples=None): | |
| model.eval() | |
| n = min(num_samples or len(dataset), len(dataset)) | |
| valid, invalid = 0, 0 | |
| samples = [] | |
| for idx in tqdm(range(n)): | |
| item = dataset.hf_dataset[idx] | |
| sentence = next( | |
| (m["content"] for m in item["prompt"] if m["role"] == "user"), "" | |
| ) | |
| try: | |
| out = infer_sentence(model, tokenizer, sentence, device) | |
| ok, msg = validate_annotation_json(out, sentence) | |
| if ok: | |
| valid += 1 | |
| else: | |
| invalid += 1 | |
| if len(samples) < 5: | |
| samples.append({"sentence": sentence, "output": out, "valid": ok, "msg": msg}) | |
| except Exception as e: | |
| invalid += 1 | |
| if len(samples) < 5: | |
| samples.append({"sentence": sentence, "error": str(e), "valid": False}) | |
| return {"valid": valid, "invalid": invalid, "samples": samples} | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--checkpoint", required=True) | |
| parser.add_argument("--data_path", default="data") | |
| parser.add_argument("--output_dir", default="eval_results") | |
| parser.add_argument("--num_samples", type=int, default=200) | |
| args = parser.parse_args() | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| Path(args.output_dir).mkdir(parents=True, exist_ok=True) | |
| print("Loading model …") | |
| model = _load_model(args.checkpoint, device) | |
| model.to(device).eval() | |
| tokenizer = AutoTokenizer.from_pretrained(args.checkpoint) | |
| _, eval_ds = load_encoder_datasets(args.data_path, tokenizer, max_length=512) | |
| print("Running evaluation …") | |
| results = evaluate_dataset(model, tokenizer, device, eval_ds, args.num_samples) | |
| total = results["valid"] + results["invalid"] | |
| print(f"\nValid : {results['valid']}/{total} ({results['valid']/total*100:.1f}%)") | |
| out_path = Path(args.output_dir) / "results.json" | |
| with open(out_path, "w") as f: | |
| json.dump(results, f, indent=2, ensure_ascii=False) | |
| print(f"Saved → {out_path}") | |
| if __name__ == "__main__": | |
| main() | |