Skip to content

Commit d5029d0

Browse files
Updated fanny algorithm, fuzzy clustering, and added property based clustering
1 parent 1c93d0a commit d5029d0

158 files changed

Lines changed: 10077 additions & 21833 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ developer/WeightedCluster-master/
5959
developer/seqHMM-main/
6060
developer/Py_FS-main/
6161
developer/eBoruta-master/
62+
developer/cluster-master/
6263

6364
# ignore RData and Rhistory
6465
.RData

Tutorials/cluster_analysis/test_sequences_to_variables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def test_phase2(diss_small, kmed, k):
131131

132132
def test_phase3(diss_small, k):
133133
"""Phase 3: fanny_membership and soft_classification_variables."""
134-
U, medoids = fanny_membership(diss_small, k=k, m=1.4, random_state=42)
134+
U, medoids = fanny_membership(diss_small, k=k, m=1.4)
135135
n_use = diss_small.shape[0]
136136
assert U.shape == (n_use, k)
137137
np.testing.assert_allclose(U.sum(axis=1), 1.0, rtol=1e-5)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Studer (2018) fuzzy + property-based clustering workflow (biofam).
4+
5+
Mirrors the WeightedCluster short R tutorial:
6+
seqdef -> seqdist(LCS) -> fanny -> summary(membership)
7+
-> seqpropclust(state, duration) -> as.clustrange
8+
9+
Run from repo root:
10+
python3 -u Tutorials/cluster_analysis/test_studer_2018_workflow.py
11+
python3 -u Tutorials/cluster_analysis/test_studer_2018_workflow.py --full
12+
13+
Default uses n=400 sequences (fast sanity check). --full uses all 2000 rows;
14+
FANNY alone may take 30-60+ minutes in pure Python (R cluster::fanny ~35 s).
15+
"""
16+
from __future__ import annotations
17+
18+
import argparse
19+
import sys
20+
import time
21+
from pathlib import Path
22+
23+
import numpy as np
24+
import pandas as pd
25+
26+
_script_dir = Path(__file__).resolve().parent
27+
_repo_root = _script_dir.parents[1]
28+
if str(_repo_root) not in sys.path:
29+
sys.path.insert(0, str(_repo_root))
30+
31+
from sequenzo import SequenceData, get_distance_matrix, load_dataset
32+
from sequenzo.clustering import (
33+
get_fuzzy_clusters,
34+
membership_summary,
35+
print_property_tree,
36+
property_based_clustering,
37+
property_clustering_quality,
38+
)
39+
40+
# R tutorial reference targets (WeightedCluster on biofam, set.seed(1))
41+
R_MEMBERSHIP_SUMMARY_MEAN = {
42+
"V1": 0.205773,
43+
"V2": 0.211530,
44+
"V3": 0.210022,
45+
"V4": 0.178295,
46+
"V5": 0.194380,
47+
}
48+
R_PROPERTY_GLOBAL_R2 = 0.48761
49+
R_CLUSTER_QUALITY = pd.DataFrame(
50+
{
51+
"PBC": [0.50, 0.54, 0.54, 0.58],
52+
"HG": [0.61, 0.69, 0.73, 0.80],
53+
"R2": [0.21, 0.34, 0.42, 0.49],
54+
},
55+
index=["cluster2", "cluster3", "cluster4", "cluster5"],
56+
)
57+
58+
STATE_LABELS = [
59+
"Parent",
60+
"Left",
61+
"Married",
62+
"Left/Married",
63+
"Child",
64+
"Left/Child",
65+
"Left/Married/Child",
66+
"Divorced",
67+
]
68+
TIME_COLS = [str(age) for age in range(15, 31)]
69+
70+
71+
def load_biofam_sequences(n_rows: int | None) -> SequenceData:
72+
df = load_dataset("biofam").reset_index(drop=True)
73+
if n_rows is not None:
74+
df = df.head(n_rows)
75+
df["id"] = np.arange(len(df))
76+
return SequenceData(
77+
df,
78+
time=TIME_COLS,
79+
states=list(range(8)),
80+
labels=STATE_LABELS,
81+
id_col="id",
82+
)
83+
84+
85+
def _compare_table(label: str, got: pd.DataFrame, ref: pd.DataFrame, cols: list[str]) -> None:
86+
print(f"\n=== {label} (Sequenzo vs R reference) ===")
87+
for col in cols:
88+
if col not in got.columns or col not in ref.columns:
89+
continue
90+
g = got[col].to_numpy(dtype=float)
91+
r = ref[col].to_numpy(dtype=float)
92+
diff = np.abs(g - r)
93+
print(f" {col}: max |diff| = {diff.max():.4f}, mean |diff| = {diff.mean():.4f}")
94+
95+
96+
def main() -> None:
97+
parser = argparse.ArgumentParser(description="Studer 2018 biofam workflow test")
98+
parser.add_argument(
99+
"--full",
100+
action="store_true",
101+
help="Use all 2000 biofam sequences (slow FANNY)",
102+
)
103+
parser.add_argument(
104+
"--n",
105+
type=int,
106+
default=400,
107+
help="Subsample size when not using --full (default: 400)",
108+
)
109+
args = parser.parse_args()
110+
n_rows = None if args.full else args.n
111+
112+
print(f"[>] Loading biofam ({'full' if args.full else f'n={n_rows}'})...")
113+
t0 = time.perf_counter()
114+
seqdata = load_biofam_sequences(n_rows)
115+
print(f" {seqdata.seqdata.shape[0]} sequences, {len(TIME_COLS)} time points")
116+
117+
print("[>] LCS distance matrix...")
118+
t1 = time.perf_counter()
119+
diss = get_distance_matrix(seqdata, method="LCS")
120+
diss = np.asarray(diss.values if hasattr(diss, "values") else diss, dtype=float)
121+
print(f" done in {time.perf_counter() - t1:.1f}s; mean diss = {diss.mean():.4f}")
122+
123+
print("[>] FANNY fuzzy clustering (k=5, memb.exp=1.5)...")
124+
t2 = time.perf_counter()
125+
fclust = get_fuzzy_clusters(diss, n_clusters=5, memb_exp=1.5, method="fanny")
126+
print(
127+
f" done in {time.perf_counter() - t2:.1f}s; "
128+
f"converged={fclust.converged}, iterations={fclust.iterations}"
129+
)
130+
131+
summary = membership_summary(fclust.membership)
132+
print("\n=== membership summary (Mean row) ===")
133+
print(summary.loc[["Mean"]].round(6))
134+
if args.full:
135+
mean_row = summary.loc["Mean"]
136+
for col in R_MEMBERSHIP_SUMMARY_MEAN:
137+
if col in mean_row.index:
138+
ref = R_MEMBERSHIP_SUMMARY_MEAN[col]
139+
got = float(mean_row[col])
140+
print(f" {col}: got={got:.6f}, R={ref:.6f}, diff={abs(got - ref):.6f}")
141+
142+
print("\n[>] Property-based clustering (state + duration, max_clusters=5)...")
143+
t3 = time.perf_counter()
144+
pclust = property_based_clustering(
145+
seqdata,
146+
diss=diss,
147+
properties=["state", "duration"],
148+
max_clusters=5,
149+
verbose=True,
150+
)
151+
print(f" done in {time.perf_counter() - t3:.1f}s")
152+
print_property_tree(pclust)
153+
154+
adj = pclust["info"].get("adjustment") or {}
155+
r2 = adj.get("R2")
156+
if r2 is not None:
157+
print(f"\nGlobal R2: {r2:.5f}", end="")
158+
if args.full:
159+
print(f" (R reference: {R_PROPERTY_GLOBAL_R2:.5f})")
160+
else:
161+
print()
162+
163+
print("\n[>] Cluster quality (as.clustrange)...")
164+
pclustqual = property_clustering_quality(pclust, diss=diss, n_clusters=5)
165+
print(pclustqual.stats.round(2))
166+
if args.full:
167+
_compare_table("cluster quality", pclustqual.stats, R_CLUSTER_QUALITY, ["PBC", "HG", "R2"])
168+
169+
print(f"\n[>] Total elapsed: {time.perf_counter() - t0:.1f}s")
170+
if not args.full:
171+
print(
172+
"[i] Subsample run — use --full for R parity checks on membership / R2 / quality."
173+
)
174+
175+
176+
if __name__ == "__main__":
177+
main()

developer/important-literature/2018-studer-property-based-fuzzy-clustering.md

Lines changed: 598 additions & 0 deletions
Large diffs are not rendered by default.

sequenzo/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
"cluster_labels_from_kmedoids_result": ("sequenzo.clustering", "cluster_labels_from_kmedoids_result"),
7373
"hard_classification_variables": ("sequenzo.clustering", "hard_classification_variables"),
7474
"fanny_membership": ("sequenzo.clustering", "fanny_membership"),
75-
"representative_indices_from_membership": ("sequenzo.clustering", "representative_indices_from_membership"),
75+
"highest_membership_indices_from_membership": ("sequenzo.clustering", "highest_membership_indices_from_membership"),
7676
"soft_classification_variables": ("sequenzo.clustering", "soft_classification_variables"),
7777
"pseudoclass_regression": ("sequenzo.clustering", "pseudoclass_regression"),
7878
# multidomain
@@ -346,7 +346,7 @@ def __getattr__(name: str) -> Any:
346346
"cluster_labels_from_kmedoids_result",
347347
"hard_classification_variables",
348348
"fanny_membership",
349-
"representative_indices_from_membership",
349+
"highest_membership_indices_from_membership",
350350
"soft_classification_variables",
351351
"pseudoclass_regression",
352352
"create_idcd_sequence_from_csvs",

sequenzo/big_data/clara/clara.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
from sequenzo.big_data.clara.utils.davies_bouldin import *
2323
from sequenzo.big_data.clara.utils.get_weighted_diss import get_weighted_diss
2424
from scipy.cluster.hierarchy import cut_tree
25-
from sequenzo.clustering.fuzzy.wfcmdd_fuzzy_clustering import wfcmdd
25+
26+
from sequenzo.clustering.fuzzy_clustering import wfcmdd
2627
from sequenzo.clustering.k_medoids import KMedoids
2728
from sequenzo.clustering.sequences_to_variables.helske_regression_variables import (
2829
fanny_membership,

sequenzo/clustering/README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ sequenzo/clustering/
1313
sequences_to_variables/
1414
helske_regression_variables.py # Helske (2024) sequence -> regression covariates
1515
helpers.py # shared small helpers
16-
fuzzy/
16+
fuzzy_clustering/
17+
src/
18+
fanny.cpp # R cluster::fanny (C++ port)
19+
fanny.h
1720
wfcmdd_fuzzy_clustering.py # distance-based fuzzy C-medoids
1821
fuzzy_sequence_plots.py # membership-weighted seq index plots
22+
fuzzy_helpers.py # FANNY / wfcmdd entry points
23+
fuzzy_regression.py # Dirichlet / beta regression on membership
24+
property_based_clustering/ # Studer (2018) seqpropclust workflow
1925
validation/
2026
partition_quality.py # fixed partitions + CQI table
2127
dissmfacw_factors.py # multi-factor discrepancy association core
@@ -41,9 +47,9 @@ sequenzo/clustering/
4147
| WeightedCluster `clustassoc` | `validation.cluster_covariate_association` | `cluster_association` | Clustering vs covariate |
4248
| WeightedCluster `rarcat` | `validation.rarcat_typology_regression` | `rarcat` | Robust typology AME |
4349
| `cluster::fanny` (membership) | `sequences_to_variables.helske_regression_variables` | `fanny_membership` | Distance-based soft membership |
44-
| WeightedCluster `wfcmdd` | `fuzzy.wfcmdd_fuzzy_clustering` | `wfcmdd` | FCMdd / NCdd / PCMdd |
45-
| WeightedCluster `fuzzyseqplot` | `fuzzy.fuzzy_sequence_plots` | `fuzzy_sequence_plot` | Membership-weighted index plot |
46-
| WeightedCluster `crispness` | `fuzzy.wfcmdd_fuzzy_clustering` | `crispness` | Partition sharpness |
50+
| WeightedCluster `wfcmdd` | `fuzzy_clustering.wfcmdd_fuzzy_clustering` | `wfcmdd` | FCMdd / NCdd / PCMdd |
51+
| WeightedCluster `fuzzyseqplot` | `fuzzy_clustering.fuzzy_sequence_plots` | `fuzzy_sequence_plot` | Membership-weighted index plot |
52+
| WeightedCluster `crispness` | `fuzzy_clustering.wfcmdd_fuzzy_clustering` | `crispness` | Partition sharpness |
4753
| Helske representativeness | `sequences_to_variables.helske_regression_variables` | `representativeness_matrix` | Not a TraMineR export |
4854
| Helske hard / soft / pseudoclass | `sequences_to_variables.helske_regression_variables` | `hard_classification_variables`, `soft_classification_variables`, `pseudoclass_regression` | Regression-ready typology covariates |
4955

sequenzo/clustering/__init__.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,40 @@
1818
FannyResult,
1919
fanny_membership,
2020
medoid_membership_approximation,
21-
representative_indices_from_membership,
21+
highest_membership_indices_from_membership,
2222
soft_classification_variables,
2323
pseudoclass_regression,
2424
max_distance,
2525
cluster_labels_to_dummies,
2626
)
27-
from .fuzzy import (
27+
from .fuzzy_clustering import (
2828
wfcmdd,
2929
crispness,
3030
WfcmddResult,
3131
fuzzy_sequence_plot,
3232
fuzzy_sequence_plot_single,
33+
get_fuzzy_clusters,
34+
FuzzyClusterResult,
35+
membership_summary,
36+
most_typical_members,
37+
prepare_dirichlet_data,
38+
DirichletRegData,
39+
DirichletRegResult,
40+
dirichlet_regression,
41+
beta_regression,
42+
)
43+
from .property_based_clustering import (
44+
extract_sequence_properties,
45+
property_based_clustering,
46+
seqpropclust,
47+
cluster_split_schedule,
48+
cut_tree,
49+
prune_property_tree,
50+
tree_labels,
51+
property_clustering_quality,
52+
print_property_tree,
53+
plot_property_tree,
54+
SUPPORTED_PROPERTIES,
3355
)
3456
from .validation import (
3557
cluster_range_from_partitions,
@@ -77,14 +99,34 @@ def _import_c_code():
7799
"FannyResult",
78100
"fanny_membership",
79101
"medoid_membership_approximation",
80-
"representative_indices_from_membership",
102+
"highest_membership_indices_from_membership",
81103
"soft_classification_variables",
82104
"pseudoclass_regression",
83105
"wfcmdd",
84106
"crispness",
85107
"WfcmddResult",
86108
"fuzzy_sequence_plot",
87109
"fuzzy_sequence_plot_single",
110+
"get_fuzzy_clusters",
111+
"FuzzyClusterResult",
112+
"membership_summary",
113+
"most_typical_members",
114+
"prepare_dirichlet_data",
115+
"DirichletRegData",
116+
"DirichletRegResult",
117+
"dirichlet_regression",
118+
"beta_regression",
119+
"extract_sequence_properties",
120+
"property_based_clustering",
121+
"seqpropclust",
122+
"cluster_split_schedule",
123+
"cut_tree",
124+
"prune_property_tree",
125+
"tree_labels",
126+
"property_clustering_quality",
127+
"print_property_tree",
128+
"plot_property_tree",
129+
"SUPPORTED_PROPERTIES",
88130
"cluster_range_from_partitions",
89131
"compute_partition_quality",
90132
"ClusterRangeResult",

sequenzo/clustering/fuzzy/__init__.py

Lines changed: 0 additions & 21 deletions
This file was deleted.

0 commit comments

Comments
 (0)