import abc
import heapq
import typing
import numpy
from ..utils import helpers
from . import cluster
logger = helpers.make_logger(__name__)
class GraphCriterion(abc.ABC):
@property
@abc.abstractmethod
def name(self) -> str:
pass
@abc.abstractmethod
def select(self, root: cluster.Cluster) -> set[cluster.Cluster]:
pass
@staticmethod
def assert_invariant(root: cluster.Cluster, selected: set[cluster.Cluster]):
for c in selected:
if any(c.is_ancestor_of(other) for other in selected):
msg = "A cluster and its ancestor were both selected."
raise ValueError(msg)
indices = {i for c in selected for i in c.indices}
if len(indices) != root.cardinality:
msg = f"There was a mis-match in the number of instances that were selected. The selected clusters have {len(indices)} instance but the root has {root.cardinality}."
raise ValueError(
msg,
)
def __call__(self, root: cluster.Cluster) -> set[cluster.Cluster]:
selected = self.select(root)
self.assert_invariant(root, selected)
return selected
class Layer(GraphCriterion):
def __init__(self, depth: int) -> None:
if depth < -1:
msg = f"expected a '-1' or a non-negative depth. got: {depth}"
raise ValueError(msg)
self.depth = depth
@property
def name(self) -> str:
return f"Layer_{self.depth}"
def select(self, root: cluster.Cluster) -> set[cluster.Cluster]:
if self.depth == -1:
return {c for layer in root.subtree for c in layer if c.is_leaf}
else:
selected = {
c for layer in root.subtree[: self.depth] for c in layer if c.is_leaf
}
selected.update(root.subtree[self.depth])
return selected
class PropertyThreshold(GraphCriterion):
def __init__(
self,
value: typing.Literal["cardinality", "radius", "lfd"],
percentile: float,
mode: typing.Literal["above", "below"],
) -> None:
if 0.0 < percentile < 100.0:
self.percentile: float = percentile
else:
msg = f"percentile must be in the (0, 100) range. Got {percentile:.2f} instead."
raise ValueError(
msg,
)
self.value = value
if mode == "above":
self.qualifies = lambda c, v: getattr(c, self.value) > v
elif mode == "below":
self.qualifies = lambda c, v: getattr(c, self.value) < v
else:
msg = f"mode must be 'above' or 'below'. Got {mode} instead."
raise ValueError(msg)
@property
def name(self) -> str:
return f"PropertyThreshold_{self.value}_{self.name}_{self.percentile:.2f}"
def select(self, root: cluster.Cluster) -> set[cluster.Cluster]:
threshold = float(
numpy.percentile(
[
getattr(c, self.value)
for layer in root.subtree
for c in layer
if c.cardinality > 1
],
self.percentile,
),
)
selected: set[cluster.Cluster] = set()
frontier: set[cluster.Cluster] = {root}
while frontier:
c = frontier.pop()
if (
c.is_leaf or self.qualifies(c, threshold) or (
self.qualifies(c.left_child, threshold)
and self.qualifies(c.right_child, threshold)
)
):
selected.add(c)
else: frontier.update(c.children)
return selected
class MetaMLSelect(GraphCriterion):
def __init__(
self,
scorer: typing.Callable[[numpy.ndarray], float],
name: typing.Optional[str] = None,
min_depth: int = 4,
) -> None:
if min_depth < 1:
msg = "min-depth must be a positive integer."
raise ValueError(msg)
self.__name = scorer.__name__ if name is None else name
self.scorer = lambda c: -scorer(numpy.asarray(c.ratios, dtype=numpy.float32))
self.min_depth = min_depth
@property
def name(self) -> str:
return self.__name
def select(self, root: cluster.Cluster) -> set[cluster.Cluster]:
tree = [c for layer in root.subtree for c in layer]
candidate_clusters = [c for c in tree if c.depth >= self.min_depth]
normalized_scores = list(
map(
float,
helpers.normalize(
numpy.asarray(list(map(self.scorer, candidate_clusters))),
mode="gaussian",
),
),
)
heap = list(zip(normalized_scores, candidate_clusters))
heapq.heapify(heap)
selected = {c for c in tree if c.depth < self.min_depth and c.is_leaf}
selected_indices = {i for c in selected for i in c.indices}
while len(heap) > 0:
_, c = heapq.heappop(heap)
if len(selected_indices.intersection(set(c.indices))) > 0:
continue
else:
selected.add(c)
selected_indices.update(set(c.indices))
return selected
__all__ = [
"GraphCriterion",
"Layer",
"PropertyThreshold",
"MetaMLSelect",
]