conceptflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- conceptflow/__init__.py +40 -0
- conceptflow/_version.py +1 -0
- conceptflow/algorithms/__init__.py +47 -0
- conceptflow/algorithms/_registry.py +58 -0
- conceptflow/algorithms/derivation.py +80 -0
- conceptflow/algorithms/enumeration.py +208 -0
- conceptflow/algorithms/hasse.py +83 -0
- conceptflow/algorithms/implications.py +150 -0
- conceptflow/algorithms/order.py +27 -0
- conceptflow/algorithms/reduction.py +119 -0
- conceptflow/cluster/__init__.py +22 -0
- conceptflow/cluster/concept_lattice.py +136 -0
- conceptflow/core/__init__.py +11 -0
- conceptflow/core/concept.py +56 -0
- conceptflow/core/context.py +241 -0
- conceptflow/core/lattice.py +129 -0
- conceptflow/core/many_valued_context.py +263 -0
- conceptflow/decomposition/__init__.py +23 -0
- conceptflow/decomposition/ordinal_factorization.py +432 -0
- conceptflow/exploration/__init__.py +11 -0
- conceptflow/exploration/builder.py +220 -0
- conceptflow/exploration/view.py +61 -0
- conceptflow/feature_extraction/__init__.py +5 -0
- conceptflow/feature_extraction/concept_encoder.py +172 -0
- conceptflow/io/__init__.py +6 -0
- conceptflow/io/cxt.py +126 -0
- conceptflow/metrics/__init__.py +11 -0
- conceptflow/metrics/rules.py +96 -0
- conceptflow/model_selection/__init__.py +11 -0
- conceptflow/preprocessing/__init__.py +23 -0
- conceptflow/preprocessing/conceptual_scaler.py +241 -0
- conceptflow/preprocessing/scales.py +466 -0
- conceptflow/rules/__init__.py +5 -0
- conceptflow/rules/implication_basis.py +128 -0
- conceptflow/validation/__init__.py +5 -0
- conceptflow/validation/input.py +68 -0
- conceptflow/visualization/__init__.py +39 -0
- conceptflow/visualization/d3_backend.py +405 -0
- conceptflow/visualization/dimflux/LICENSE +395 -0
- conceptflow/visualization/dimflux/README.md +77 -0
- conceptflow/visualization/dimflux/libs/brunt-fork.jar +0 -0
- conceptflow/visualization/dimflux/src/__init__.py +0 -0
- conceptflow/visualization/dimflux/src/dim_flux/additive_realizer.py +213 -0
- conceptflow/visualization/dimflux/src/dim_flux/dim_draw.py +132 -0
- conceptflow/visualization/dimflux/src/dim_flux/lgs.py +149 -0
- conceptflow/visualization/dimflux/src/dim_flux/projection.py +137 -0
- conceptflow/visualization/dimflux/src/dim_flux/realizer.py +97 -0
- conceptflow/visualization/dimflux/src/fca/context.py +130 -0
- conceptflow/visualization/dimflux/src/fca/lattice.py +125 -0
- conceptflow/visualization/dimflux/src/fdp/forces.py +519 -0
- conceptflow/visualization/dimflux/src/utils/variables.py +184 -0
- conceptflow/visualization/dimflux_layout.py +153 -0
- conceptflow/visualization/graph_data.py +210 -0
- conceptflow/visualization/html_figure.py +96 -0
- conceptflow/visualization/lattice_plot.py +92 -0
- conceptflow/visualization/nested.py +1553 -0
- conceptflow-0.1.0.dist-info/METADATA +748 -0
- conceptflow-0.1.0.dist-info/RECORD +61 -0
- conceptflow-0.1.0.dist-info/WHEEL +5 -0
- conceptflow-0.1.0.dist-info/licenses/LICENSE +28 -0
- conceptflow-0.1.0.dist-info/top_level.txt +1 -0
conceptflow/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ConceptFlow.
|
|
3
|
+
|
|
4
|
+
A scikit-learn compatible Formal Concept Analysis framework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from conceptflow._version import __version__
|
|
8
|
+
from conceptflow.core import (
|
|
9
|
+
Concept,
|
|
10
|
+
ConceptLattice,
|
|
11
|
+
FormalContext,
|
|
12
|
+
ManyValuedContext,
|
|
13
|
+
)
|
|
14
|
+
from conceptflow.visualization import (
|
|
15
|
+
GraphData,
|
|
16
|
+
GraphEdge,
|
|
17
|
+
GraphNode,
|
|
18
|
+
lattice_to_graph_data,
|
|
19
|
+
plot_lattice,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from conceptflow.exploration import (
|
|
23
|
+
ExplorationBuilder,
|
|
24
|
+
ExplorationView,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
"Concept",
|
|
30
|
+
"FormalContext",
|
|
31
|
+
"ManyValuedContext",
|
|
32
|
+
"ConceptLattice",
|
|
33
|
+
"GraphData",
|
|
34
|
+
"GraphEdge",
|
|
35
|
+
"GraphNode",
|
|
36
|
+
"lattice_to_graph_data",
|
|
37
|
+
"plot_lattice",
|
|
38
|
+
"ExplorationBuilder",
|
|
39
|
+
"ExplorationView",
|
|
40
|
+
]
|
conceptflow/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from conceptflow.algorithms.derivation import (
|
|
2
|
+
attribute_closure,
|
|
3
|
+
attribute_derivation,
|
|
4
|
+
object_derivation,
|
|
5
|
+
)
|
|
6
|
+
from conceptflow.algorithms.enumeration import (
|
|
7
|
+
enumerate_concepts,
|
|
8
|
+
enumerate_concepts_bruteforce,
|
|
9
|
+
enumerate_concepts_nextclosure,
|
|
10
|
+
enumerate_concepts_closebyone,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from conceptflow.algorithms._registry import (
|
|
14
|
+
SUPPORTED_ENUMERATION_ALGORITHMS,
|
|
15
|
+
normalize_enumeration_algorithm,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from conceptflow.algorithms.hasse import compute_hasse_edges, is_cover
|
|
19
|
+
from conceptflow.algorithms.implications import Implication, compute_canonical_basis
|
|
20
|
+
from conceptflow.algorithms.order import strict_subconcept_of, subconcept_of
|
|
21
|
+
|
|
22
|
+
from conceptflow.algorithms.reduction import (
|
|
23
|
+
ClarificationResult,
|
|
24
|
+
clarified_context,
|
|
25
|
+
clarify_context,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"object_derivation",
|
|
30
|
+
"attribute_derivation",
|
|
31
|
+
"attribute_closure",
|
|
32
|
+
"enumerate_concepts",
|
|
33
|
+
"enumerate_concepts_bruteforce",
|
|
34
|
+
"enumerate_concepts_nextclosure",
|
|
35
|
+
"enumerate_concepts_closebyone",
|
|
36
|
+
"subconcept_of",
|
|
37
|
+
"strict_subconcept_of",
|
|
38
|
+
"is_cover",
|
|
39
|
+
"compute_hasse_edges",
|
|
40
|
+
"Implication",
|
|
41
|
+
"compute_canonical_basis",
|
|
42
|
+
"SUPPORTED_ENUMERATION_ALGORITHMS",
|
|
43
|
+
"normalize_enumeration_algorithm",
|
|
44
|
+
"ClarificationResult",
|
|
45
|
+
"clarified_context",
|
|
46
|
+
"clarify_context",
|
|
47
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Algorithm registry utilities.
|
|
3
|
+
|
|
4
|
+
This module centralizes algorithm name handling so that public APIs can
|
|
5
|
+
support clean canonical names and optional aliases without duplicating
|
|
6
|
+
string logic across the codebase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ENUMERATION_ALGORITHM_ALIASES = {
|
|
13
|
+
"bruteforce": "bruteforce",
|
|
14
|
+
"brute_force": "bruteforce",
|
|
15
|
+
"brute-force": "bruteforce",
|
|
16
|
+
"nextclosure": "nextclosure",
|
|
17
|
+
"next_closure": "nextclosure",
|
|
18
|
+
"next-closure": "nextclosure",
|
|
19
|
+
"closebyone": "closebyone",
|
|
20
|
+
"close_by_one": "closebyone",
|
|
21
|
+
"close-by-one": "closebyone",
|
|
22
|
+
"cbo": "closebyone",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
SUPPORTED_ENUMERATION_ALGORITHMS = frozenset(
|
|
26
|
+
ENUMERATION_ALGORITHM_ALIASES.values()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_enumeration_algorithm(name: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Normalize a concept enumeration algorithm name.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
name:
|
|
37
|
+
User-provided algorithm name.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
str
|
|
42
|
+
Canonical algorithm name.
|
|
43
|
+
|
|
44
|
+
Raises
|
|
45
|
+
------
|
|
46
|
+
ValueError
|
|
47
|
+
If the algorithm is unknown.
|
|
48
|
+
"""
|
|
49
|
+
normalized = name.lower().strip().replace(" ", "_")
|
|
50
|
+
|
|
51
|
+
if normalized not in ENUMERATION_ALGORITHM_ALIASES:
|
|
52
|
+
supported = ", ".join(sorted(SUPPORTED_ENUMERATION_ALGORITHMS))
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f'Unknown concept enumeration algorithm "{name}". '
|
|
55
|
+
f"Supported algorithms are: {supported}."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return ENUMERATION_ALGORITHM_ALIASES[normalized]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Derivation and closure operators for Formal Concept Analysis.
|
|
3
|
+
|
|
4
|
+
This module contains the basic FCA derivation operations.
|
|
5
|
+
|
|
6
|
+
For a formal context (G, M, I):
|
|
7
|
+
|
|
8
|
+
- A' is the set of attributes common to all objects in A.
|
|
9
|
+
- B' is the set of objects having all attributes in B.
|
|
10
|
+
- B'' is the closure of B.
|
|
11
|
+
|
|
12
|
+
The functions in this module operate on FormalContext objects but are kept
|
|
13
|
+
outside the FormalContext class so that algorithms remain reusable and easier
|
|
14
|
+
to optimize later.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from conceptflow.core.context import FormalContext
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def object_derivation(
|
|
27
|
+
context: FormalContext,
|
|
28
|
+
object_indices: Iterable[int],
|
|
29
|
+
) -> frozenset[int]:
|
|
30
|
+
"""
|
|
31
|
+
Compute A' for a set of object indices A.
|
|
32
|
+
"""
|
|
33
|
+
object_indices = frozenset(object_indices)
|
|
34
|
+
|
|
35
|
+
if not object_indices:
|
|
36
|
+
return frozenset(range(context.n_attributes))
|
|
37
|
+
|
|
38
|
+
for index in object_indices:
|
|
39
|
+
if index < 0 or index >= context.n_objects:
|
|
40
|
+
raise IndexError(
|
|
41
|
+
f"Object index {index} is out of range for a context with "
|
|
42
|
+
f"{context.n_objects} objects."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
mask = context.incidence[list(object_indices), :].all(axis=0)
|
|
46
|
+
return frozenset(np.flatnonzero(mask).tolist())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def attribute_derivation(
|
|
50
|
+
context: FormalContext,
|
|
51
|
+
attribute_indices: Iterable[int],
|
|
52
|
+
) -> frozenset[int]:
|
|
53
|
+
"""
|
|
54
|
+
Compute B' for a set of attribute indices B.
|
|
55
|
+
"""
|
|
56
|
+
attribute_indices = frozenset(attribute_indices)
|
|
57
|
+
|
|
58
|
+
if not attribute_indices:
|
|
59
|
+
return frozenset(range(context.n_objects))
|
|
60
|
+
|
|
61
|
+
for index in attribute_indices:
|
|
62
|
+
if index < 0 or index >= context.n_attributes:
|
|
63
|
+
raise IndexError(
|
|
64
|
+
f"Attribute index {index} is out of range for a context with "
|
|
65
|
+
f"{context.n_attributes} attributes."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
mask = context.incidence[:, list(attribute_indices)].all(axis=1)
|
|
69
|
+
return frozenset(np.flatnonzero(mask).tolist())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def attribute_closure(
|
|
73
|
+
context: FormalContext,
|
|
74
|
+
attribute_indices: Iterable[int],
|
|
75
|
+
) -> frozenset[int]:
|
|
76
|
+
"""
|
|
77
|
+
Compute B'' for a set of attribute indices B.
|
|
78
|
+
"""
|
|
79
|
+
extent = attribute_derivation(context, attribute_indices)
|
|
80
|
+
return object_derivation(context, extent)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concept enumeration algorithms for ConceptFlow.
|
|
3
|
+
|
|
4
|
+
This module contains algorithms for enumerating formal concepts from a
|
|
5
|
+
formal context.
|
|
6
|
+
|
|
7
|
+
Implemented methods:
|
|
8
|
+
|
|
9
|
+
- brute-force enumeration
|
|
10
|
+
- NextClosure enumeration
|
|
11
|
+
- CloseByOne-style recursive enumeration
|
|
12
|
+
|
|
13
|
+
The brute-force algorithm is kept as a correctness baseline. NextClosure is
|
|
14
|
+
the first FCA-native enumeration algorithm used by ConceptFlow.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from itertools import chain, combinations
|
|
21
|
+
|
|
22
|
+
from conceptflow.algorithms._registry import normalize_enumeration_algorithm
|
|
23
|
+
from conceptflow.algorithms.derivation import (
|
|
24
|
+
attribute_closure,
|
|
25
|
+
attribute_derivation,
|
|
26
|
+
)
|
|
27
|
+
from conceptflow.core.concept import Concept
|
|
28
|
+
from conceptflow.core.context import FormalContext
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def powerset(items: Iterable[int]) -> Iterable[tuple[int, ...]]:
|
|
32
|
+
"""
|
|
33
|
+
Yield all subsets of the given iterable.
|
|
34
|
+
"""
|
|
35
|
+
items = list(items)
|
|
36
|
+
return chain.from_iterable(
|
|
37
|
+
combinations(items, r) for r in range(len(items) + 1)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def enumerate_concepts_bruteforce(context: FormalContext) -> list[Concept]:
|
|
42
|
+
"""
|
|
43
|
+
Enumerate all formal concepts using brute force.
|
|
44
|
+
|
|
45
|
+
This tries every subset of attributes, closes it, and constructs the
|
|
46
|
+
corresponding concept.
|
|
47
|
+
"""
|
|
48
|
+
concepts: set[Concept] = set()
|
|
49
|
+
|
|
50
|
+
attribute_indices = range(context.n_attributes)
|
|
51
|
+
|
|
52
|
+
for subset in powerset(attribute_indices):
|
|
53
|
+
intent = attribute_closure(context, subset)
|
|
54
|
+
extent = attribute_derivation(context, intent)
|
|
55
|
+
concepts.add(Concept(extent=extent, intent=intent))
|
|
56
|
+
|
|
57
|
+
return sorted(
|
|
58
|
+
concepts,
|
|
59
|
+
key=lambda c: (
|
|
60
|
+
len(c.extent),
|
|
61
|
+
sorted(c.extent),
|
|
62
|
+
sorted(c.intent),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _next_closure_candidate(
|
|
68
|
+
context: FormalContext,
|
|
69
|
+
current_intent: frozenset[int],
|
|
70
|
+
attribute_order: list[int],
|
|
71
|
+
) -> frozenset[int] | None:
|
|
72
|
+
"""
|
|
73
|
+
Compute the next closed intent after current_intent in lectic order.
|
|
74
|
+
"""
|
|
75
|
+
index_of = {attribute: i for i, attribute in enumerate(attribute_order)}
|
|
76
|
+
|
|
77
|
+
for i in range(len(attribute_order) - 1, -1, -1):
|
|
78
|
+
attribute = attribute_order[i]
|
|
79
|
+
|
|
80
|
+
if attribute in current_intent:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
prefix = {a for a in current_intent if index_of[a] < i}
|
|
84
|
+
candidate_seed = prefix | {attribute}
|
|
85
|
+
candidate_closure = attribute_closure(context, candidate_seed)
|
|
86
|
+
|
|
87
|
+
is_valid = True
|
|
88
|
+
|
|
89
|
+
for j in range(i):
|
|
90
|
+
earlier_attribute = attribute_order[j]
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
earlier_attribute in candidate_closure
|
|
94
|
+
) != (
|
|
95
|
+
earlier_attribute in current_intent
|
|
96
|
+
):
|
|
97
|
+
is_valid = False
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if is_valid:
|
|
101
|
+
return candidate_closure
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def enumerate_concepts_nextclosure(context: FormalContext) -> list[Concept]:
|
|
107
|
+
"""
|
|
108
|
+
Enumerate all formal concepts using the NextClosure algorithm.
|
|
109
|
+
"""
|
|
110
|
+
concepts: list[Concept] = []
|
|
111
|
+
|
|
112
|
+
attribute_order = list(range(context.n_attributes))
|
|
113
|
+
current_intent: frozenset[int] | None = attribute_closure(context, [])
|
|
114
|
+
|
|
115
|
+
while current_intent is not None:
|
|
116
|
+
extent = attribute_derivation(context, current_intent)
|
|
117
|
+
concepts.append(Concept(extent=extent, intent=current_intent))
|
|
118
|
+
|
|
119
|
+
current_intent = _next_closure_candidate(
|
|
120
|
+
context=context,
|
|
121
|
+
current_intent=current_intent,
|
|
122
|
+
attribute_order=attribute_order,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return concepts
|
|
126
|
+
|
|
127
|
+
def enumerate_concepts_closebyone(context: FormalContext) -> list[Concept]:
|
|
128
|
+
"""
|
|
129
|
+
Enumerate formal concepts using a simple CloseByOne-style recursion.
|
|
130
|
+
|
|
131
|
+
This implementation is intended as a clear baseline implementation of
|
|
132
|
+
the CloseByOne idea: recursively generate closures and use a canonicity
|
|
133
|
+
condition to avoid duplicates.
|
|
134
|
+
"""
|
|
135
|
+
concepts: set[Concept] = set()
|
|
136
|
+
n_attributes = context.n_attributes
|
|
137
|
+
|
|
138
|
+
def is_canonical(
|
|
139
|
+
current_intent: frozenset[int],
|
|
140
|
+
candidate_intent: frozenset[int],
|
|
141
|
+
attribute_index: int,
|
|
142
|
+
) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Check the CloseByOne canonicity condition.
|
|
145
|
+
|
|
146
|
+
A candidate is canonical if no earlier attribute was introduced by
|
|
147
|
+
the closure unless it was already present in the current intent.
|
|
148
|
+
"""
|
|
149
|
+
for earlier in range(attribute_index):
|
|
150
|
+
if earlier in candidate_intent and earlier not in current_intent:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def recurse(intent: frozenset[int], start_attribute: int) -> None:
|
|
156
|
+
extent = attribute_derivation(context, intent)
|
|
157
|
+
concepts.add(Concept(extent=extent, intent=intent))
|
|
158
|
+
|
|
159
|
+
for attribute_index in range(start_attribute, n_attributes):
|
|
160
|
+
if attribute_index in intent:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
candidate_seed = intent | {attribute_index}
|
|
164
|
+
candidate_intent = attribute_closure(context, candidate_seed)
|
|
165
|
+
|
|
166
|
+
if is_canonical(
|
|
167
|
+
current_intent=intent,
|
|
168
|
+
candidate_intent=candidate_intent,
|
|
169
|
+
attribute_index=attribute_index,
|
|
170
|
+
):
|
|
171
|
+
recurse(
|
|
172
|
+
intent=candidate_intent,
|
|
173
|
+
start_attribute=attribute_index + 1,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
initial_intent = attribute_closure(context, [])
|
|
177
|
+
recurse(initial_intent, 0)
|
|
178
|
+
|
|
179
|
+
return sorted(
|
|
180
|
+
concepts,
|
|
181
|
+
key=lambda c: (
|
|
182
|
+
len(c.extent),
|
|
183
|
+
sorted(c.extent),
|
|
184
|
+
sorted(c.intent),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def enumerate_concepts(
|
|
189
|
+
context: FormalContext,
|
|
190
|
+
method: str = "nextclosure",
|
|
191
|
+
) -> list[Concept]:
|
|
192
|
+
"""
|
|
193
|
+
Enumerate concepts using the selected method.
|
|
194
|
+
"""
|
|
195
|
+
method = normalize_enumeration_algorithm(method)
|
|
196
|
+
|
|
197
|
+
if method == "bruteforce":
|
|
198
|
+
return enumerate_concepts_bruteforce(context)
|
|
199
|
+
|
|
200
|
+
if method == "nextclosure":
|
|
201
|
+
return enumerate_concepts_nextclosure(context)
|
|
202
|
+
|
|
203
|
+
if method == "closebyone":
|
|
204
|
+
return enumerate_concepts_closebyone(context)
|
|
205
|
+
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f'Algorithm "{method}" was normalized but has no implementation.'
|
|
208
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hasse diagram / cover relation utilities for concept lattices.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
from conceptflow.algorithms.order import strict_subconcept_of
|
|
11
|
+
from conceptflow.core.concept import Concept
|
|
12
|
+
|
|
13
|
+
_HASSE_LARGE_LATTICE_THRESHOLD = 500
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_cover(
|
|
17
|
+
lower: Concept,
|
|
18
|
+
upper: Concept,
|
|
19
|
+
concepts: Iterable[Concept],
|
|
20
|
+
) -> bool:
|
|
21
|
+
"""
|
|
22
|
+
Return whether ``upper`` covers ``lower``.
|
|
23
|
+
"""
|
|
24
|
+
concepts = tuple(concepts)
|
|
25
|
+
|
|
26
|
+
if not strict_subconcept_of(lower, upper):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
for middle in concepts:
|
|
30
|
+
if middle == lower or middle == upper:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
strict_subconcept_of(lower, middle)
|
|
35
|
+
and strict_subconcept_of(middle, upper)
|
|
36
|
+
):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_hasse_edges(
|
|
43
|
+
concepts: Iterable[Concept],
|
|
44
|
+
) -> tuple[tuple[Concept, Concept], ...]:
|
|
45
|
+
"""
|
|
46
|
+
Compute cover edges of a concept lattice.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
tuple of tuple
|
|
51
|
+
Edges as ``(lower, upper)`` pairs.
|
|
52
|
+
"""
|
|
53
|
+
concepts = tuple(concepts)
|
|
54
|
+
n = len(concepts)
|
|
55
|
+
|
|
56
|
+
if n > _HASSE_LARGE_LATTICE_THRESHOLD:
|
|
57
|
+
warnings.warn(
|
|
58
|
+
f"Computing Hasse edges for {n} concepts. "
|
|
59
|
+
"This may be slow for large lattices.",
|
|
60
|
+
stacklevel=2,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Precompute strict order as index sets: above[i] holds all j where
|
|
64
|
+
# concepts[i] < concepts[j]. This avoids O(n) tuple conversion on every
|
|
65
|
+
# is_cover() call and enables set-difference to find direct covers.
|
|
66
|
+
above: list[frozenset[int]] = [
|
|
67
|
+
frozenset(
|
|
68
|
+
j for j in range(n)
|
|
69
|
+
if i != j and strict_subconcept_of(concepts[i], concepts[j])
|
|
70
|
+
)
|
|
71
|
+
for i in range(n)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
edges: list[tuple[Concept, Concept]] = []
|
|
75
|
+
|
|
76
|
+
for i in range(n):
|
|
77
|
+
# Elements above i that are reachable via an intermediate step are
|
|
78
|
+
# not direct covers. Their union is: ⋃_{j ∈ above[i]} above[j].
|
|
79
|
+
reachable_via_intermediate = frozenset(k for j in above[i] for k in above[j])
|
|
80
|
+
for j in above[i] - reachable_via_intermediate:
|
|
81
|
+
edges.append((concepts[i], concepts[j]))
|
|
82
|
+
|
|
83
|
+
return tuple(edges)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Duquenne-Guigues (stem) basis computation for ConceptFlow.
|
|
3
|
+
|
|
4
|
+
The canonical basis is the smallest set of implications (attribute-set
|
|
5
|
+
"premise forces conclusion" rules) that is logically equivalent to every
|
|
6
|
+
implication that holds in a formal context. It is built from the context's
|
|
7
|
+
*pseudo-intents*.
|
|
8
|
+
|
|
9
|
+
Definition (Ganter & Wille, "Formal Concept Analysis", Prop. 2.15)
|
|
10
|
+
-------------------------------------------------------------------
|
|
11
|
+
For a formal context K = (G, M, I) with derivation/closure operator
|
|
12
|
+
``''`` (``attribute_closure``), a set P subseteq M is a **pseudo-intent** if:
|
|
13
|
+
|
|
14
|
+
1. P'' != P (P is not itself a concept intent), and
|
|
15
|
+
2. for every pseudo-intent Q that is a *proper subset* of P: Q'' subseteq P.
|
|
16
|
+
|
|
17
|
+
Condition 2 is recursive, but well-founded: since Q must be strictly
|
|
18
|
+
smaller than P, processing candidate sets in increasing size order (ties
|
|
19
|
+
broken by any fixed rule, here lectic/lexicographic) guarantees every
|
|
20
|
+
pseudo-intent smaller than the current candidate has already been decided
|
|
21
|
+
before it is needed.
|
|
22
|
+
|
|
23
|
+
The canonical basis is then exactly:
|
|
24
|
+
|
|
25
|
+
{ P -> (P'' \\ P) : P is a pseudo-intent }
|
|
26
|
+
|
|
27
|
+
This is the same style of "clear, correct, exponential-worst-case"
|
|
28
|
+
algorithm as ``enumerate_concepts_bruteforce``: it tries every subset of
|
|
29
|
+
attributes (there is no way around this in general -- deciding whether a
|
|
30
|
+
given set is a pseudo-intent is already as hard as the underlying concept
|
|
31
|
+
lattice can be large), but it is straightforward to verify against the
|
|
32
|
+
definition above.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from itertools import chain, combinations
|
|
39
|
+
|
|
40
|
+
from conceptflow.algorithms.derivation import attribute_closure
|
|
41
|
+
from conceptflow.core.context import FormalContext
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Implication:
|
|
46
|
+
"""
|
|
47
|
+
A single exact implication premise -> conclusion.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
premise:
|
|
52
|
+
Attribute indices forming the "if" side.
|
|
53
|
+
|
|
54
|
+
conclusion:
|
|
55
|
+
Attribute indices forced by the premise, *beyond* the premise
|
|
56
|
+
itself (i.e. ``conclusion`` and ``premise`` are disjoint).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
premise: frozenset[int]
|
|
60
|
+
conclusion: frozenset[int]
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
object.__setattr__(self, "premise", frozenset(self.premise))
|
|
64
|
+
object.__setattr__(self, "conclusion", frozenset(self.conclusion))
|
|
65
|
+
|
|
66
|
+
def premise_names(self, context: FormalContext) -> tuple[str, ...]:
|
|
67
|
+
"""Return the premise as attribute names, in a fixed order."""
|
|
68
|
+
return tuple(context.attributes[i] for i in sorted(self.premise))
|
|
69
|
+
|
|
70
|
+
def conclusion_names(self, context: FormalContext) -> tuple[str, ...]:
|
|
71
|
+
"""Return the conclusion as attribute names, in a fixed order."""
|
|
72
|
+
return tuple(context.attributes[i] for i in sorted(self.conclusion))
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return (
|
|
76
|
+
f"Implication(premise={sorted(self.premise)}, "
|
|
77
|
+
f"conclusion={sorted(self.conclusion)})"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _powerset(n_attributes: int):
|
|
82
|
+
"""Yield every subset of range(n_attributes), smallest first."""
|
|
83
|
+
attributes = range(n_attributes)
|
|
84
|
+
return chain.from_iterable(
|
|
85
|
+
combinations(attributes, size) for size in range(n_attributes + 1)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_canonical_basis(context: FormalContext) -> list[Implication]:
|
|
90
|
+
"""
|
|
91
|
+
Compute the Duquenne-Guigues (stem) basis of a formal context.
|
|
92
|
+
|
|
93
|
+
Step by step
|
|
94
|
+
------------
|
|
95
|
+
1. Generate every candidate attribute subset, sorted by
|
|
96
|
+
``(size, sorted(subset))`` -- smallest first. This order is what
|
|
97
|
+
makes step 3 well-defined: any pseudo-intent that could matter for
|
|
98
|
+
a minimality check on the current candidate is strictly smaller,
|
|
99
|
+
so it was already visited earlier in this same loop.
|
|
100
|
+
2. Close the candidate with the context's own derivation operator
|
|
101
|
+
(``attribute_closure``, i.e. B''). If the candidate is already
|
|
102
|
+
equal to its own closure, it is a genuine concept intent, not a
|
|
103
|
+
pseudo-intent -- skip it.
|
|
104
|
+
3. Otherwise, check the minimality condition against every
|
|
105
|
+
already-found pseudo-intent Q that is a proper subset of this
|
|
106
|
+
candidate: all of their closures must already fit inside the
|
|
107
|
+
candidate. If so, the candidate is itself a new pseudo-intent;
|
|
108
|
+
record it together with its closure.
|
|
109
|
+
4. Once every candidate has been classified, turn each pseudo-intent
|
|
110
|
+
P (with closure P'') into one implication P -> (P'' \\ P) -- the
|
|
111
|
+
attributes P forces *beyond itself*.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
context:
|
|
116
|
+
The formal context to compute the basis for.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
list[Implication]
|
|
121
|
+
The canonical basis, ordered by ``(premise size, premise)``.
|
|
122
|
+
"""
|
|
123
|
+
pseudo_intents: dict[frozenset[int], frozenset[int]] = {}
|
|
124
|
+
|
|
125
|
+
for subset in _powerset(context.n_attributes):
|
|
126
|
+
candidate = frozenset(subset)
|
|
127
|
+
closure = attribute_closure(context, candidate)
|
|
128
|
+
|
|
129
|
+
if closure == candidate:
|
|
130
|
+
# Already a concept intent -- nothing new to force.
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
is_minimal = all(
|
|
134
|
+
known_closure <= candidate
|
|
135
|
+
for known_premise, known_closure in pseudo_intents.items()
|
|
136
|
+
if known_premise < candidate
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if is_minimal:
|
|
140
|
+
pseudo_intents[candidate] = closure
|
|
141
|
+
|
|
142
|
+
ordered_premises = sorted(
|
|
143
|
+
pseudo_intents,
|
|
144
|
+
key=lambda premise: (len(premise), sorted(premise)),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return [
|
|
148
|
+
Implication(premise=premise, conclusion=pseudo_intents[premise] - premise)
|
|
149
|
+
for premise in ordered_premises
|
|
150
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Order relations for formal concepts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from conceptflow.core.concept import Concept
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def subconcept_of(lower: Concept, upper: Concept) -> bool:
|
|
11
|
+
"""
|
|
12
|
+
Return whether ``lower <= upper`` in the concept lattice.
|
|
13
|
+
|
|
14
|
+
In FCA:
|
|
15
|
+
|
|
16
|
+
lower <= upper iff extent(lower) subseteq extent(upper)
|
|
17
|
+
|
|
18
|
+
Equivalently, this is reverse inclusion on intents.
|
|
19
|
+
"""
|
|
20
|
+
return lower.extent.issubset(upper.extent)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def strict_subconcept_of(lower: Concept, upper: Concept) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Return whether ``lower < upper`` in the concept lattice.
|
|
26
|
+
"""
|
|
27
|
+
return lower != upper and subconcept_of(lower, upper)
|