codebook / potato /solo_mode /labeling_functions.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
24.8 kB
"""
Labeling Function Extraction and Application
Inspired by ALCHEmist (NeurIPS 2024): extracts reusable labeling functions
from high-confidence LLM predictions to label instances without API calls.
A labeling function encodes a pattern like:
"When text contains 'love it' -> positive (confidence: 0.95)"
These functions are extracted from LLM reasoning on high-confidence predictions,
then applied to unlabeled instances via majority voting.
"""
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
ABSTAIN = "__ABSTAIN__"
@dataclass
class LabelingFunction:
"""A reusable labeling function extracted from LLM patterns.
Each function encodes a condition-label mapping discovered from
high-confidence LLM predictions.
"""
id: str
pattern_text: str # Human-readable pattern description
condition: str # The condition part (e.g., "text contains 'great'")
label: str # The label to assign when condition matches
confidence: float # Source LLM confidence when pattern was discovered
source_instance_ids: List[str] = field(default_factory=list)
coverage: int = 0 # Number of instances this function matched
accuracy: Optional[float] = None # Accuracy against human labels if known
enabled: bool = True
created_at: str = ""
extracted_from_reasoning: str = "" # Original LLM reasoning snippet
def __post_init__(self):
if not self.created_at:
self.created_at = datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
'id': self.id,
'pattern_text': self.pattern_text,
'condition': self.condition,
'label': self.label,
'confidence': self.confidence,
'source_instance_ids': self.source_instance_ids,
'coverage': self.coverage,
'accuracy': self.accuracy,
'enabled': self.enabled,
'created_at': self.created_at,
'extracted_from_reasoning': self.extracted_from_reasoning,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'LabelingFunction':
"""Deserialize from dictionary."""
return cls(
id=data['id'],
pattern_text=data['pattern_text'],
condition=data['condition'],
label=data['label'],
confidence=data.get('confidence', 0.0),
source_instance_ids=data.get('source_instance_ids', []),
coverage=data.get('coverage', 0),
accuracy=data.get('accuracy'),
enabled=data.get('enabled', True),
created_at=data.get('created_at', ''),
extracted_from_reasoning=data.get('extracted_from_reasoning', ''),
)
@dataclass
class LabelingFunctionVote:
"""A vote from a labeling function for a specific instance."""
function_id: str
label: str
confidence: float
@dataclass
class ApplyResult:
"""Result of applying labeling functions to an instance."""
instance_id: str
label: Optional[str] = None
votes: List[LabelingFunctionVote] = field(default_factory=list)
abstained: bool = True
vote_agreement: float = 0.0 # Fraction of votes that agree on the label
def to_dict(self) -> Dict[str, Any]:
return {
'instance_id': self.instance_id,
'label': self.label,
'abstained': self.abstained,
'vote_agreement': self.vote_agreement,
'num_votes': len(self.votes),
}
EXTRACTION_PROMPT = """Analyze the following high-confidence LLM predictions and extract reusable labeling patterns.
For each prediction, the LLM was highly confident about its label. Extract patterns that could be applied to new instances without calling the LLM.
Predictions:
{predictions_text}
Extract labeling functions as a JSON array. Each function should have:
- "pattern_text": A human-readable description of the pattern
- "condition": A simple text-matching condition (e.g., "text contains 'keyword'", "text starts with 'pattern'", "text mentions sentiment words like 'great', 'love'")
- "label": The label to assign when the condition matches
- "keywords": List of keywords/phrases that trigger this pattern
Return ONLY a JSON array of objects. Example:
[
{{
"pattern_text": "Positive sentiment keywords like 'love', 'great', 'amazing'",
"condition": "text contains positive sentiment keywords",
"label": "positive",
"keywords": ["love", "great", "amazing", "excellent", "wonderful"]
}}
]"""
class LabelingFunctionExtractor:
"""Extracts labeling functions from high-confidence LLM predictions.
Analyzes patterns in LLM reasoning to discover reusable rules
that can label future instances without API calls.
"""
def __init__(self, app_config: Dict, solo_config):
self._app_config = app_config
self._solo_config = solo_config
self._lf_config = solo_config.labeling_functions
self._endpoint = None
def extract_from_predictions(
self,
predictions: List[Dict[str, Any]],
) -> List[LabelingFunction]:
"""Extract labeling functions from high-confidence predictions.
Args:
predictions: List of dicts with 'instance_id', 'text',
'predicted_label', 'confidence', 'reasoning'.
Returns:
List of extracted LabelingFunction objects.
"""
if not predictions:
return []
# Filter to high-confidence predictions
min_conf = self._lf_config.min_confidence
high_conf = [p for p in predictions if p.get('confidence', 0) >= min_conf]
if not high_conf:
return []
# Group by label
by_label: Dict[str, List[Dict]] = {}
for p in high_conf:
label = str(p.get('predicted_label', ''))
by_label.setdefault(label, []).append(p)
# Try LLM-assisted extraction first
functions = self._extract_with_llm(high_conf)
# Fallback to keyword-based extraction if LLM fails
if not functions:
functions = self._extract_keyword_patterns(by_label)
# Limit to max_functions
max_fns = self._lf_config.max_functions
if len(functions) > max_fns:
# Keep highest-confidence functions
functions.sort(key=lambda f: f.confidence, reverse=True)
functions = functions[:max_fns]
return functions
def _extract_with_llm(
self, predictions: List[Dict[str, Any]]
) -> List[LabelingFunction]:
"""Use LLM to extract labeling functions from prediction patterns."""
endpoint = self._get_endpoint()
if endpoint is None:
return []
# Build prompt with prediction examples (limit to 20 for context)
sample = predictions[:20]
pred_lines = []
for p in sample:
text = str(p.get('text', ''))[:200]
pred_lines.append(
f"- Text: \"{text}\"\n"
f" Label: {p.get('predicted_label')} "
f"(confidence: {p.get('confidence', 0):.2f})\n"
f" Reasoning: {p.get('reasoning', 'N/A')}"
)
prompt = EXTRACTION_PROMPT.format(
predictions_text="\n\n".join(pred_lines)
)
try:
response = endpoint.query(prompt)
parsed = self._parse_json_array(response)
if not parsed:
return []
functions = []
for item in parsed:
if not isinstance(item, dict):
continue
pattern_text = item.get('pattern_text', '')
condition = item.get('condition', '')
label = item.get('label', '')
keywords = item.get('keywords', [])
if not label or (not condition and not keywords):
continue
# Find source instances matching this pattern
source_ids = []
for p in predictions:
if str(p.get('predicted_label', '')) == label:
source_ids.append(p['instance_id'])
if len(source_ids) >= 5:
break
# Compute average confidence for matching predictions
matching_confs = [
p['confidence'] for p in predictions
if str(p.get('predicted_label', '')) == label
]
avg_conf = (
sum(matching_confs) / len(matching_confs)
if matching_confs else 0.0
)
fn = LabelingFunction(
id=f"lf_{uuid.uuid4().hex[:8]}",
pattern_text=pattern_text,
condition=condition,
label=label,
confidence=avg_conf,
source_instance_ids=source_ids,
extracted_from_reasoning=', '.join(keywords) if keywords else condition,
)
functions.append(fn)
logger.info(f"LLM extracted {len(functions)} labeling functions")
return functions
except Exception as e:
logger.warning(f"LLM extraction failed: {e}")
return []
def _extract_keyword_patterns(
self, by_label: Dict[str, List[Dict]]
) -> List[LabelingFunction]:
"""Fallback: extract keyword patterns from prediction texts.
Groups predictions by label and finds common words/phrases
that appear frequently in texts with the same label.
"""
functions = []
for label, preds in by_label.items():
if len(preds) < self._lf_config.min_coverage:
continue
# Collect all words from texts for this label
word_counts: Dict[str, int] = {}
word_instances: Dict[str, List[str]] = {}
for p in preds:
text = str(p.get('text', '')).lower()
words = set(re.findall(r'\b\w{3,}\b', text))
for w in words:
word_counts[w] = word_counts.get(w, 0) + 1
word_instances.setdefault(w, []).append(p['instance_id'])
# Find words that appear in >= min_coverage predictions
min_cov = self._lf_config.min_coverage
common_words = {
w: c for w, c in word_counts.items()
if c >= min_cov
}
if not common_words:
continue
# Filter out very common words (> 80% of all predictions)
total = len(preds)
significant = {
w: c for w, c in common_words.items()
if c <= total * 0.8
}
if not significant:
continue
# Take top keywords by frequency
top_keywords = sorted(
significant.items(), key=lambda x: x[1], reverse=True
)[:5]
keywords = [w for w, _ in top_keywords]
avg_conf = (
sum(p.get('confidence', 0) for p in preds) / len(preds)
)
source_ids = [p['instance_id'] for p in preds[:5]]
fn = LabelingFunction(
id=f"lf_{uuid.uuid4().hex[:8]}",
pattern_text=(
f"Text containing keywords like "
f"'{', '.join(keywords)}' -> {label}"
),
condition=f"text contains any of: {', '.join(keywords)}",
label=label,
confidence=avg_conf,
source_instance_ids=source_ids,
coverage=len(preds),
extracted_from_reasoning=', '.join(keywords),
)
functions.append(fn)
return functions
def _get_endpoint(self):
"""Get or create an AI endpoint for extraction."""
if self._endpoint is not None:
return self._endpoint
try:
from potato.ai.ai_endpoint import AIEndpointFactory
models = (
self._solo_config.revision_models
or self._solo_config.labeling_models
)
for model_config in models:
try:
endpoint_config = model_config.to_endpoint_config(temperature_override=0.3)
endpoint = AIEndpointFactory.create_endpoint(endpoint_config)
if endpoint:
self._endpoint = endpoint
return endpoint
except Exception:
continue
except Exception as e:
logger.warning(f"Could not create extraction endpoint: {e}")
return None
def _parse_json_array(self, response: str) -> Optional[list]:
"""Parse a JSON array from LLM response."""
import json
if not response:
return None
# Try direct parse
text = response.strip()
try:
result = json.loads(text)
if isinstance(result, list):
return result
except (json.JSONDecodeError, TypeError):
pass
# Try extracting from markdown code fence
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
if match:
try:
result = json.loads(match.group(1).strip())
if isinstance(result, list):
return result
except (json.JSONDecodeError, TypeError):
pass
# Try finding array brackets
start = text.find('[')
end = text.rfind(']')
if start >= 0 and end > start:
try:
result = json.loads(text[start:end + 1])
if isinstance(result, list):
return result
except (json.JSONDecodeError, TypeError):
pass
return None
class LabelingFunctionApplier:
"""Applies labeling functions to instances for weak supervision.
Uses majority voting among matching labeling functions to assign labels
without calling the LLM.
"""
def __init__(self, vote_threshold: float = 0.5):
self._vote_threshold = vote_threshold
def apply(
self,
instance_id: str,
text: str,
functions: List[LabelingFunction],
) -> ApplyResult:
"""Apply all enabled labeling functions to an instance.
Args:
instance_id: The instance identifier.
text: The instance text.
functions: List of labeling functions to try.
Returns:
ApplyResult with the voted label or abstention.
"""
votes: List[LabelingFunctionVote] = []
text_lower = text.lower()
for fn in functions:
if not fn.enabled:
continue
if self._matches(fn, text_lower):
votes.append(LabelingFunctionVote(
function_id=fn.id,
label=fn.label,
confidence=fn.confidence,
))
if not votes:
return ApplyResult(instance_id=instance_id, abstained=True)
# Majority vote weighted by confidence
label_scores: Dict[str, float] = {}
for v in votes:
label_scores[v.label] = label_scores.get(v.label, 0) + v.confidence
# Find winning label
best_label = max(label_scores, key=label_scores.get)
total_score = sum(label_scores.values())
agreement = label_scores[best_label] / total_score if total_score > 0 else 0
# Check if agreement meets threshold
if agreement < self._vote_threshold:
return ApplyResult(
instance_id=instance_id,
votes=votes,
abstained=True,
vote_agreement=agreement,
)
return ApplyResult(
instance_id=instance_id,
label=best_label,
votes=votes,
abstained=False,
vote_agreement=agreement,
)
def apply_batch(
self,
instances: List[Dict[str, str]],
functions: List[LabelingFunction],
) -> List[ApplyResult]:
"""Apply labeling functions to a batch of instances.
Args:
instances: List of dicts with 'instance_id' and 'text'.
functions: List of labeling functions.
Returns:
List of ApplyResult, one per instance.
"""
enabled = [f for f in functions if f.enabled]
if not enabled:
return [
ApplyResult(instance_id=inst['instance_id'], abstained=True)
for inst in instances
]
return [
self.apply(inst['instance_id'], inst['text'], enabled)
for inst in instances
]
def _matches(self, fn: LabelingFunction, text_lower: str) -> bool:
"""Check if a labeling function matches the given text.
Uses keyword matching from the function's extracted_from_reasoning
and condition fields.
"""
# Extract keywords from the function
keywords = self._get_keywords(fn)
if not keywords:
return False
# Check if any keyword appears in the text
return any(kw in text_lower for kw in keywords)
def _get_keywords(self, fn: LabelingFunction) -> List[str]:
"""Extract lowercase keywords from a labeling function."""
keywords = []
# Parse keywords from extracted_from_reasoning (comma-separated)
reasoning = fn.extracted_from_reasoning
if reasoning:
parts = [p.strip().lower() for p in reasoning.split(',')]
keywords.extend(p for p in parts if p and len(p) >= 2)
# Parse keywords from condition if it has "contains" pattern
condition = fn.condition.lower()
# Match patterns like "text contains 'word'" or "any of: word1, word2"
contains_match = re.findall(r"'([^']+)'", condition)
if contains_match:
keywords.extend(w.lower() for w in contains_match)
any_of_match = re.search(r'any of:\s*(.+)', condition)
if any_of_match:
parts = [p.strip().lower() for p in any_of_match.group(1).split(',')]
keywords.extend(p for p in parts if p and len(p) >= 2)
return keywords
class LabelingFunctionManager:
"""Manages the lifecycle of labeling functions.
Handles extraction, storage, application, and statistics tracking.
"""
def __init__(self, app_config: Dict, solo_config):
self._app_config = app_config
self._solo_config = solo_config
self._lf_config = solo_config.labeling_functions
self._functions: Dict[str, LabelingFunction] = {}
self._extractor = LabelingFunctionExtractor(app_config, solo_config)
self._applier = LabelingFunctionApplier(
vote_threshold=self._lf_config.vote_threshold
)
self._instances_labeled: int = 0
self._instances_abstained: int = 0
@property
def enabled(self) -> bool:
return self._lf_config.enabled
def get_all_functions(self) -> List[LabelingFunction]:
"""Get all labeling functions."""
return list(self._functions.values())
def get_enabled_functions(self) -> List[LabelingFunction]:
"""Get only enabled labeling functions."""
return [f for f in self._functions.values() if f.enabled]
def get_function(self, function_id: str) -> Optional[LabelingFunction]:
"""Get a specific labeling function by ID."""
return self._functions.get(function_id)
def add_function(self, fn: LabelingFunction) -> None:
"""Add a labeling function."""
self._functions[fn.id] = fn
def toggle_function(self, function_id: str) -> Optional[bool]:
"""Toggle a function's enabled state. Returns new state or None."""
fn = self._functions.get(function_id)
if fn is None:
return None
fn.enabled = not fn.enabled
return fn.enabled
def remove_function(self, function_id: str) -> bool:
"""Remove a labeling function."""
return self._functions.pop(function_id, None) is not None
def extract_functions(
self,
predictions: List[Dict[str, Any]],
) -> List[LabelingFunction]:
"""Extract new labeling functions from predictions.
Args:
predictions: List of dicts with instance_id, text,
predicted_label, confidence, reasoning.
Returns:
List of newly extracted functions.
"""
new_fns = self._extractor.extract_from_predictions(predictions)
for fn in new_fns:
self._functions[fn.id] = fn
if new_fns:
logger.info(
f"Extracted {len(new_fns)} labeling functions "
f"(total: {len(self._functions)})"
)
return new_fns
def try_label(
self, instance_id: str, text: str
) -> Optional[ApplyResult]:
"""Try to label an instance using labeling functions.
Returns:
ApplyResult if a label was assigned, None if abstained or disabled.
"""
if not self._lf_config.enabled:
return None
enabled = self.get_enabled_functions()
if not enabled:
return None
result = self._applier.apply(instance_id, text, enabled)
if result.abstained:
self._instances_abstained += 1
return None
self._instances_labeled += 1
# Update coverage counts
for vote in result.votes:
fn = self._functions.get(vote.function_id)
if fn:
fn.coverage += 1
return result
def apply_batch(
self, instances: List[Dict[str, str]]
) -> Tuple[List[ApplyResult], List[Dict[str, str]]]:
"""Apply labeling functions to a batch, returning labeled and remaining.
Args:
instances: List of dicts with instance_id and text.
Returns:
Tuple of (labeled_results, unlabeled_instances).
"""
if not self._lf_config.enabled:
return [], instances
enabled = self.get_enabled_functions()
if not enabled:
return [], instances
labeled = []
remaining = []
for inst in instances:
result = self._applier.apply(
inst['instance_id'], inst['text'], enabled
)
if result.abstained:
remaining.append(inst)
self._instances_abstained += 1
else:
labeled.append(result)
self._instances_labeled += 1
# Update coverage
for vote in result.votes:
fn = self._functions.get(vote.function_id)
if fn:
fn.coverage += 1
return labeled, remaining
def get_stats(self) -> Dict[str, Any]:
"""Get labeling function statistics."""
functions = list(self._functions.values())
enabled = [f for f in functions if f.enabled]
return {
'enabled': self._lf_config.enabled,
'total_functions': len(functions),
'enabled_functions': len(enabled),
'instances_labeled': self._instances_labeled,
'instances_abstained': self._instances_abstained,
'total_coverage': sum(f.coverage for f in functions),
'avg_confidence': (
sum(f.confidence for f in functions) / len(functions)
if functions else 0.0
),
}
def to_dict(self) -> Dict[str, Any]:
"""Serialize state for persistence."""
return {
'functions': [f.to_dict() for f in self._functions.values()],
'instances_labeled': self._instances_labeled,
'instances_abstained': self._instances_abstained,
}
def load_state(self, data: Dict[str, Any]) -> None:
"""Restore state from persisted data."""
self._functions = {}
for fn_data in data.get('functions', []):
fn = LabelingFunction.from_dict(fn_data)
self._functions[fn.id] = fn
self._instances_labeled = data.get('instances_labeled', 0)
self._instances_abstained = data.get('instances_abstained', 0)