mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
feat: enhance semantic extraction with BYOM support, NER aggregation, RE implementation, and Triplet improvements
- Implemented 'Bring Your Own Model' (BYOM) support for NER, Relation, and Triplet extraction - Added NER aggregation strategies (simple, max, average) - Implemented Relation Extraction via Sequence Classification with entity markers - Enhanced Triplet Extraction with REBEL post-processing and lazy loading - Updated all extractors to prioritize runtime options over config defaults - Added extensive tests and examples (huggingface_demo.py) - Updated documentation and CHANGELOG
This commit is contained in:
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Bring Your Own Model (BYOM) Support**:
|
||||||
|
- Enabled full support for custom Hugging Face models in `NERExtractor`, `RelationExtractor`, and `TripletExtractor`.
|
||||||
|
- Added support for custom tokenizers in `HuggingFaceModelLoader` to handle models with non-standard tokenization requirements.
|
||||||
|
- Implemented robust fallback logic for model selection: runtime options (`extract(model=...)`) now correctly override configuration defaults.
|
||||||
|
- **Enhanced NER Implementation**:
|
||||||
|
- Added configurable aggregation strategies (`simple`, `first`, `average`, `max`) to `extract_entities_huggingface` for better sub-word token handling.
|
||||||
|
- Implemented robust IOB/BILOU parsing to reconstruct entities from raw model outputs when structured output is unavailable.
|
||||||
|
- Added confidence scoring for aggregated entities.
|
||||||
|
- **Relation Extraction Improvements**:
|
||||||
|
- Implemented standard entity marker technique (wrapping subject/object with `<subj>`, `<obj>` tags) in `extract_relations_huggingface` for compatibility with sequence classification models.
|
||||||
|
- Added structured output parsing to convert raw model predictions into validated `Relation` objects.
|
||||||
|
- **Triplet Extraction Completion**:
|
||||||
|
- Added specialized parsing for Seq2Seq models (e.g., REBEL) in `extract_triplets_huggingface` to generate structured triplets directly from text.
|
||||||
|
- Implemented post-processing logic to clean and validate generated triplets.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Model Parameter Precedence**:
|
||||||
|
- Fixed issue where configuration defaults took precedence over runtime arguments in Hugging Face extractors. Runtime options now correctly override config values.
|
||||||
|
- **Import Handling**:
|
||||||
|
- Fixed circular import issues in test suites by implementing robust mocking strategies.
|
||||||
|
|
||||||
## [0.2.4] - 2026-01-22
|
## [0.2.4] - 2026-01-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -184,18 +184,15 @@ Core entity extraction implementation used by notebooks and lower-level integrat
|
|||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
| `method` | str or list | `"ml"` | Method(s): "ml", "llm", "pattern", "regex", "huggingface" |
|
| `method` | str or list | `"ml"` | Method(s): "ml", "llm", "pattern", "regex", "huggingface" |
|
||||||
| `silent_fail` | bool | `False` | Return empty list on error instead of raising (LLM only) |
|
| `entity_types` | list | `None` | Filter for specific entity types |
|
||||||
| `max_text_length` | int | `64000` | Max text length for auto-chunking (LLM only) |
|
| `**config` | dict | `{}` | Method-specific config (e.g., `model`, `aggregation_strategy`, `device`) |
|
||||||
| `max_tokens` | int | `None` | Max output tokens for LLM generation |
|
|
||||||
| `max_workers` | int | `1` | Threads for parallel batch processing |
|
|
||||||
| `**config` | dict | `{}` | Method-specific config (e.g., `model`, `provider`) |
|
|
||||||
|
|
||||||
**Methods:**
|
**Methods:**
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `extract(text)` | Alias for `extract_entities`. Get list of entities. |
|
| `extract(text, pipeline_id=None, **kwargs)` | Alias for `extract_entities`. Supports `max_workers`. |
|
||||||
| `extract_entities(text)` | Get list of entities |
|
| `extract_entities(text, pipeline_id=None, **kwargs)` | Get list of entities. Supports `max_workers`. |
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
@@ -231,18 +228,19 @@ Extracts relationships between entities.
|
|||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
|
| `method` | str | `"dependency"` | Method: "dependency", "pattern", "cooccurrence", "huggingface", "llm" |
|
||||||
| `relation_types` | list | `None` | Specific relation types to extract |
|
| `relation_types` | list | `None` | Specific relation types to extract |
|
||||||
| `bidirectional` | bool | `False` | Extract bidirectional relations |
|
| `bidirectional` | bool | `False` | Extract bidirectional relations |
|
||||||
| `confidence_threshold` | float | `0.6` | Minimum confidence score |
|
| `confidence_threshold` | float | `0.6` | Minimum confidence score |
|
||||||
| `max_distance` | int | `50` | Max token distance between entities |
|
| `max_distance` | int | `50` | Max token distance between entities |
|
||||||
| `max_workers` | int | `1` | Threads for parallel batch processing |
|
| `**config` | dict | `{}` | Method-specific config (e.g., `model`, `device` for HuggingFace) |
|
||||||
|
|
||||||
**Methods:**
|
**Methods:**
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `extract(text, entities)` | Alias for `extract_relations`. Find links. |
|
| `extract(text, entities, pipeline_id=None, **kwargs)` | Alias for `extract_relations`. Supports `max_workers`. |
|
||||||
| `extract_relations(text, entities)` | Find links |
|
| `extract_relations(text, entities, pipeline_id=None, **kwargs)` | Find links. Supports `max_workers`. |
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
@@ -341,19 +339,18 @@ Extracts RDF triplets (Subject-Predicate-Object).
|
|||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
| Parameter | Type | Default | Description |
|
||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
|
| `method` | str | `"pattern"` | Extraction method ("pattern", "rules", "huggingface", "llm") |
|
||||||
|
| `triplet_types` | list | `None` | Specific triplet types/predicates to extract |
|
||||||
| `include_temporal` | bool | `False` | Include time information |
|
| `include_temporal` | bool | `False` | Include time information |
|
||||||
| `include_provenance` | bool | `False` | Track source sentences |
|
| `include_provenance` | bool | `False` | Track source sentences |
|
||||||
| `method` | str | `"pattern"` | Extraction method ("pattern", "rules", "huggingface", "llm") |
|
| `**kwargs` | dict | `{}` | Configuration options (e.g., `model`, `device`) |
|
||||||
| `silent_fail` | bool | `False` | Return empty list on error instead of raising (LLM only) |
|
|
||||||
| `max_text_length` | int | `64000` | Max text length for auto-chunking (LLM only) |
|
|
||||||
| `max_tokens` | int | `None` | Max output tokens for LLM generation |
|
|
||||||
| `max_workers` | int | `1` | Threads for parallel batch processing |
|
|
||||||
|
|
||||||
**Methods:**
|
**Methods:**
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `extract_triplets(text)` | Get (S, P, O) tuples |
|
| `extract(text, entities=None, relations=None, pipeline_id=None, **kwargs)` | Alias for `extract_triplets`. Supports `max_workers`. |
|
||||||
|
| `extract_triplets(text, entities=None, relations=None, pipeline_id=None, **kwargs)` | Get (S, P, O) tuples. Supports `max_workers`. |
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
HuggingFace Local Model Usage Demo (Bring Your Own Model)
|
||||||
|
|
||||||
|
This script demonstrates how to use the 'semantica' library with local HuggingFace models
|
||||||
|
for Named Entity Recognition (NER), Relation Extraction (RE), and Triplet Extraction.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
pip install transformers torch
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/huggingface_demo.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add project root to path (for running from this dir)
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor, Entity
|
||||||
|
|
||||||
|
def demo_ner():
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("NER Demo: Bring Your Own Model (BYOM)")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 1. Initialize NERExtractor with HuggingFace method and a specific model
|
||||||
|
# Common models: "dslim/bert-base-NER", "dbmdz/bert-large-cased-finetuned-conll03-english"
|
||||||
|
model_name = "dslim/bert-base-NER"
|
||||||
|
print(f"Initializing NERExtractor with model: {model_name}...")
|
||||||
|
|
||||||
|
extractor = NERExtractor(
|
||||||
|
method="huggingface",
|
||||||
|
model=model_name,
|
||||||
|
device="cpu" # Use "cuda" for GPU
|
||||||
|
)
|
||||||
|
|
||||||
|
text = "Steve Jobs founded Apple Inc. in Cupertino, California on April 1, 1976."
|
||||||
|
print(f"\nInput text: {text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Note: This will download the model if not cached (approx 400MB)
|
||||||
|
print("Extracting entities (this may take a moment on first run)...")
|
||||||
|
entities = extractor.extract_entities(text)
|
||||||
|
|
||||||
|
print(f"\nExtracted {len(entities)} entities:")
|
||||||
|
for ent in entities:
|
||||||
|
print(f" - {ent.text:20} | Type: {ent.label:10} | Conf: {ent.confidence:.2f}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Extraction failed (missing dependencies?): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_relation():
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Relation Extraction Demo: Local Model")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 1. Initialize RelationExtractor
|
||||||
|
# Note: Relation extraction usually requires a SequenceClassification model
|
||||||
|
# trained on relation datasets (e.g., TACRED, SemEval).
|
||||||
|
# For demo purposes, we'll use a generic placeholder or a widely used one.
|
||||||
|
model_name = "semantica/relation-model-v1" # This is hypothetical; replace with real model
|
||||||
|
print(f"Initializing RelationExtractor with method='huggingface'...")
|
||||||
|
|
||||||
|
extractor = RelationExtractor(
|
||||||
|
method="huggingface",
|
||||||
|
model=model_name,
|
||||||
|
device="cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
text = "Steve Jobs founded Apple Inc."
|
||||||
|
# Pre-defined entities are usually required for relation extraction
|
||||||
|
entities = [
|
||||||
|
Entity(text="Steve Jobs", label="PERSON", start_char=0, end_char=10),
|
||||||
|
Entity(text="Apple Inc.", label="ORG", start_char=19, end_char=29)
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"\nInput text: {text}")
|
||||||
|
print(f"Entities: {[e.text for e in entities]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Extracting relations...")
|
||||||
|
# Note: This will fail if the model doesn't exist on HF Hub.
|
||||||
|
# In a real scenario, use a valid model ID like "some-user/bert-relation-extraction"
|
||||||
|
# For this demo, we just show the call structure.
|
||||||
|
relations = extractor.extract_relations(text, entities)
|
||||||
|
|
||||||
|
print(f"\nExtracted {len(relations)} relations:")
|
||||||
|
for rel in relations:
|
||||||
|
print(f" - {rel.subject.text} --[{rel.predicate}]--> {rel.object.text} (Conf: {rel.confidence:.2f})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Note: Relation extraction mock run (model download might fail or be skipped): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_triplet():
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("Triplet Extraction Demo: REBEL (Seq2Seq)")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 1. Initialize TripletExtractor with REBEL model
|
||||||
|
# REBEL is a popular model for end-to-end triplet extraction
|
||||||
|
model_name = "Babelscape/rebel-large"
|
||||||
|
print(f"Initializing TripletExtractor with model: {model_name}...")
|
||||||
|
|
||||||
|
extractor = TripletExtractor(
|
||||||
|
method="huggingface",
|
||||||
|
model=model_name,
|
||||||
|
device="cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
text = "Apple was founded by Steve Jobs in 1976."
|
||||||
|
print(f"\nInput text: {text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Extracting triplets (this may take a moment)...")
|
||||||
|
triplets = extractor.extract_triplets(text)
|
||||||
|
|
||||||
|
print(f"\nExtracted {len(triplets)} triplets:")
|
||||||
|
for triplet in triplets:
|
||||||
|
print(f" - ({triplet.subject}, {triplet.predicate}, {triplet.object})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Extraction failed (missing dependencies?): {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Starting Semantica HuggingFace Usage Demo...")
|
||||||
|
print("Note: This script attempts to download models from Hugging Face Hub.")
|
||||||
|
print("Ensure you have an internet connection and 'transformers' installed.")
|
||||||
|
|
||||||
|
# Run demos
|
||||||
|
# We wrap in try-except to ensure the script doesn't crash the whole session if one fails
|
||||||
|
try:
|
||||||
|
demo_ner()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"NER Demo Error: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
demo_relation()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Relation Demo Error: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
demo_triplet()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Triplet Demo Error: {e}")
|
||||||
@@ -19,6 +19,7 @@ Relation Extraction:
|
|||||||
- "pattern": Pattern-based relation extraction
|
- "pattern": Pattern-based relation extraction
|
||||||
- "regex": Advanced regex-based relation extraction
|
- "regex": Advanced regex-based relation extraction
|
||||||
- "cooccurrence": Co-occurrence based relation detection
|
- "cooccurrence": Co-occurrence based relation detection
|
||||||
|
- "similarity": Similarity-based relation extraction
|
||||||
- "dependency": Dependency parsing-based relation extraction
|
- "dependency": Dependency parsing-based relation extraction
|
||||||
- "huggingface": HuggingFace relation extraction models
|
- "huggingface": HuggingFace relation extraction models
|
||||||
- "llm": LLM-based relation extraction
|
- "llm": LLM-based relation extraction
|
||||||
@@ -717,24 +718,148 @@ def extract_entities_huggingface(
|
|||||||
device: Optional[str] = None,
|
device: Optional[str] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> List[Entity]:
|
) -> List[Entity]:
|
||||||
"""HuggingFace entity extraction."""
|
"""
|
||||||
|
Extract entities using HuggingFace transformers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text
|
||||||
|
model: Model name or path
|
||||||
|
device: Device to use (cpu, cuda, mps)
|
||||||
|
**kwargs: Additional arguments passed to the pipeline (e.g., aggregation_strategy)
|
||||||
|
"""
|
||||||
loader = HuggingFaceModelLoader(device=device)
|
loader = HuggingFaceModelLoader(device=device)
|
||||||
model_obj = loader.load_ner_model(model)
|
# Pass kwargs (like aggregation_strategy) to load_ner_model
|
||||||
|
model_obj = loader.load_ner_model(model, **kwargs)
|
||||||
results = loader.extract_entities(model_obj, text)
|
results = loader.extract_entities(model_obj, text)
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
|
|
||||||
|
# Check if manual aggregation is needed (raw IOB tags detected)
|
||||||
|
needs_manual_aggregation = False
|
||||||
|
if results and isinstance(results[0], dict):
|
||||||
|
first_label = results[0].get("label", "")
|
||||||
|
# If we see B- tags and no entity_group (which implies aggregation wasn't done), we aggregate manually
|
||||||
|
if (first_label.startswith("B-") or first_label.startswith("I-")) and "entity_group" not in results[0]:
|
||||||
|
needs_manual_aggregation = True
|
||||||
|
|
||||||
|
if needs_manual_aggregation:
|
||||||
|
current_entity = None
|
||||||
|
for result in results:
|
||||||
|
label = result.get("label", "")
|
||||||
|
word = result.get("word", result.get("entity", ""))
|
||||||
|
score = result.get("score", 1.0)
|
||||||
|
start = result.get("start", 0)
|
||||||
|
end = result.get("end", 0)
|
||||||
|
|
||||||
|
# Clean word (handle BERT ## and RoBERTa Ġ)
|
||||||
|
clean_word = word.replace("##", "").replace("Ġ", "")
|
||||||
|
if not clean_word:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine tag type and entity type
|
||||||
|
tag_prefix = label[:2] if len(label) > 2 else ""
|
||||||
|
entity_type = label[2:] if len(label) > 2 else label
|
||||||
|
|
||||||
|
if tag_prefix == "B-":
|
||||||
|
# Save previous entity
|
||||||
|
if current_entity:
|
||||||
|
entities.append(current_entity)
|
||||||
|
|
||||||
|
# Start new entity
|
||||||
|
current_entity = Entity(
|
||||||
|
text=clean_word,
|
||||||
|
label=entity_type,
|
||||||
|
start_char=start,
|
||||||
|
end_char=end,
|
||||||
|
confidence=score,
|
||||||
|
metadata={
|
||||||
|
"model": model,
|
||||||
|
"extraction_method": "huggingface",
|
||||||
|
"source": "huggingface",
|
||||||
|
"raw_iob": True
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
elif tag_prefix == "I-" and current_entity:
|
||||||
|
# Check if type matches (loose check allows for some noise, strict check enforces type)
|
||||||
|
# We'll be lenient and allow continuation if it makes sense contextually,
|
||||||
|
# but ideally types should match.
|
||||||
|
if current_entity.label == entity_type:
|
||||||
|
# Append text
|
||||||
|
# Use offsets to determine spacing
|
||||||
|
if start > current_entity.end_char:
|
||||||
|
# If there's a gap, add space (unless it was a subword that got split but has gap? Unlikely)
|
||||||
|
# Usually gap means space.
|
||||||
|
# However, for ## subwords, start usually equals end.
|
||||||
|
# For Ġ, it implies space.
|
||||||
|
current_entity.text += " " + clean_word
|
||||||
|
else:
|
||||||
|
current_entity.text += clean_word
|
||||||
|
|
||||||
|
current_entity.end_char = end
|
||||||
|
# Update confidence (average)
|
||||||
|
current_entity.confidence = (current_entity.confidence + score) / 2
|
||||||
|
else:
|
||||||
|
# Type mismatch - treat as new entity or ignore?
|
||||||
|
# Treating as new B- is safer to avoid losing data
|
||||||
|
if current_entity:
|
||||||
|
entities.append(current_entity)
|
||||||
|
|
||||||
|
current_entity = Entity(
|
||||||
|
text=clean_word,
|
||||||
|
label=entity_type,
|
||||||
|
start_char=start,
|
||||||
|
end_char=end,
|
||||||
|
confidence=score,
|
||||||
|
metadata={
|
||||||
|
"model": model,
|
||||||
|
"extraction_method": "huggingface",
|
||||||
|
"source": "huggingface",
|
||||||
|
"raw_iob": True
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# O tag or I- without B or other cases
|
||||||
|
if current_entity:
|
||||||
|
entities.append(current_entity)
|
||||||
|
current_entity = None
|
||||||
|
|
||||||
|
# Append last entity
|
||||||
|
if current_entity:
|
||||||
|
entities.append(current_entity)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Standard processing for aggregated results or simple output
|
||||||
for result in results:
|
for result in results:
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
|
# Handle different output formats based on aggregation strategy
|
||||||
|
label = result.get("entity_group", result.get("label", "UNKNOWN"))
|
||||||
|
text_content = result.get("word", result.get("entity", ""))
|
||||||
|
|
||||||
|
# Clean up text content (remove ## for subwords if raw)
|
||||||
|
if "##" in text_content and "aggregation_strategy" not in kwargs:
|
||||||
|
text_content = text_content.replace("##", "")
|
||||||
|
if "Ġ" in text_content: # RoBERTa
|
||||||
|
text_content = text_content.replace("Ġ", " ").strip()
|
||||||
|
|
||||||
entities.append(
|
entities.append(
|
||||||
Entity(
|
Entity(
|
||||||
text=result.get("word", result.get("entity", "")),
|
text=text_content,
|
||||||
label=result.get("entity_group", result.get("label", "UNKNOWN")),
|
label=label,
|
||||||
start_char=result.get("start", 0),
|
start_char=result.get("start", 0),
|
||||||
end_char=result.get("end", 0),
|
end_char=result.get("end", 0),
|
||||||
confidence=result.get("score", 1.0),
|
confidence=result.get("score", 1.0),
|
||||||
metadata={"model": model, "extraction_method": "huggingface"},
|
metadata={
|
||||||
|
"model": model,
|
||||||
|
"extraction_method": "huggingface",
|
||||||
|
"source": "huggingface"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif isinstance(result, list):
|
||||||
|
# Handle list of lists (sometimes returned by pipeline)
|
||||||
|
pass
|
||||||
|
|
||||||
return entities
|
return entities
|
||||||
|
|
||||||
@@ -1494,14 +1619,26 @@ def extract_relations_huggingface(
|
|||||||
) -> List[Relation]:
|
) -> List[Relation]:
|
||||||
"""HuggingFace relation extraction."""
|
"""HuggingFace relation extraction."""
|
||||||
loader = HuggingFaceModelLoader(device=device)
|
loader = HuggingFaceModelLoader(device=device)
|
||||||
model_obj = loader.load_relation_model(model)
|
model_obj = loader.load_relation_model(model, **kwargs)
|
||||||
|
|
||||||
# This is simplified - actual implementation would depend on model architecture
|
# Pass kwargs (e.g. threshold)
|
||||||
results = loader.extract_relations(model_obj, text, entities)
|
results = loader.extract_relations(model_obj, text, entities, **kwargs)
|
||||||
|
|
||||||
relations = []
|
relations = []
|
||||||
# Parse results based on model output format
|
for result in results:
|
||||||
# This is a placeholder - actual parsing would depend on the model
|
relations.append(
|
||||||
|
Relation(
|
||||||
|
subject=result["subject"],
|
||||||
|
predicate=result["relation"],
|
||||||
|
object=result["object"],
|
||||||
|
confidence=result.get("score", 1.0),
|
||||||
|
context=text,
|
||||||
|
metadata={
|
||||||
|
"model": model,
|
||||||
|
"extraction_method": "huggingface"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
return relations
|
return relations
|
||||||
|
|
||||||
|
|
||||||
@@ -1986,16 +2123,46 @@ def extract_triplets_huggingface(
|
|||||||
) -> List[Triplet]:
|
) -> List[Triplet]:
|
||||||
"""HuggingFace triplet extraction."""
|
"""HuggingFace triplet extraction."""
|
||||||
loader = HuggingFaceModelLoader(device=device)
|
loader = HuggingFaceModelLoader(device=device)
|
||||||
model_obj = loader.load_triplet_model(model)
|
model_obj = loader.load_triplet_model(model, **kwargs)
|
||||||
|
|
||||||
|
# REBEL needs special tokens to be preserved
|
||||||
|
if "skip_special_tokens" not in kwargs:
|
||||||
|
kwargs["skip_special_tokens"] = False
|
||||||
|
|
||||||
results = loader.extract_triplets(model_obj, text, **kwargs)
|
results = loader.extract_triplets(model_obj, text, **kwargs)
|
||||||
|
|
||||||
triplets = []
|
triplets = []
|
||||||
for result in results:
|
for result in results:
|
||||||
# Parse result based on model output format
|
|
||||||
# This is a placeholder - actual parsing would depend on the model
|
|
||||||
if "triplet" in result:
|
if "triplet" in result:
|
||||||
# Parse triplet string (format depends on model)
|
decoded_text = result["triplet"]
|
||||||
pass
|
|
||||||
|
# Clean up common special tokens that might interfere or are noise
|
||||||
|
decoded_text = decoded_text.replace("<s>", "").replace("</s>", "").replace("<pad>", "")
|
||||||
|
|
||||||
|
# Parse REBEL format: <triplet> subject <subj> predicate <obj> object
|
||||||
|
# We use a non-greedy match and lookahead for next triplet or end of string
|
||||||
|
import re
|
||||||
|
pattern = r"<triplet>(?P<head>.*?)<subj>(?P<relation>.*?)<obj>(?P<tail>.*?)(?=<triplet>|$)"
|
||||||
|
|
||||||
|
matches = re.finditer(pattern, decoded_text)
|
||||||
|
for match in matches:
|
||||||
|
head = match.group("head").strip()
|
||||||
|
relation = match.group("relation").strip()
|
||||||
|
tail = match.group("tail").strip()
|
||||||
|
|
||||||
|
if head and relation and tail:
|
||||||
|
triplets.append(
|
||||||
|
Triplet(
|
||||||
|
subject=head,
|
||||||
|
predicate=relation,
|
||||||
|
object=tail,
|
||||||
|
confidence=0.9, # Model generation doesn't provide per-triplet confidence
|
||||||
|
metadata={
|
||||||
|
"model": model,
|
||||||
|
"extraction_method": "huggingface_rebel"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return triplets
|
return triplets
|
||||||
|
|
||||||
|
|||||||
@@ -364,8 +364,11 @@ class NERExtractor:
|
|||||||
# Prepare method-specific options
|
# Prepare method-specific options
|
||||||
method_options = all_options.copy()
|
method_options = all_options.copy()
|
||||||
if method_name == "huggingface":
|
if method_name == "huggingface":
|
||||||
method_options["model"] = all_options.get(
|
# Prioritize runtime options over config/defaults
|
||||||
"huggingface_model", self.huggingface_model
|
method_options["model"] = (
|
||||||
|
options.get("huggingface_model")
|
||||||
|
or options.get("model")
|
||||||
|
or self.huggingface_model
|
||||||
)
|
)
|
||||||
method_options["device"] = all_options.get("device")
|
method_options["device"] = all_options.get("device")
|
||||||
elif method_name == "llm":
|
elif method_name == "llm":
|
||||||
|
|||||||
@@ -1186,25 +1186,32 @@ class HuggingFaceModelLoader:
|
|||||||
# Import torch at method level to ensure it's available
|
# Import torch at method level to ensure it's available
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
cache_key = f"{model_name}_ner"
|
# Include aggregation_strategy in cache key
|
||||||
|
agg_strategy = kwargs.get("aggregation_strategy", "simple")
|
||||||
|
cache_key = f"{model_name}_ner_{agg_strategy}"
|
||||||
if cache_key in self._cache:
|
if cache_key in self._cache:
|
||||||
return self._cache[cache_key]
|
return self._cache[cache_key]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from transformers import pipeline
|
from transformers import pipeline
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"transformers library not installed. Install with: pip install semantica[models-huggingface]"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
nlp = pipeline(
|
nlp = pipeline(
|
||||||
"ner",
|
"ner",
|
||||||
model=model_name,
|
model=model_name,
|
||||||
device=self.device if torch.cuda.is_available() else -1,
|
device=self.device if torch.cuda.is_available() else -1,
|
||||||
aggregation_strategy="simple",
|
aggregation_strategy=agg_strategy,
|
||||||
|
tokenizer=kwargs.get("tokenizer") # Allow custom tokenizer
|
||||||
)
|
)
|
||||||
self._cache[cache_key] = nlp
|
self._cache[cache_key] = nlp
|
||||||
return nlp
|
return nlp
|
||||||
except (ImportError, OSError):
|
except OSError as e:
|
||||||
raise ImportError(
|
self.logger.error(f"Failed to load NER model '{model_name}': {e}")
|
||||||
"transformers library not installed. Install with: pip install semantica[models-huggingface]"
|
raise ValueError(f"Could not load HuggingFace model '{model_name}'. Check if model name is correct. Error: {e}")
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to load NER model {model_name}: {e}")
|
self.logger.error(f"Failed to load NER model {model_name}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -1219,19 +1226,31 @@ class HuggingFaceModelLoader:
|
|||||||
return self._cache[cache_key]
|
return self._cache[cache_key]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from transformers import pipeline
|
from transformers import pipeline, AutoTokenizer
|
||||||
|
except ImportError:
|
||||||
nlp = pipeline(
|
|
||||||
"text-classification",
|
|
||||||
model=model_name,
|
|
||||||
device=self.device if torch.cuda.is_available() else -1,
|
|
||||||
)
|
|
||||||
self._cache[cache_key] = nlp
|
|
||||||
return nlp
|
|
||||||
except (ImportError, OSError):
|
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"transformers library not installed. Install with: pip install semantica[models-huggingface]"
|
"transformers library not installed. Install with: pip install semantica[models-huggingface]"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Allow custom tokenizer
|
||||||
|
tokenizer = kwargs.get("tokenizer")
|
||||||
|
if not tokenizer and kwargs.get("tokenizer_name"):
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(kwargs.get("tokenizer_name"))
|
||||||
|
|
||||||
|
pipeline_kwargs = {
|
||||||
|
"model": model_name,
|
||||||
|
"device": self.device if torch.cuda.is_available() else -1,
|
||||||
|
}
|
||||||
|
if tokenizer:
|
||||||
|
pipeline_kwargs["tokenizer"] = tokenizer
|
||||||
|
|
||||||
|
nlp = pipeline("text-classification", **pipeline_kwargs)
|
||||||
|
self._cache[cache_key] = nlp
|
||||||
|
return nlp
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.error(f"Failed to load relation model '{model_name}': {e}")
|
||||||
|
raise ValueError(f"Could not load HuggingFace model '{model_name}'. Check if model name is correct. Error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to load relation model {model_name}: {e}")
|
self.logger.error(f"Failed to load relation model {model_name}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -1244,18 +1263,27 @@ class HuggingFaceModelLoader:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, pipeline
|
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, pipeline
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"transformers library not installed. Install with: pip install semantica[models-huggingface]"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Allow custom tokenizer
|
||||||
|
tokenizer = kwargs.get("tokenizer")
|
||||||
|
if not tokenizer:
|
||||||
|
tokenizer_name = kwargs.get("tokenizer_name", model_name)
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
|
||||||
|
|
||||||
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
|
||||||
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
|
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
|
||||||
model.to(self.device)
|
model.to(self.device)
|
||||||
|
|
||||||
nlp = {"tokenizer": tokenizer, "model": model, "device": self.device}
|
nlp = {"tokenizer": tokenizer, "model": model, "device": self.device}
|
||||||
self._cache[cache_key] = nlp
|
self._cache[cache_key] = nlp
|
||||||
return nlp
|
return nlp
|
||||||
except (ImportError, OSError):
|
except OSError as e:
|
||||||
raise ImportError(
|
self.logger.error(f"Failed to load triplet model '{model_name}': {e}")
|
||||||
"transformers library not installed. Install with: pip install semantica[models-huggingface]"
|
raise ValueError(f"Could not load HuggingFace model '{model_name}'. Check if model name is correct. Error: {e}")
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to load triplet model {model_name}: {e}")
|
self.logger.error(f"Failed to load triplet model {model_name}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -1264,10 +1292,84 @@ class HuggingFaceModelLoader:
|
|||||||
"""Extract entities using loaded model."""
|
"""Extract entities using loaded model."""
|
||||||
return model(text)
|
return model(text)
|
||||||
|
|
||||||
def extract_relations(self, model, text: str, entities: List) -> List[Dict]:
|
def extract_relations(self, model, text: str, entities: List, **kwargs) -> List[Dict]:
|
||||||
"""Extract relations using loaded model."""
|
"""
|
||||||
# This would need to be customized based on the model architecture
|
Extract relations using loaded model.
|
||||||
return model(text)
|
Iterates through entity pairs and classifies the relationship.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Sort entities by position
|
||||||
|
sorted_entities = sorted(entities, key=lambda e: e.start_char)
|
||||||
|
|
||||||
|
# Marker configuration
|
||||||
|
subj_start = kwargs.get("subj_start_marker", "<subj>")
|
||||||
|
subj_end = kwargs.get("subj_end_marker", "</subj>")
|
||||||
|
obj_start = kwargs.get("obj_start_marker", "<obj>")
|
||||||
|
obj_end = kwargs.get("obj_end_marker", "</obj>")
|
||||||
|
|
||||||
|
# Iterate through all pairs
|
||||||
|
import itertools
|
||||||
|
for i, e1 in enumerate(sorted_entities):
|
||||||
|
for e2 in sorted_entities:
|
||||||
|
if e1 == e2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check distance (optional optimization)
|
||||||
|
# if abs(e1.start_char - e2.start_char) > 200: continue
|
||||||
|
|
||||||
|
# Format text with markers
|
||||||
|
# Strategy: [CLS] text with <subj>...</subj> and <obj>...</obj> [SEP]
|
||||||
|
# We need to insert markers into the original text
|
||||||
|
|
||||||
|
# Create a copy of text with markers inserted
|
||||||
|
# We need to handle offsets correctly.
|
||||||
|
# Simplest way: reconstruct string pieces
|
||||||
|
|
||||||
|
p1_start, p1_end = e1.start_char, e1.end_char
|
||||||
|
p2_start, p2_end = e2.start_char, e2.end_char
|
||||||
|
|
||||||
|
if p1_start < p2_start:
|
||||||
|
formatted_text = (
|
||||||
|
text[:p1_start] +
|
||||||
|
f"{subj_start} " + text[p1_start:p1_end] + f" {subj_end}" +
|
||||||
|
text[p1_end:p2_start] +
|
||||||
|
f"{obj_start} " + text[p2_start:p2_end] + f" {obj_end}" +
|
||||||
|
text[p2_end:]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
formatted_text = (
|
||||||
|
text[:p2_start] +
|
||||||
|
f"{obj_start} " + text[p2_start:p2_end] + f" {obj_end}" +
|
||||||
|
text[p2_end:p1_start] +
|
||||||
|
f"{subj_start} " + text[p1_start:p1_end] + f" {subj_end}" +
|
||||||
|
text[p1_end:]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Predict
|
||||||
|
try:
|
||||||
|
# Pipeline returns [{'label': 'LABEL', 'score': 0.99}]
|
||||||
|
prediction = model(formatted_text, top_k=1)
|
||||||
|
|
||||||
|
if prediction:
|
||||||
|
res = prediction[0] if isinstance(prediction, list) else prediction
|
||||||
|
if isinstance(res, list): res = res[0] # top_k=1 returns list of dicts
|
||||||
|
|
||||||
|
label = res.get("label")
|
||||||
|
score = res.get("score")
|
||||||
|
|
||||||
|
# Filter "no_relation" or low confidence
|
||||||
|
if label != "no_relation" and score > kwargs.get("threshold", 0.5):
|
||||||
|
results.append({
|
||||||
|
"subject": e1,
|
||||||
|
"object": e2,
|
||||||
|
"relation": label,
|
||||||
|
"score": score
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Relation prediction failed for pair {e1.text}-{e2.text}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
def extract_triplets(self, model, text: str, **kwargs) -> List[Dict]:
|
def extract_triplets(self, model, text: str, **kwargs) -> List[Dict]:
|
||||||
"""Extract triplets using loaded model."""
|
"""Extract triplets using loaded model."""
|
||||||
@@ -1279,15 +1381,13 @@ class HuggingFaceModelLoader:
|
|||||||
max_input_length = kwargs.get("max_input_length", 512)
|
max_input_length = kwargs.get("max_input_length", 512)
|
||||||
max_length = kwargs.get("max_length", 128)
|
max_length = kwargs.get("max_length", 128)
|
||||||
|
|
||||||
# Allow max_new_tokens as well
|
|
||||||
generate_kwargs = {"max_length": max_length}
|
generate_kwargs = {"max_length": max_length}
|
||||||
if "max_new_tokens" in kwargs:
|
if "max_new_tokens" in kwargs:
|
||||||
generate_kwargs["max_new_tokens"] = kwargs["max_new_tokens"]
|
generate_kwargs["max_new_tokens"] = kwargs["max_new_tokens"]
|
||||||
# If max_new_tokens is set, we might want to remove max_length or ensure they don't conflict
|
|
||||||
# For Seq2Seq, max_length usually refers to the total length of the target sequence
|
|
||||||
|
|
||||||
# Pass other generation args
|
# Pass other generation args including beams and penalties
|
||||||
for param in ["num_beams", "temperature", "top_p", "top_k", "do_sample"]:
|
for param in ["num_beams", "temperature", "top_p", "top_k", "do_sample",
|
||||||
|
"length_penalty", "repetition_penalty"]:
|
||||||
if param in kwargs:
|
if param in kwargs:
|
||||||
generate_kwargs[param] = kwargs[param]
|
generate_kwargs[param] = kwargs[param]
|
||||||
|
|
||||||
@@ -1296,10 +1396,10 @@ class HuggingFaceModelLoader:
|
|||||||
).to(device)
|
).to(device)
|
||||||
|
|
||||||
outputs = model_obj.generate(**inputs, **generate_kwargs)
|
outputs = model_obj.generate(**inputs, **generate_kwargs)
|
||||||
decoded = tokenizer.decode(outputs[0], skip_special_tokens=True)
|
# Allow controlling skip_special_tokens (important for REBEL which uses special tokens for delimiters)
|
||||||
|
skip_special_tokens = kwargs.get("skip_special_tokens", True)
|
||||||
|
decoded = tokenizer.decode(outputs[0], skip_special_tokens=skip_special_tokens)
|
||||||
|
|
||||||
# Parse decoded output (format depends on model)
|
|
||||||
# This is a placeholder - actual parsing would depend on model output format
|
|
||||||
return [{"triplet": decoded}]
|
return [{"triplet": decoded}]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -408,8 +408,12 @@ class RelationExtractor:
|
|||||||
method_options["relation_types"] = relation_types
|
method_options["relation_types"] = relation_types
|
||||||
|
|
||||||
if method_name == "huggingface":
|
if method_name == "huggingface":
|
||||||
method_options["model"] = all_options.get(
|
# Prioritize runtime options over config/defaults
|
||||||
"huggingface_model", all_options.get("model")
|
method_options["model"] = (
|
||||||
|
options.get("huggingface_model")
|
||||||
|
or options.get("model")
|
||||||
|
or self.config.get("huggingface_model")
|
||||||
|
or self.config.get("model")
|
||||||
)
|
)
|
||||||
method_options["device"] = all_options.get("device")
|
method_options["device"] = all_options.get("device")
|
||||||
elif method_name == "llm":
|
elif method_name == "llm":
|
||||||
|
|||||||
@@ -113,9 +113,17 @@ extractor = NERExtractor(method="ml")
|
|||||||
entities = extractor.extract(text)
|
entities = extractor.extract(text)
|
||||||
print(f"ML method: {len(entities)} entities")
|
print(f"ML method: {len(entities)} entities")
|
||||||
|
|
||||||
# HuggingFace model extraction
|
# HuggingFace model extraction (Bring Your Own Model)
|
||||||
extractor = NERExtractor(method="huggingface")
|
extractor = NERExtractor(method="huggingface")
|
||||||
entities = extractor.extract(text, model="dslim/bert-base-NER")
|
|
||||||
|
# Use a specific model and aggregation strategy at runtime
|
||||||
|
# Runtime options override configuration defaults
|
||||||
|
entities = extractor.extract(
|
||||||
|
text,
|
||||||
|
model="dslim/bert-base-NER",
|
||||||
|
aggregation_strategy="max", # Options: "simple", "first", "average", "max"
|
||||||
|
device="cpu" # or "cuda"
|
||||||
|
)
|
||||||
print(f"HuggingFace method: {len(entities)} entities")
|
print(f"HuggingFace method: {len(entities)} entities")
|
||||||
|
|
||||||
# LLM-based extraction with advanced options
|
# LLM-based extraction with advanced options
|
||||||
@@ -229,9 +237,18 @@ relations = extractor.extract(text, entities=entities)
|
|||||||
extractor = RelationExtractor(method="cooccurrence")
|
extractor = RelationExtractor(method="cooccurrence")
|
||||||
relations = extractor.extract(text, entities=entities)
|
relations = extractor.extract(text, entities=entities)
|
||||||
|
|
||||||
# HuggingFace model
|
# HuggingFace model (Bring Your Own Model)
|
||||||
extractor = RelationExtractor(method="huggingface")
|
extractor = RelationExtractor(method="huggingface")
|
||||||
relations = extractor.extract(text, entities=entities, model="microsoft/DialoGPT-medium")
|
|
||||||
|
# Use a sequence classification model trained for relations
|
||||||
|
# The extractor automatically formats input with entity markers:
|
||||||
|
# "Steve Jobs founded Apple" -> "<subj> Steve Jobs </subj> founded <obj> Apple </obj>"
|
||||||
|
relations = extractor.extract(
|
||||||
|
text,
|
||||||
|
entities=entities,
|
||||||
|
model="semantica/relation-model-v1", # Replace with your model ID
|
||||||
|
device="cpu"
|
||||||
|
)
|
||||||
|
|
||||||
# LLM-based relation extraction
|
# LLM-based relation extraction
|
||||||
extractor = RelationExtractor(method="llm")
|
extractor = RelationExtractor(method="llm")
|
||||||
@@ -294,9 +311,16 @@ triplets = extractor.extract_triplets(text)
|
|||||||
extractor = TripletExtractor(method="rules")
|
extractor = TripletExtractor(method="rules")
|
||||||
triplets = extractor.extract_triplets(text)
|
triplets = extractor.extract_triplets(text)
|
||||||
|
|
||||||
# HuggingFace model
|
# HuggingFace model (Seq2Seq / REBEL)
|
||||||
extractor = TripletExtractor(method="huggingface")
|
extractor = TripletExtractor(method="huggingface")
|
||||||
triplets = extractor.extract_triplets(text, model="t5-base")
|
|
||||||
|
# Use a Seq2Seq model like REBEL for end-to-end triplet extraction
|
||||||
|
# This method generates triplets directly from text without needing separate NER/RE steps
|
||||||
|
triplets = extractor.extract_triplets(
|
||||||
|
text,
|
||||||
|
model="Babelscape/rebel-large",
|
||||||
|
device="cpu"
|
||||||
|
)
|
||||||
|
|
||||||
# LLM-based triplet extraction
|
# LLM-based triplet extraction
|
||||||
extractor = TripletExtractor(method="llm")
|
extractor = TripletExtractor(method="llm")
|
||||||
|
|||||||
@@ -366,8 +366,17 @@ class TripletExtractor:
|
|||||||
from .ner_extractor import NERExtractor
|
from .ner_extractor import NERExtractor
|
||||||
from .relation_extractor import RelationExtractor
|
from .relation_extractor import RelationExtractor
|
||||||
|
|
||||||
|
# Use method-based extraction
|
||||||
|
methods = options.get("method", self.method)
|
||||||
|
if isinstance(methods, str):
|
||||||
|
methods = [methods]
|
||||||
|
|
||||||
|
# Determine if we need to extract entities/relations based on method
|
||||||
|
# HuggingFace (Seq2Seq) does not need pre-extracted entities/relations
|
||||||
|
needs_entities_relations = any(m not in ["huggingface"] for m in methods)
|
||||||
|
|
||||||
# Extract entities if not provided
|
# Extract entities if not provided
|
||||||
if entities is None:
|
if entities is None and needs_entities_relations:
|
||||||
self.progress_tracker.update_tracking(
|
self.progress_tracker.update_tracking(
|
||||||
tracking_id, message="Extracting entities..."
|
tracking_id, message="Extracting entities..."
|
||||||
)
|
)
|
||||||
@@ -375,18 +384,23 @@ class TripletExtractor:
|
|||||||
ner_config = self.config.get("ner", {})
|
ner_config = self.config.get("ner", {})
|
||||||
if "ner_method" in self.config:
|
if "ner_method" in self.config:
|
||||||
ner_config = {**ner_config, "method": self.config["ner_method"]}
|
ner_config = {**ner_config, "method": self.config["ner_method"]}
|
||||||
self._ner_extractor = NERExtractor(
|
|
||||||
**ner_config,
|
# Filter out 'model' and 'huggingface_model' from shared config
|
||||||
**{
|
# to prevent passing triplet model to NER extractor
|
||||||
|
shared_config = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in self.config.items()
|
for k, v in self.config.items()
|
||||||
if k not in ["ner", "relation", "validator", "serializer", "quality"]
|
if k not in ["ner", "relation", "validator", "serializer", "quality", "model", "huggingface_model"]
|
||||||
},
|
}
|
||||||
|
|
||||||
|
self._ner_extractor = NERExtractor(
|
||||||
|
**ner_config,
|
||||||
|
**shared_config,
|
||||||
)
|
)
|
||||||
entities = self._ner_extractor.extract_entities(text)
|
entities = self._ner_extractor.extract_entities(text)
|
||||||
|
|
||||||
# Extract relations if not provided
|
# Extract relations if not provided
|
||||||
if relations is None:
|
if relations is None and needs_entities_relations:
|
||||||
self.progress_tracker.update_tracking(
|
self.progress_tracker.update_tracking(
|
||||||
tracking_id, message="Extracting relations..."
|
tracking_id, message="Extracting relations..."
|
||||||
)
|
)
|
||||||
@@ -394,21 +408,20 @@ class TripletExtractor:
|
|||||||
rel_config = self.config.get("relation", {})
|
rel_config = self.config.get("relation", {})
|
||||||
if "relation_method" in self.config:
|
if "relation_method" in self.config:
|
||||||
rel_config = {**rel_config, "method": self.config["relation_method"]}
|
rel_config = {**rel_config, "method": self.config["relation_method"]}
|
||||||
self._relation_extractor = RelationExtractor(
|
|
||||||
**rel_config,
|
# Filter out 'model' and 'huggingface_model' from shared config
|
||||||
**{
|
shared_config = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in self.config.items()
|
for k, v in self.config.items()
|
||||||
if k not in ["ner", "relation", "validator", "serializer", "quality"]
|
if k not in ["ner", "relation", "validator", "serializer", "quality", "model", "huggingface_model"]
|
||||||
},
|
}
|
||||||
|
|
||||||
|
self._relation_extractor = RelationExtractor(
|
||||||
|
**rel_config,
|
||||||
|
**shared_config,
|
||||||
)
|
)
|
||||||
relations = self._relation_extractor.extract_relations(text, entities)
|
relations = self._relation_extractor.extract_relations(text, entities)
|
||||||
|
|
||||||
# Use method-based extraction
|
|
||||||
methods = options.get("method", self.method)
|
|
||||||
if isinstance(methods, str):
|
|
||||||
methods = [methods]
|
|
||||||
|
|
||||||
triplet_types = options.get("triplet_types", self.triplet_types)
|
triplet_types = options.get("triplet_types", self.triplet_types)
|
||||||
|
|
||||||
# Merge config with options
|
# Merge config with options
|
||||||
@@ -450,8 +463,12 @@ class TripletExtractor:
|
|||||||
method_options["triplet_types"] = triplet_types
|
method_options["triplet_types"] = triplet_types
|
||||||
|
|
||||||
if method_name == "huggingface":
|
if method_name == "huggingface":
|
||||||
method_options["model"] = all_options.get(
|
# Prioritize runtime options over config/defaults
|
||||||
"huggingface_model", all_options.get("model")
|
method_options["model"] = (
|
||||||
|
options.get("huggingface_model")
|
||||||
|
or options.get("model")
|
||||||
|
or self.config.get("huggingface_model")
|
||||||
|
or self.config.get("model")
|
||||||
)
|
)
|
||||||
method_options["device"] = all_options.get("device")
|
method_options["device"] = all_options.get("device")
|
||||||
elif method_name == "llm":
|
elif method_name == "llm":
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
# Mock dependencies to avoid import hangs and external calls
|
||||||
|
sys.modules['spacy'] = MagicMock()
|
||||||
|
sys.modules['semantica.semantic_extract.methods'] = MagicMock()
|
||||||
|
sys.modules['semantica.utils.logging'] = MagicMock()
|
||||||
|
sys.modules['semantica.utils.progress_tracker'] = MagicMock()
|
||||||
|
sys.modules['semantica.semantic_extract.providers'] = MagicMock()
|
||||||
|
|
||||||
|
# Mock get_logger and get_progress_tracker
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
sys.modules['semantica.utils.logging'].get_logger.return_value = mock_logger
|
||||||
|
|
||||||
|
mock_tracker = MagicMock()
|
||||||
|
sys.modules['semantica.utils.progress_tracker'].get_progress_tracker.return_value = mock_tracker
|
||||||
|
|
||||||
|
# Mock the methods module functions specifically
|
||||||
|
mock_methods = sys.modules['semantica.semantic_extract.methods']
|
||||||
|
mock_methods.get_entity_method = MagicMock()
|
||||||
|
mock_methods.get_relation_method = MagicMock()
|
||||||
|
mock_methods.get_triplet_method = MagicMock()
|
||||||
|
|
||||||
|
# Mock specific extraction functions
|
||||||
|
mock_extract_entities_hf = MagicMock()
|
||||||
|
mock_extract_relations_hf = MagicMock()
|
||||||
|
mock_extract_triplets_hf = MagicMock()
|
||||||
|
|
||||||
|
# Setup the registry mocks to return our mock functions
|
||||||
|
mock_methods.get_entity_method.return_value = mock_extract_entities_hf
|
||||||
|
mock_methods.get_relation_method.return_value = mock_extract_relations_hf
|
||||||
|
mock_methods.get_triplet_method.return_value = mock_extract_triplets_hf
|
||||||
|
|
||||||
|
# Now import the classes under test
|
||||||
|
# We need to patch where they import 'methods' locally if they do
|
||||||
|
with patch.dict(sys.modules):
|
||||||
|
from semantica.semantic_extract.ner_extractor import NERExtractor
|
||||||
|
from semantica.semantic_extract.relation_extractor import RelationExtractor
|
||||||
|
from semantica.semantic_extract.triplet_extractor import TripletExtractor
|
||||||
|
from semantica.semantic_extract.ner_extractor import Entity
|
||||||
|
from semantica.semantic_extract.relation_extractor import Relation
|
||||||
|
|
||||||
|
class TestExtractorsDispatch(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.mock_extract_entities_hf = mock_extract_entities_hf
|
||||||
|
self.mock_extract_relations_hf = mock_extract_relations_hf
|
||||||
|
self.mock_extract_triplets_hf = mock_extract_triplets_hf
|
||||||
|
|
||||||
|
self.mock_extract_entities_hf.reset_mock()
|
||||||
|
self.mock_extract_relations_hf.reset_mock()
|
||||||
|
self.mock_extract_triplets_hf.reset_mock()
|
||||||
|
|
||||||
|
# Configure mocks to return something iterable/valid
|
||||||
|
self.mock_extract_entities_hf.return_value = [MagicMock(spec=Entity, confidence=0.9, text="Test Entity")]
|
||||||
|
self.mock_extract_relations_hf.return_value = [MagicMock(spec=Relation, confidence=0.9)]
|
||||||
|
self.mock_extract_triplets_hf.return_value = [MagicMock(confidence=0.9)]
|
||||||
|
|
||||||
|
def test_ner_extractor_huggingface_dispatch(self):
|
||||||
|
print("\nTesting NERExtractor dispatch to HuggingFace...")
|
||||||
|
# Initialize with HuggingFace method
|
||||||
|
extractor = NERExtractor(method="huggingface")
|
||||||
|
|
||||||
|
# Call extract_entities
|
||||||
|
text = "Steve Jobs founded Apple."
|
||||||
|
# Use a specific model via kwargs
|
||||||
|
extractor.extract_entities(text, model="my-custom-ner-model")
|
||||||
|
|
||||||
|
# Verify get_entity_method was called with "huggingface"
|
||||||
|
mock_methods.get_entity_method.assert_called_with("huggingface")
|
||||||
|
|
||||||
|
# Verify the extraction function was called with correct model
|
||||||
|
# We need to check the call args to see if 'model' was passed correctly
|
||||||
|
# The logic we implemented: method_options["model"] = all_options.get("huggingface_model") or all_options.get("model") or self.huggingface_model
|
||||||
|
|
||||||
|
call_args = self.mock_extract_entities_hf.call_args
|
||||||
|
self.assertIsNotNone(call_args, "extract_entities_huggingface should have been called")
|
||||||
|
|
||||||
|
_, kwargs = call_args
|
||||||
|
self.assertEqual(kwargs.get("model"), "my-custom-ner-model", "Should use model passed in kwargs")
|
||||||
|
|
||||||
|
print("NERExtractor dispatch verified.")
|
||||||
|
|
||||||
|
def test_relation_extractor_huggingface_dispatch(self):
|
||||||
|
print("\nTesting RelationExtractor dispatch to HuggingFace...")
|
||||||
|
extractor = RelationExtractor(method="huggingface")
|
||||||
|
|
||||||
|
text = "Steve Jobs founded Apple."
|
||||||
|
entities = [MagicMock(spec=Entity)]
|
||||||
|
|
||||||
|
# Call extract_relations with explicit model
|
||||||
|
extractor.extract_relations(text, entities, model="my-relation-model")
|
||||||
|
|
||||||
|
# Verify dispatch
|
||||||
|
mock_methods.get_relation_method.assert_called_with("huggingface")
|
||||||
|
|
||||||
|
call_args = self.mock_extract_relations_hf.call_args
|
||||||
|
self.assertIsNotNone(call_args, "extract_relations_huggingface should have been called")
|
||||||
|
|
||||||
|
_, kwargs = call_args
|
||||||
|
self.assertEqual(kwargs.get("model"), "my-relation-model", "Should use model passed in kwargs")
|
||||||
|
|
||||||
|
print("RelationExtractor dispatch verified.")
|
||||||
|
|
||||||
|
def test_triplet_extractor_huggingface_dispatch(self):
|
||||||
|
print("\nTesting TripletExtractor dispatch to HuggingFace...")
|
||||||
|
extractor = TripletExtractor(method="huggingface")
|
||||||
|
|
||||||
|
text = "Steve Jobs founded Apple."
|
||||||
|
|
||||||
|
# Call extract_triplets with explicit model
|
||||||
|
extractor.extract_triplets(text, model="my-triplet-model")
|
||||||
|
|
||||||
|
# Verify dispatch
|
||||||
|
mock_methods.get_triplet_method.assert_called_with("huggingface")
|
||||||
|
|
||||||
|
call_args = self.mock_extract_triplets_hf.call_args
|
||||||
|
self.assertIsNotNone(call_args, "extract_triplets_huggingface should have been called")
|
||||||
|
|
||||||
|
_, kwargs = call_args
|
||||||
|
self.assertEqual(kwargs.get("model"), "my-triplet-model", "Should use model passed in kwargs")
|
||||||
|
|
||||||
|
print("TripletExtractor dispatch verified.")
|
||||||
|
|
||||||
|
def test_ner_extractor_huggingface_fallback(self):
|
||||||
|
print("\nTesting NERExtractor fallback logic...")
|
||||||
|
# Init with huggingface_model in config
|
||||||
|
extractor = NERExtractor(method="huggingface", huggingface_model="config-model")
|
||||||
|
|
||||||
|
extractor.extract_entities("text")
|
||||||
|
|
||||||
|
_, kwargs = self.mock_extract_entities_hf.call_args
|
||||||
|
self.assertEqual(kwargs.get("model"), "config-model", "Should prioritize huggingface_model from config")
|
||||||
|
|
||||||
|
# Now override with kwargs model
|
||||||
|
extractor.extract_entities("text", model="kwargs-model")
|
||||||
|
_, kwargs = self.mock_extract_entities_hf.call_args
|
||||||
|
self.assertEqual(kwargs.get("model"), "kwargs-model", "Should allow overriding config huggingface_model via model kwarg")
|
||||||
|
|
||||||
|
# Let's test passing 'huggingface_model' in kwargs
|
||||||
|
extractor.extract_entities("text", huggingface_model="override-model")
|
||||||
|
_, kwargs = self.mock_extract_entities_hf.call_args
|
||||||
|
self.assertEqual(kwargs.get("model"), "override-model", "Should allow overriding huggingface_model via kwargs")
|
||||||
|
|
||||||
|
def test_triplet_extractor_lazy_loading(self):
|
||||||
|
print("\nTesting TripletExtractor lazy loading for HuggingFace...")
|
||||||
|
# Initialize with HuggingFace method
|
||||||
|
extractor = TripletExtractor(method="huggingface")
|
||||||
|
|
||||||
|
# Check initial state
|
||||||
|
self.assertIsNone(extractor._ner_extractor)
|
||||||
|
self.assertIsNone(extractor._relation_extractor)
|
||||||
|
|
||||||
|
# Run extraction
|
||||||
|
extractor.extract_triplets("Steve Jobs founded Apple.")
|
||||||
|
|
||||||
|
# Check state AFTER extraction - should STILL be None because huggingface (REBEL) doesn't need them
|
||||||
|
self.assertIsNone(extractor._ner_extractor, "NERExtractor should not be initialized for HuggingFace method")
|
||||||
|
self.assertIsNone(extractor._relation_extractor, "RelationExtractor should not be initialized for HuggingFace method")
|
||||||
|
|
||||||
|
print("TripletExtractor lazy loading verified.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
print("Starting test script...", flush=True)
|
||||||
|
|
||||||
|
# Mock transformers and torch BEFORE any project imports
|
||||||
|
try:
|
||||||
|
mock_transformers = MagicMock()
|
||||||
|
mock_pipeline = MagicMock()
|
||||||
|
mock_transformers.pipeline = mock_pipeline
|
||||||
|
sys.modules["transformers"] = mock_transformers
|
||||||
|
sys.modules["torch"] = MagicMock()
|
||||||
|
sys.modules["torch"].cuda.is_available.return_value = False
|
||||||
|
|
||||||
|
# Mock spacy
|
||||||
|
mock_spacy = MagicMock()
|
||||||
|
sys.modules["spacy"] = mock_spacy
|
||||||
|
|
||||||
|
# Mock instructor
|
||||||
|
sys.modules["instructor"] = MagicMock()
|
||||||
|
|
||||||
|
# Also mock semantica.semantic_extract.config to avoid initialization issues
|
||||||
|
mock_config_module = MagicMock()
|
||||||
|
mock_config_instance = MagicMock()
|
||||||
|
# Setup default return values for config
|
||||||
|
mock_config_instance.get.return_value = {}
|
||||||
|
mock_config_instance.get_optimization_config.return_value = {"enable_cache": False}
|
||||||
|
|
||||||
|
mock_config_module.config = mock_config_instance
|
||||||
|
mock_config_module.Config = MagicMock(return_value=mock_config_instance)
|
||||||
|
sys.modules["semantica.semantic_extract.config"] = mock_config_module
|
||||||
|
|
||||||
|
print("Mocks setup complete.", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error setting up mocks: {e}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Add project root
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
print(f"Added to path: {sys.path[0]}", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Importing methods...", flush=True)
|
||||||
|
from semantica.semantic_extract.methods import extract_entities_huggingface, extract_relations_huggingface, extract_triplets_huggingface
|
||||||
|
print("Importing Entity class...", flush=True)
|
||||||
|
from semantica.semantic_extract.ner_extractor import Entity
|
||||||
|
print("Imports successful.", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Import failed: {e}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def test_enhanced_impl():
|
||||||
|
print("Testing enhanced implementation...", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Test NER with aggregation strategy
|
||||||
|
print("\n--- Testing NER ---", flush=True)
|
||||||
|
|
||||||
|
# Setup mock pipeline return value
|
||||||
|
mock_ner_pipeline = MagicMock()
|
||||||
|
mock_ner_pipeline.return_value = [
|
||||||
|
{"entity_group": "PERSON", "score": 0.99, "word": "Elon Musk", "start": 0, "end": 9},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Configure pipeline side effect
|
||||||
|
def pipeline_side_effect(task, **kwargs):
|
||||||
|
if task == "ner": return mock_ner_pipeline
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
mock_pipeline.side_effect = pipeline_side_effect
|
||||||
|
|
||||||
|
# Test calling with aggregation_strategy
|
||||||
|
entities = extract_entities_huggingface(
|
||||||
|
"Elon Musk founded SpaceX.",
|
||||||
|
model="dslim/bert-base-NER",
|
||||||
|
aggregation_strategy="max"
|
||||||
|
)
|
||||||
|
print(f"Entities: {entities}", flush=True)
|
||||||
|
|
||||||
|
# Verify aggregation_strategy was passed
|
||||||
|
mock_pipeline.assert_any_call(
|
||||||
|
"ner",
|
||||||
|
model="dslim/bert-base-NER",
|
||||||
|
device=-1,
|
||||||
|
aggregation_strategy="max",
|
||||||
|
tokenizer=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Test Relations with Input Formatting
|
||||||
|
print("\n--- Testing Relations ---", flush=True)
|
||||||
|
e1 = Entity(text="Elon Musk", label="PERSON", start_char=0, end_char=9)
|
||||||
|
e2 = Entity(text="SpaceX", label="ORG", start_char=18, end_char=24)
|
||||||
|
|
||||||
|
mock_rel_pipeline = MagicMock()
|
||||||
|
mock_rel_pipeline.return_value = [{"label": "founded", "score": 0.9}]
|
||||||
|
|
||||||
|
# Update pipeline mock to return rel pipeline
|
||||||
|
def pipeline_side_effect_rel(task, **kwargs):
|
||||||
|
if task == "ner": return mock_ner_pipeline
|
||||||
|
if task == "text-classification": return mock_rel_pipeline
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
mock_pipeline.side_effect = pipeline_side_effect_rel
|
||||||
|
|
||||||
|
relations = extract_relations_huggingface(
|
||||||
|
"Elon Musk founded SpaceX.",
|
||||||
|
entities=[e1, e2],
|
||||||
|
model="some-relation-model"
|
||||||
|
)
|
||||||
|
print(f"Relations: {relations}", flush=True)
|
||||||
|
|
||||||
|
# Verify input formatting
|
||||||
|
# Check if ANY call contained the correct formatting
|
||||||
|
found_match = False
|
||||||
|
for call in mock_rel_pipeline.call_args_list:
|
||||||
|
args, _ = call
|
||||||
|
if "<subj> Elon Musk </subj>" in args[0] and "<obj> SpaceX </obj>" in args[0]:
|
||||||
|
found_match = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found_match:
|
||||||
|
print("Failed to find expected call args in:", flush=True)
|
||||||
|
for call in mock_rel_pipeline.call_args_list:
|
||||||
|
print(f" {call[0]}", flush=True)
|
||||||
|
|
||||||
|
assert found_match, "Did not find relation call with Elon Musk as subject"
|
||||||
|
|
||||||
|
# 3. Test Triplets with REBEL parsing
|
||||||
|
print("\n--- Testing Triplets ---", flush=True)
|
||||||
|
|
||||||
|
# Mock Tokenizer and Model
|
||||||
|
mock_tokenizer_instance = MagicMock()
|
||||||
|
mock_transformers.AutoTokenizer.from_pretrained.return_value = mock_tokenizer_instance
|
||||||
|
mock_tokenizer_instance.encode.return_value = MagicMock()
|
||||||
|
# Mock decode to return REBEL format
|
||||||
|
mock_tokenizer_instance.decode.return_value = "<s><triplet> Elon Musk <subj> founded <obj> SpaceX <triplet> SpaceX <subj> created <obj> Starship</s>"
|
||||||
|
|
||||||
|
mock_model_instance = MagicMock()
|
||||||
|
mock_transformers.AutoModelForSeq2SeqLM.from_pretrained.return_value = mock_model_instance
|
||||||
|
mock_model_instance.generate.return_value = [MagicMock()]
|
||||||
|
|
||||||
|
triplets = extract_triplets_huggingface(
|
||||||
|
"Elon Musk founded SpaceX and created Starship.",
|
||||||
|
model="Babelscape/rebel-large"
|
||||||
|
)
|
||||||
|
print(f"Triplets: {triplets}", flush=True)
|
||||||
|
|
||||||
|
# Verify parsing
|
||||||
|
assert len(triplets) == 2
|
||||||
|
assert triplets[0].subject == "Elon Musk"
|
||||||
|
assert triplets[0].predicate == "founded"
|
||||||
|
assert triplets[0].object == "SpaceX"
|
||||||
|
assert triplets[1].subject == "SpaceX"
|
||||||
|
assert triplets[1].predicate == "created"
|
||||||
|
assert triplets[1].object == "Starship"
|
||||||
|
|
||||||
|
# Verify skip_special_tokens=False was passed
|
||||||
|
mock_tokenizer_instance.decode.assert_called_with(
|
||||||
|
mock_model_instance.generate.return_value[0],
|
||||||
|
skip_special_tokens=False
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during test execution: {e}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
test_enhanced_impl()
|
||||||
|
print("\nAll tests passed!", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nTest failed: {e}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
Reference in New Issue
Block a user