Files
AI/참고/ontocast-main/ontocast/tool/agg/normalizer.py

280 lines
9.0 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
"""Entity normalization for disambiguation.
This module handles the preparation of entities for embedding-based disambiguation.
It creates normalized string representations r(e) that include:
- Normalized form of the entity URI
- Semantic neighbors (types, properties)
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from rdflib import RDF, RDFS, Literal, URIRef
from ontocast.onto.constants import DEFAULT_IRI
from ontocast.onto.rdfgraph import RDFGraph
if TYPE_CHECKING:
from ontocast.tool.agg.uri_builder import EntityRole
@dataclass
class EntityRepresentation:
"""Normalized representation of an entity for embedding.
Attributes:
entity: Original entity URI
normal_form: Normalized string (lowercase, no diacritics, etc.)
types: List of type URIs for this entity
properties: List of property URIs used with this entity
labels: List of labels found for this entity
representation: Combined string representation r(e) for embedding
is_ontology_entity: Whether this entity is from an ontology namespace
role: Detected entity role (class / property / instance)
"""
entity: URIRef
normal_form: str
types: list[URIRef]
properties: list[URIRef]
labels: list[str]
representation: str
is_ontology_entity: bool
role: EntityRole | None = field(default=None)
class EntityNormalizer:
"""Normalizes entities and creates string representations for embedding.
This class is responsible for transforming entity URIs into normalized
string representations that can be embedded and compared.
"""
def __init__(self, facts_iri: str = DEFAULT_IRI):
"""Initialize the entity normalizer.
Args:
facts_iri: Base IRI for fact entities. Entities under this namespace
are facts; all other entities are considered ontology entities.
"""
self.facts_iri = facts_iri.rstrip("/") + "/"
def normalize_string(self, text: str) -> str:
"""Normalize a string: lowercase, remove diacritics, clean special chars.
CamelCase is split so that it yields the same logical tokens as snake_case
(e.g. 'PLRedShift' -> 'pl red shift').
Args:
text: Input string to normalize
Returns:
Normalized string suitable for comparison
Examples:
'PLRedShift' -> 'pl red shift'
'PL_red_shift_value' -> 'pl red shift value'
'Café' -> 'cafe'
"""
# Remove diacritics
text = "".join(
c
for c in unicodedata.normalize("NFD", text)
if unicodedata.category(c) != "Mn"
)
# Insert space before capitals that start a word (followed by lowercase)
# so e.g. PLRedShift -> PL Red Shift -> pl red shift (like snake_case)
text = re.sub(r"(?=[A-Z][a-z])", " ", text)
# Convert to lowercase
text = text.lower()
# Replace underscores and hyphens with spaces
text = text.replace("_", " ").replace("-", " ")
# Collapse multiple spaces and strip
return re.sub(r"\s+", " ", text).strip()
def normalize_uri(self, uri: URIRef) -> str:
"""Extract and normalize the local part of a URI.
Args:
uri: URI to normalize
Returns:
Normalized local name
Examples:
'http://example.org/PLRedShift' -> 'pl red shift'
'http://example.org/PL_red_shift_value' -> 'pl red shift value'
"""
uri_str = str(uri)
# Extract local name from fragment or path
if "#" in uri_str:
local = uri_str.rsplit("#", 1)[-1]
else:
trimmed = uri_str.rstrip("/")
local = trimmed.rsplit("/", 1)[-1] if "/" in trimmed else trimmed
# Handle camelCase before normalization
# Insert spaces before uppercase letters
local = re.sub(r"([a-z])([A-Z])", r"\1 \2", local)
local = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", local)
return self.normalize_string(local)
def is_ontology_entity(self, entity: URIRef) -> bool:
"""Check if an entity belongs to an ontology namespace.
Facts live under ``facts_iri``; everything else is an ontology entity.
Args:
entity: Entity URI to check
Returns:
True if entity is **not** from the facts namespace
"""
return not str(entity).startswith(self.facts_iri)
def extract_entity_context(
self, entity: URIRef, graph: RDFGraph
) -> tuple[list[URIRef], list[URIRef], list[str], bool]:
"""Extract semantic context for an entity from the graph.
Args:
entity: Entity to extract context for
graph: RDF graph containing the entity
Returns:
Tuple of (types, properties, labels, is_predicate).
*is_predicate* is ``True`` when the entity appears in the
predicate position of at least one triple.
"""
types = []
properties = set()
labels = []
is_predicate = False
# Extract information from triples
for s, p, o in graph:
# When entity is subject
if s == entity:
properties.add(p)
# Collect types
if p == RDF.type and isinstance(o, URIRef):
types.append(o)
# Collect labels
if p == RDFS.label and isinstance(o, Literal):
labels.append(str(o))
# When entity is object
elif o == entity:
properties.add(p)
# When entity is used as predicate
if p == entity:
is_predicate = True
return types, list(properties), labels, is_predicate
def create_representation(
self, entity: URIRef, graph: RDFGraph
) -> EntityRepresentation:
"""Create a normalized representation r(e) for an entity.
This combines the normalized form with semantic neighbors to create
a rich representation suitable for embedding. The entity role
(class / property / instance) is detected from the already-extracted
context so no additional graph scan is needed downstream.
Args:
entity: Entity URI
graph: RDF graph containing the entity
Returns:
EntityRepresentation containing r(e) and metadata
"""
from ontocast.tool.agg.uri_builder import detect_role_from_context
# Get normalized form
normal_form = self.normalize_uri(entity)
# Extract semantic context
types, properties, labels, is_predicate = self.extract_entity_context(
entity, graph
)
# Detect role from the already-extracted context (no extra graph scan)
role = detect_role_from_context(types, is_predicate)
# Build representation string r(e)
parts = [normal_form]
# Add labels if available (most informative)
if labels:
parts.extend(
self.normalize_string(label) for label in labels[:3]
) # Max 3 labels
# Add type information (very important semantic signal)
if types:
type_names = [self.normalize_uri(t) for t in types[:3]] # Max 3 types
parts.extend(f"type {tn}" for tn in type_names)
# Add property information (additional semantic signal)
if properties:
# Filter out very common properties
filtered_props = [
p for p in properties if p not in {RDF.type, RDFS.label, RDFS.comment}
]
prop_names = [
self.normalize_uri(p) for p in filtered_props[:5]
] # Max 5 properties
parts.extend(f"has {pn}" for pn in prop_names)
# Combine into representation
representation = " ".join(parts)
# Check if ontology entity
is_ontology = self.is_ontology_entity(entity)
return EntityRepresentation(
entity=entity,
normal_form=normal_form,
types=types,
properties=properties,
labels=labels,
representation=representation,
is_ontology_entity=is_ontology,
role=role,
)
def create_representations_batch(
self, entities: list[URIRef], graphs: dict[URIRef, RDFGraph]
) -> dict[URIRef, EntityRepresentation]:
"""Create representations for multiple entities.
Args:
entities: List of entity URIs
graphs: Mapping from entity to its source graph
Returns:
Dictionary mapping entity URIs to their representations
"""
representations = {}
for entity in entities:
graph = graphs.get(entity)
if graph is not None:
representations[entity] = self.create_representation(entity, graph)
return representations