turkish-extended-ner / src /features.py
WildGenie's picture
Upload src/features.py with huggingface_hub
fadedde verified
Raw
History Blame Contribute Delete
11.3 kB
import os
import re
import numpy as np
class BERTFeatureExtractor:
_instance = None
def __new__(cls, model_name="dbmdz/bert-base-turkish-cased"):
if cls._instance is None:
cls._instance = super(BERTFeatureExtractor, cls).__new__(cls)
cls._instance.initialized = False
return cls._instance
def __init__(self, model_name="dbmdz/bert-base-turkish-cased"):
if self.initialized:
return
import torch
from transformers import AutoTokenizer, AutoModel
self.device = "mps" if torch.backends.mps.is_available() else "cpu"
self.tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
# Use AutoModel to handle BERT, BART (VBART), and GPT (Kumru) architectures
self.model = AutoModel.from_pretrained(model_name, trust_remote_code=True).to(self.device).eval()
self.cache = {}
self.initialized = True
def get_sentence_embeddings(self, tokens):
"""
Extracts contextual embeddings for a sentence.
Returns a list of vectors (1 per token).
"""
sent_str = " ".join(tokens)
if sent_str in self.cache:
return self.cache[sent_str]
import torch
inputs = self.tokenizer(tokens, is_split_into_words=True, return_tensors="pt", padding=True, truncation=True).to(self.device)
# Filter inputs to only include what the model accepts
import inspect
sig = inspect.signature(self.model.forward)
model_inputs = {k: v for k, v in inputs.items() if k in sig.parameters}
with torch.no_grad():
outputs = self.model(**model_inputs)
# Use last hidden state
last_hidden_state = outputs.last_hidden_state[0] # (seq_len, 768)
# Map word tokens back to original tokens (handling subwords)
word_ids = inputs.word_ids()
word_embeddings = []
current_word_idx = -1
current_word_vecs = []
for i, word_idx in enumerate(word_ids):
if word_idx is None: continue # Skip [CLS], [SEP], [PAD]
if word_idx != current_word_idx:
if current_word_vecs:
word_embeddings.append(torch.stack(current_word_vecs).mean(dim=0).cpu().numpy())
current_word_idx = word_idx
current_word_vecs = [last_hidden_state[i]]
else:
current_word_vecs.append(last_hidden_state[i])
if current_word_vecs:
word_embeddings.append(torch.stack(current_word_vecs).mean(dim=0).cpu().numpy())
# Ensure we have the same number of vectors as tokens
if len(word_embeddings) != len(tokens):
# This can happen due to truncation or tokenizer edge cases
if len(word_embeddings) < len(tokens):
word_embeddings.extend([np.zeros(768)] * (len(tokens) - len(word_embeddings)))
else:
word_embeddings = word_embeddings[:len(tokens)]
self.cache[sent_str] = word_embeddings
return word_embeddings
class FeatureExtractor:
def __init__(self, gazetteer_dir="gazetteers", use_gazetteers=True, use_morphology=True,
use_embeddings=False, embedding_model="dbmdz/bert-base-turkish-cased"):
self.use_gazetteers = use_gazetteers
self.use_morphology = use_morphology
self.use_embeddings = use_embeddings
self.gazetteers = {}
self.embedding_extractor = None
if self.use_embeddings:
self.embedding_extractor = BERTFeatureExtractor(model_name=embedding_model)
# Only load if enabled
if self.use_gazetteers:
self.load_gazetteers(gazetteer_dir)
else:
self.full_names = {}
self.tokens_set = {}
def load_gazetteers(self, gazetteer_dir):
"""
Loads gazetteer files into sets for fast lookup.
Stores both full entries and individual tokens.
"""
self.full_names = {}
self.tokens_set = {} # For partial membership signal
if not os.path.exists(gazetteer_dir):
return
for filename in os.listdir(gazetteer_dir):
if filename.endswith(".txt"):
name = filename.replace(".txt", "")
path = os.path.join(gazetteer_dir, filename)
self.full_names[name] = set()
self.tokens_set[name] = set()
with open(path, "r", encoding="utf-8") as f:
for line in f:
entry = line.strip().lower()
if entry:
self.full_names[name].add(entry)
# Also add individual tokens
for t in entry.split():
self.tokens_set[name].add(t)
def check_gazetteer(self, text, gazetteer_name, is_token=False):
if not self.use_gazetteers:
return False
text = text.lower()
if is_token:
return text in self.tokens_set.get(gazetteer_name, set())
return text in self.full_names.get(gazetteer_name, set())
def word2features(self, sent, i):
"""
Extracts features for a single word in a sentence.
"""
word = sent[i]['word']
lemma = sent[i]['lemma']
pos = sent[i]['pos']
features = {
'bias': 1.0,
'word.lower()': word.lower(),
'word.isupper()': word.isupper(),
'word.istitle()': word.istitle(),
'word.isdigit()': word.isdigit(),
'word.has_apostrophe': "'" in word or "’" in word,
}
# Morphology Features (Optional)
if self.use_morphology:
features['lemma'] = lemma
features['pos'] = pos
# Deep Morphology (Nuve) - Standard or Hybrid
if 'nuve_morph' in sent[i]:
morph = sent[i]['nuve_morph']
prefix = "nuve."
else:
morph = sent[i].get('morph', [])
prefix = "morph." # Backward compatibility
if isinstance(morph, list) and len(morph) > 0:
features[f'{prefix}count'] = len(morph)
features[f'{prefix}last_suffix_id'] = morph[-1].get('Id', '')
features[f'{prefix}has_change'] = any(m.get('HasChange', False) for m in morph)
# Suffix sequence feature
suffix_seq = "-".join([m.get('Id', '') for m in morph if m.get('Type') != 'Root'])
if suffix_seq:
features[f'{prefix}suffix_seq'] = suffix_seq
# Combine all labels
for m in morph:
for label in m.get('Labels', []):
features[f'{prefix}label_{label}'] = True
# Zemberek Morphology (Hybrid Only)
if 'zemberek_morph' in sent[i]:
z_morph = sent[i]['zemberek_morph']
prefix = "zember."
if isinstance(z_morph, list) and len(z_morph) > 0:
features[f'{prefix}count'] = len(z_morph)
features[f'{prefix}last_suffix_id'] = z_morph[-1].get('Id', '')
suffix_seq = "-".join([m.get('Id', '') for m in z_morph if m.get('Type') != 'Root'])
if suffix_seq:
features[f'{prefix}suffix_seq'] = suffix_seq
# Gazetteer Features (Optional - controlled by self.use_gazetteers)
if self.use_gazetteers:
# Multi-word Lookup via Windows
for g_name in ['kisiler', 'yerler', 'sirketler', 'kurumlar', 'film_muzik', 'topluluklar']:
# Single word membership
features[f'in_{g_name}_tokens'] = self.check_gazetteer(word, g_name, is_token=True)
# Contextual Match (is this word the START of a multi-word gazetteer entry?)
# Check current + next
if i < len(sent) - 1:
bigram = f"{word} {sent[i+1]['word']}"
if self.check_gazetteer(bigram, g_name):
features[f'start_bigram_{g_name}'] = True
# Check current + next + next next
if i < len(sent) - 2:
trigram = f"{word} {sent[i+1]['word']} {sent[i+2]['word']}"
if self.check_gazetteer(trigram, g_name):
features[f'start_trigram_{g_name}'] = True
# Check prev + current
if i > 0:
prev_bigram = f"{sent[i-1]['word']} {word}"
if self.check_gazetteer(prev_bigram, g_name):
features[f'inside_bigram_{g_name}'] = True
# Specific keyword indicators (often tied to specific categories like Company/Org)
word_l = word.lower()
features.update({
'kw_holding': 'holding' in word_l,
'kw_bank': 'banka' in word_l,
'kw_company_suffix': any(s in word_l for s in ['a.ş', 'ltd', 'şirketi']),
'kw_group_suffix': any(s in word_l for s in ['grubu', 'kulübü', 'derneği', 'partisi']),
'kw_org_suffix': any(s in word_l for s in ['vakfı', 'üniversitesi', 'belediyesi'])
})
# Regex Features (Extended NER cues)
if re.search(r'(Derneği|Kulübü|Topluluğu)$', word):
features['suffix_group'] = True
if re.search(r'(Vakfı|Birliği|Fonu)$', word):
features['suffix_org'] = True
# Context Features
if i > 0:
word1 = sent[i-1]['word']
features.update({
'-1:word.lower()': word1.lower(),
'-1:word.istitle()': word1.istitle(),
'-1:word.isupper()': word1.isupper(),
})
if self.use_morphology:
features['-1:lemma'] = sent[i-1]['lemma']
else:
features['BOS'] = True
if i < len(sent)-1:
word1 = sent[i+1]['word']
features.update({
'+1:word.lower()': word1.lower(),
'+1:word.istitle()': word1.istitle(),
'+1:word.isupper()': word1.isupper(),
})
if self.use_morphology:
features['+1:lemma'] = sent[i+1]['lemma']
else:
features['EOS'] = True
return features
def sent2features(self, sent):
features_list = [self.word2features(sent, i) for i in range(len(sent))]
# Add embedding features if enabled
if self.use_embeddings and self.embedding_extractor:
tokens = [w['word'] for w in sent]
embeddings = self.embedding_extractor.get_sentence_embeddings(tokens)
for i, emb in enumerate(embeddings):
# We use a very simplified mapping or just a few dimensions to avoid exploding CRF features
# In a real scenario, we might use PCA. Here we'll just use the first 16 values as a demo.
for j in range(16):
features_list[i][f'emb_{j}'] = float(emb[j])
return features_list