uce-gemma-80k / _model.py
klpeng's picture
Add lightweight concept metadata API and release packaging (#1)
5c94901
Raw History Blame Contribute Delete
13.4 kB
"""UCE Gemma: normalized embeddings or plain text to 80,513 labeled concepts."""
import json
from pathlib import Path
import numpy as np
import torch
from safetensors.torch import load_file
from scipy import sparse
if __package__:
from .concept_metadata import ConceptMetadata, METADATA_FILES
else:
from concept_metadata import ConceptMetadata, METADATA_FILES
def _directory(path, revision=None, cache_dir=None, local_files_only=False, *, with_embedder=False):
directory = Path(path).expanduser()
if not directory.is_dir():
from huggingface_hub import snapshot_download
directory = Path(snapshot_download(
repo_id=str(path), revision=revision, cache_dir=cache_dir,
local_files_only=local_files_only,
allow_patterns=METADATA_FILES + ['encoder.safetensors']
+ (['gemma_embedder/*'] if with_embedder else []),
))
return directory
def _batch_size(value):
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
raise ValueError('batch_size must be a positive integer')
class UCEGemmaEncoder(torch.nn.Module):
"""Gemma embedding-to-concept encoder; float32 sigmoid scores, top 128."""
def __init__(self, config, tensors, labels, *, prevalences=None, metadata=None):
super().__init__()
self.config = dict(config)
if config.get('model_type') != 'uce_gemma_80k' or config.get('format_version') != 1:
raise ValueError('Unsupported UCE Gemma package')
self.input_dim = config['input_dim']
self.num_features = config['num_features']
self.top_k = config['top_k']
if config.get('input_unit_norm') is not True or not 0 < self.top_k <= self.num_features:
raise ValueError('Invalid encoder configuration')
if set(tensors) != {'weight', 'bias'}:
raise ValueError('Expected weight and bias only')
if tensors['weight'].shape != (self.num_features, self.input_dim) or tensors['bias'].shape != (self.num_features,):
raise ValueError('Invalid encoder weight shape')
for name, tensor in tensors.items():
if tensor.dtype != torch.float32 or not torch.isfinite(tensor).all():
raise ValueError('Expected finite float32 weights')
self.register_parameter(name, torch.nn.Parameter(tensor, requires_grad=False))
self.labels = dict(labels)
self.metadata = metadata or ConceptMetadata(config, labels, prevalences=prevalences)
self._prevalences = self.metadata._prevalences
if set(self.labels) != set(range(self.num_features)) or any(not label.strip() for label in self.labels.values()):
raise ValueError('Every output coordinate must have one nonempty label')
self.eval()
@classmethod
def from_pretrained(cls, model_id_or_path, *, revision=None, device='cpu', cache_dir=None, local_files_only=False):
directory = _directory(model_id_or_path, revision, cache_dir, local_files_only)
config = json.loads((directory / 'config.json').read_text())
metadata = ConceptMetadata.from_pretrained(directory)
if not Path(model_id_or_path).expanduser().is_dir():
metadata.provenance.update(
source_repo_id=str(model_id_or_path), requested_revision=revision,
resolved_revision=directory.name if directory.parent.name == 'snapshots' else None)
return cls(config, load_file(str(directory / 'encoder.safetensors')), metadata.labels,
metadata=metadata).to(device)
def get_prevalence(self, concept_id):
"""Return saved historical Gemini baseline counts, fractions, and bounds."""
return self.metadata.get_prevalence(concept_id)
@torch.inference_mode()
def forward(self, embeddings):
"""Return device tensors (concept IDs, scores), each [batch, 128]."""
if self.weight.dtype != torch.float32 or self.bias.dtype != torch.float32:
raise ValueError('Keep the concept encoder in float32')
if isinstance(embeddings, torch.Tensor):
if embeddings.is_complex() or embeddings.dtype == torch.bool:
raise ValueError('Expected real embeddings')
x = embeddings.detach().to(device=self.weight.device, dtype=torch.float32)
else:
a = np.asarray(embeddings)
if a.dtype.kind not in 'fiu':
raise ValueError('Expected real embeddings')
x = torch.from_numpy(np.array(a, dtype=np.float32, copy=True)).to(self.weight.device)
if x.ndim != 2 or x.shape[1] != self.input_dim or not torch.isfinite(x).all():
raise ValueError(f'Expected finite embeddings of shape (N, {self.input_dim})')
if not torch.allclose(torch.linalg.vector_norm(x, dim=1), torch.ones(len(x), device=x.device), atol=2e-5, rtol=0):
raise ValueError('Provide unit-normalized Gemma document embeddings')
with torch.autocast(device_type=self.weight.device.type, enabled=False):
logits = torch.nn.functional.linear(x, self.weight, self.bias)
if not torch.isfinite(logits).all():
raise ValueError('Nonfinite encoder scores')
# Rank logits first: sigmoid saturation must not change top-k membership.
values, indices = logits.topk(self.top_k, dim=-1)
return indices, torch.sigmoid(values)
def encode_iter(self, embeddings, *, batch_size=64):
"""Yield float32 SciPy CSR batches with 80,513 concept columns."""
_batch_size(batch_size)
if not isinstance(embeddings, (np.ndarray, torch.Tensor)):
raise TypeError('Expected a NumPy array or Torch tensor')
if embeddings.ndim != 2 or embeddings.shape[1] != self.input_dim:
raise ValueError(f'Expected shape (N, {self.input_dim})')
for start in range(0, len(embeddings), batch_size):
ids, vals = self(embeddings[start:start + batch_size])
ids, vals = ids.cpu().numpy().astype(np.int32), vals.cpu().numpy()
rowptr = np.arange(0, vals.size + 1, self.top_k, dtype=np.int32)
result = sparse.csr_matrix((vals.ravel(), ids.ravel(), rowptr), shape=(len(vals), self.num_features))
result.eliminate_zeros()
result.sort_indices()
yield result
def encode(self, embeddings, *, batch_size=64):
batches = list(self.encode_iter(embeddings, batch_size=batch_size))
return sparse.vstack(batches, format='csr') if batches else sparse.csr_matrix((0, self.num_features), dtype=np.float32)
def explain(self, row, *, top_n=20, include_prevalence=False):
"""Describe an encoded row using saved labels; no LLM/API calls."""
if not sparse.issparse(row) or row.shape != (1, self.num_features):
raise ValueError('Pass one encoded sparse row')
if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n < 0:
raise ValueError('top_n must be a nonnegative integer')
row = row.tocsr(copy=True)
row.sum_duplicates(); row.eliminate_zeros()
order = np.lexsort((row.indices, -row.data))[:top_n]
concepts = [dict(concept_id=int(row.indices[i]), label=self.labels[int(row.indices[i])], activation=float(row.data[i])) for i in order]
if include_prevalence:
for concept in concepts:
concept['baseline_prevalence'] = self.get_prevalence(concept['concept_id'])
return concepts
class UCEGemma(torch.nn.Module):
"""Plain strings -> text-only Gemma backbone -> concept scores.
Includes no vision/audio towers, multimodal projection weights, or processor.
Uses the original bfloat16 text inference and float32 concept encoder.
"""
def __init__(self, encoder, text_model, tokenizer):
super().__init__()
self.encoder = encoder
self.text_model = text_model
self.tokenizer = tokenizer
self.text_config = encoder.config['text_pipeline']
self.config = encoder.config
self.num_features = encoder.num_features
self.labels = encoder.labels
self.metadata = encoder.metadata
self.eval()
@classmethod
def from_pretrained(cls, model_id_or_path, *, revision=None, device='cpu', cache_dir=None, local_files_only=False):
from transformers import AutoTokenizer, EmbeddingGemma2TextModel
directory = _directory(model_id_or_path, revision, cache_dir, local_files_only, with_embedder=True)
encoder = UCEGemmaEncoder.from_pretrained(directory, device=device)
# Record the Hub revision before the local path loses its repository context.
if not Path(model_id_or_path).expanduser().is_dir():
encoder.metadata.provenance.update(
source_repo_id=str(model_id_or_path), requested_revision=revision,
resolved_revision=directory.name if directory.parent.name == 'snapshots' else None)
if 'text_pipeline' not in encoder.config or not (directory / 'gemma_embedder').is_dir():
raise ValueError('Load the full package to encode text')
text_model = EmbeddingGemma2TextModel.from_pretrained(
directory / 'gemma_embedder', dtype=torch.bfloat16, local_files_only=True,
).to(device).eval()
tokenizer = AutoTokenizer.from_pretrained(directory / 'gemma_embedder', local_files_only=True, trust_remote_code=False)
return cls(encoder, text_model, tokenizer)
@staticmethod
def _texts(texts):
if isinstance(texts, str):
texts = [texts]
if not isinstance(texts, (list, tuple)) or any(not isinstance(t, str) for t in texts):
raise TypeError('Expected a string or a list/tuple of plain strings')
return texts
@torch.inference_mode()
def embed(self, texts, *, batch_size=32):
"""Return normalized float32 [N,768] embeddings; overlength inputs raise."""
texts = self._texts(texts)
_batch_size(batch_size)
batches = []
device = next(self.text_model.parameters()).device
for start in range(0, len(texts), batch_size):
inputs = self.tokenizer([self.text_config['prompt'] + t for t in texts[start:start+batch_size]],
padding=True, truncation=False, return_tensors='pt')
if inputs['input_ids'].shape[1] > self.text_config['max_seq_length']:
raise ValueError('Text exceeds 8192 tokens including prompt; split the document explicitly')
ids, mask = inputs['input_ids'].to(device), inputs['attention_mask'].to(device)
# Original multimodal wrapper replaces placeholder IDs by PAD even for text-only input.
model_ids = ids.clone()
for token_id in self.text_config['placeholder_token_ids']:
model_ids.masked_fill_(ids == token_id, self.tokenizer.pad_token_id)
hidden = self.text_model(input_ids=model_ids, attention_mask=mask).last_hidden_state
expanded = mask.unsqueeze(-1).expand_as(hidden).to(hidden.dtype)
pooled = (hidden * expanded).sum(1) / expanded.sum(1).clamp_min(1e-9)
# Match SentenceTransformer Normalize and encode(normalize_embeddings=True).
pooled = torch.nn.functional.normalize(pooled, p=2, dim=1)
pooled = torch.nn.functional.normalize(pooled, p=2, dim=1)
array = pooled.float().cpu().numpy()
array /= np.linalg.norm(array, axis=1, keepdims=True)
batches.append(array)
return np.concatenate(batches) if batches else np.empty((0, self.encoder.input_dim), np.float32)
def encode_iter(self, texts, *, batch_size=32):
texts = self._texts(texts)
_batch_size(batch_size)
for start in range(0, len(texts), batch_size):
embeddings = self.embed(texts[start:start+batch_size], batch_size=batch_size)
yield self.encoder.encode(embeddings, batch_size=batch_size)
def encode(self, texts, *, batch_size=32):
batches = list(self.encode_iter(texts, batch_size=batch_size))
return sparse.vstack(batches, format='csr') if batches else sparse.csr_matrix((0, self.encoder.num_features), dtype=np.float32)
def forward(self, texts):
return self.encode(texts)
def explain(self, row, *, top_n=20, include_prevalence=False):
return self.encoder.explain(row, top_n=top_n, include_prevalence=include_prevalence)
def get_prevalence(self, concept_id):
"""Look up one concept's historical Gemini corpus baseline statistics."""
return self.encoder.get_prevalence(concept_id)
def main():
import argparse
parser = argparse.ArgumentParser(description='Universal Concept Encoder: Gemma text to 80,513 concepts')
parser.add_argument('--model', default=str(Path(__file__).resolve().parent), help='Local package or Hub repository ID')
parser.add_argument('--text', required=True, help='Plain text to encode')
parser.add_argument('--device', default='cpu')
parser.add_argument('--top-n', type=int, default=20)
args = parser.parse_args()
model = UCEGemma.from_pretrained(args.model, device=args.device)
print(json.dumps(model.explain(model.encode(args.text)[0], top_n=args.top_n), ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()