baticlust 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.
- baticlust/__init__.py +18 -0
- baticlust/clustering.py +130 -0
- baticlust/preprocessing.py +24 -0
- baticlust/validation.py +164 -0
- baticlust-0.1.0.dist-info/METADATA +138 -0
- baticlust-0.1.0.dist-info/RECORD +9 -0
- baticlust-0.1.0.dist-info/WHEEL +5 -0
- baticlust-0.1.0.dist-info/licenses/LICENSE +21 -0
- baticlust-0.1.0.dist-info/top_level.txt +1 -0
baticlust/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .clustering import stratify_data, cluster_stratum, generate_archetypes, select_medoids
|
|
2
|
+
from .preprocessing import scale_features, one_hot_encode
|
|
3
|
+
from .validation import compute_archetype_performance, validate_typology, plot_medoid_validation, validate_typology_with_medoids, plot_distribution_comparison
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
root_path = Path(__file__).parent
|
|
7
|
+
|
|
8
|
+
# paths for the various data folders
|
|
9
|
+
data_path = {
|
|
10
|
+
"base": root_path / "data",
|
|
11
|
+
"gis": root_path / "data" / "gis",
|
|
12
|
+
"climate": root_path / "data" / "climate",
|
|
13
|
+
"census": root_path / "data" / "census",
|
|
14
|
+
"diagnosis": root_path / "data" / "diagnosis",
|
|
15
|
+
"building_statistics": root_path / "data" / "building_statistics",
|
|
16
|
+
"energy_use": root_path / "data" / "energy_use",
|
|
17
|
+
"energy_open_data": root_path / "data" / "energy_open_data",
|
|
18
|
+
}
|
baticlust/clustering.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
import numpy as np
|
|
3
|
+
import faiss
|
|
4
|
+
import fastcluster
|
|
5
|
+
from scipy.cluster.hierarchy import fcluster
|
|
6
|
+
from scipy.spatial.distance import cdist
|
|
7
|
+
from joblib import Parallel, delayed
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
def stratify_data(df: pl.DataFrame, strata_cols: list[str]) -> list[tuple[tuple, pl.DataFrame]]:
|
|
11
|
+
"""
|
|
12
|
+
Splits the DataFrame into strata based on unique combinations of strata_cols.
|
|
13
|
+
Returns a list of (stratum_values, sub_df).
|
|
14
|
+
"""
|
|
15
|
+
partitions = df.partition_by(strata_cols, as_dict=True)
|
|
16
|
+
return list(partitions.items())
|
|
17
|
+
|
|
18
|
+
def cluster_stratum(sub_df: pl.DataFrame, features: list[str], method: str, n_clusters: int, **kwargs) -> pl.DataFrame:
|
|
19
|
+
"""
|
|
20
|
+
Clusters a single sub-sample (stratum).
|
|
21
|
+
Returns the sub_df with a new 'cluster_id' column.
|
|
22
|
+
"""
|
|
23
|
+
if sub_df.height == 0:
|
|
24
|
+
return sub_df.with_columns(pl.lit(-1, dtype=pl.Int64).alias("cluster_id"))
|
|
25
|
+
|
|
26
|
+
# Prepare data for clustering
|
|
27
|
+
# Ensure features are float32 for FAISS
|
|
28
|
+
X = sub_df.select(features).to_numpy().astype(np.float32)
|
|
29
|
+
|
|
30
|
+
# Handle case where n_clusters >= n_samples
|
|
31
|
+
if n_clusters >= sub_df.height:
|
|
32
|
+
return sub_df.with_columns(
|
|
33
|
+
pl.int_range(0, sub_df.height, dtype=pl.Int64).alias("cluster_id")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if method == 'kmeans':
|
|
37
|
+
# FAISS K-Means
|
|
38
|
+
d = X.shape[1]
|
|
39
|
+
# faiss.Kmeans expects n_clusters (k) and dimension (d)
|
|
40
|
+
kmeans = faiss.Kmeans(d, n_clusters, niter=kwargs.get('niter', 20), verbose=kwargs.get('verbose', False))
|
|
41
|
+
kmeans.train(X)
|
|
42
|
+
_, labels = kmeans.index.search(X, 1)
|
|
43
|
+
labels = labels.flatten()
|
|
44
|
+
|
|
45
|
+
elif method == 'hierarchical':
|
|
46
|
+
# fastcluster
|
|
47
|
+
linkage_method = kwargs.get('linkage_method', 'ward')
|
|
48
|
+
metric = kwargs.get('metric', 'euclidean')
|
|
49
|
+
|
|
50
|
+
Z = fastcluster.linkage(X, method=linkage_method, metric=metric)
|
|
51
|
+
labels = fcluster(Z, n_clusters, criterion='maxclust')
|
|
52
|
+
labels = labels - 1 # 0-based
|
|
53
|
+
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(f"Unknown clustering method: {method}")
|
|
56
|
+
|
|
57
|
+
return sub_df.with_columns(pl.Series("cluster_id", labels).cast(pl.Int64))
|
|
58
|
+
|
|
59
|
+
def select_medoids(df: pl.DataFrame, cluster_col: str, features: list[str]) -> pl.DataFrame:
|
|
60
|
+
"""
|
|
61
|
+
Identifies the medoid for each cluster.
|
|
62
|
+
The medoid is the point in the cluster with the minimum sum of distances to all other points in the cluster.
|
|
63
|
+
Returns a DataFrame containing only the medoid rows.
|
|
64
|
+
"""
|
|
65
|
+
# We need global indices to select rows later
|
|
66
|
+
df_with_idx = df.with_row_index("row_idx")
|
|
67
|
+
|
|
68
|
+
# Group by cluster
|
|
69
|
+
partitions = df_with_idx.partition_by(cluster_col, as_dict=True)
|
|
70
|
+
|
|
71
|
+
medoid_indices = []
|
|
72
|
+
|
|
73
|
+
for cluster_id, group in partitions.items():
|
|
74
|
+
if group.height == 0:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
X_group = group.select(features).to_numpy()
|
|
78
|
+
|
|
79
|
+
# Calculate pairwise distances
|
|
80
|
+
# cdist returns a matrix of distances
|
|
81
|
+
# If group is large, this can be memory intensive (N^2).
|
|
82
|
+
# For large groups, we might want to approximate or sample.
|
|
83
|
+
# Assuming reasonable cluster sizes for archetypes.
|
|
84
|
+
dists = cdist(X_group, X_group, metric='euclidean')
|
|
85
|
+
|
|
86
|
+
# Sum of distances for each point
|
|
87
|
+
sum_dists = dists.sum(axis=1)
|
|
88
|
+
|
|
89
|
+
# Find index of minimum sum
|
|
90
|
+
local_medoid_idx = np.argmin(sum_dists)
|
|
91
|
+
|
|
92
|
+
# Get the global row index
|
|
93
|
+
global_idx = group["row_idx"][int(local_medoid_idx)]
|
|
94
|
+
medoid_indices.append(global_idx)
|
|
95
|
+
|
|
96
|
+
# Filter the original dataframe to keep only medoids
|
|
97
|
+
medoids_df = df_with_idx.filter(pl.col("row_idx").is_in(medoid_indices)).drop("row_idx")
|
|
98
|
+
|
|
99
|
+
return medoids_df
|
|
100
|
+
|
|
101
|
+
def _process_stratum(sub_df, total_samples, total_n_clusters, features, method, **kwargs):
|
|
102
|
+
n_samples = sub_df.height
|
|
103
|
+
# Proportional allocation
|
|
104
|
+
if total_samples > 0:
|
|
105
|
+
n_clusters = max(1, int(round(total_n_clusters * (n_samples / total_samples))))
|
|
106
|
+
else:
|
|
107
|
+
n_clusters = 1
|
|
108
|
+
|
|
109
|
+
n_clusters = min(n_clusters, n_samples)
|
|
110
|
+
|
|
111
|
+
return cluster_stratum(sub_df, features, method, n_clusters, **kwargs)
|
|
112
|
+
|
|
113
|
+
def generate_archetypes(df: pl.DataFrame, strata_cols: list[str], features: list[str], method: str, total_n_clusters: int, **kwargs) -> pl.DataFrame:
|
|
114
|
+
"""
|
|
115
|
+
Orchestrates stratification and clustering.
|
|
116
|
+
Distributes total_n_clusters proportionally to strata size.
|
|
117
|
+
"""
|
|
118
|
+
strata = stratify_data(df, strata_cols)
|
|
119
|
+
total_samples = df.height
|
|
120
|
+
|
|
121
|
+
results = Parallel(n_jobs=-1)(
|
|
122
|
+
delayed(_process_stratum)(
|
|
123
|
+
sub_df, total_samples, total_n_clusters, features, method, **kwargs
|
|
124
|
+
) for _, sub_df in tqdm(strata, desc="Clustering strata")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if not results:
|
|
128
|
+
return df.with_columns(pl.lit(None).alias("cluster_id"))
|
|
129
|
+
|
|
130
|
+
return pl.concat(results)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
|
|
3
|
+
def scale_features(df: pl.DataFrame, features: list[str], method: str = 'standard') -> pl.DataFrame:
|
|
4
|
+
"""
|
|
5
|
+
Scales numerical features using Standard or MinMax scaling.
|
|
6
|
+
"""
|
|
7
|
+
if method == 'standard':
|
|
8
|
+
return df.with_columns([
|
|
9
|
+
((pl.col(f) - pl.col(f).mean()) / pl.col(f).std()).fill_nan(0).alias(f)
|
|
10
|
+
for f in features
|
|
11
|
+
])
|
|
12
|
+
elif method == 'minmax':
|
|
13
|
+
return df.with_columns([
|
|
14
|
+
((pl.col(f) - pl.col(f).min()) / (pl.col(f).max() - pl.col(f).min())).fill_nan(0).alias(f)
|
|
15
|
+
for f in features
|
|
16
|
+
])
|
|
17
|
+
else:
|
|
18
|
+
raise ValueError("Method must be 'standard' or 'minmax'")
|
|
19
|
+
|
|
20
|
+
def one_hot_encode(df: pl.DataFrame, columns: list[str]) -> pl.DataFrame:
|
|
21
|
+
"""
|
|
22
|
+
One-hot encodes categorical columns.
|
|
23
|
+
"""
|
|
24
|
+
return df.to_dummies(columns)
|
baticlust/validation.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.stats import wasserstein_distance, ks_2samp, gaussian_kde
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
from sklearn.metrics import r2_score
|
|
6
|
+
|
|
7
|
+
def compute_archetype_performance(df: pl.DataFrame, cluster_col: str, target_col: str) -> pl.DataFrame:
|
|
8
|
+
"""
|
|
9
|
+
Calculate the representative energy performance for each cluster.
|
|
10
|
+
"""
|
|
11
|
+
return df.group_by(cluster_col).agg([
|
|
12
|
+
pl.col(target_col).mean().alias(f"{target_col}_mean"),
|
|
13
|
+
pl.col(target_col).median().alias(f"{target_col}_median"),
|
|
14
|
+
pl.col(target_col).std().alias(f"{target_col}_std"),
|
|
15
|
+
pl.col(target_col).count().alias("count")
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
def validate_typology_with_medoids(df: pl.DataFrame, medoids_df: pl.DataFrame, cluster_col: str, target_col: str) -> dict[str, float]:
|
|
19
|
+
"""
|
|
20
|
+
Compare the aggregated cluster-based distribution of the target_col (energy performance)
|
|
21
|
+
against the actual global distribution in the original data, using medoids as representatives.
|
|
22
|
+
"""
|
|
23
|
+
# Original distribution
|
|
24
|
+
original_dist = df.select(target_col).to_numpy().flatten()
|
|
25
|
+
|
|
26
|
+
# Reconstructed distribution using medoids
|
|
27
|
+
# We need to map each row in the original df to its medoid's target_col value.
|
|
28
|
+
|
|
29
|
+
# Medoid values for each cluster
|
|
30
|
+
# We need to make sure we select only the cluster_col and target_col from medoids_df
|
|
31
|
+
medoid_values = medoids_df.select([cluster_col, target_col]).rename({target_col: "medoid_val"})
|
|
32
|
+
|
|
33
|
+
# Join back to the full clustered dataframe
|
|
34
|
+
reconstructed_df = df.join(medoid_values, on=cluster_col, how="left")
|
|
35
|
+
reconstructed_dist = reconstructed_df.select("medoid_val").to_numpy().flatten()
|
|
36
|
+
|
|
37
|
+
wd = wasserstein_distance(original_dist, reconstructed_dist)
|
|
38
|
+
ks_stat, _ = ks_2samp(original_dist, reconstructed_dist)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
"wasserstein_distance": wd,
|
|
42
|
+
"ks_statistic": ks_stat
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def validate_typology(df: pl.DataFrame, cluster_col: str, target_col: str) -> dict[str, float]:
|
|
46
|
+
"""
|
|
47
|
+
Compare the aggregated cluster-based distribution of the target_col (energy performance)
|
|
48
|
+
against the actual global distribution in the original data.
|
|
49
|
+
"""
|
|
50
|
+
# Original distribution
|
|
51
|
+
original_dist = df.select(target_col).to_numpy().flatten()
|
|
52
|
+
|
|
53
|
+
# Cluster-based distribution (reconstructed)
|
|
54
|
+
# We assume the cluster representative value (e.g., mean) represents all members of that cluster
|
|
55
|
+
# We need to map each row to its cluster's mean value.
|
|
56
|
+
|
|
57
|
+
# Calculate cluster means
|
|
58
|
+
cluster_means = df.group_by(cluster_col).agg(pl.col(target_col).mean().alias("mean_val"))
|
|
59
|
+
|
|
60
|
+
# Join back to get the 'predicted' value for each row
|
|
61
|
+
reconstructed_df = df.join(cluster_means, on=cluster_col, how="left")
|
|
62
|
+
reconstructed_dist = reconstructed_df.select("mean_val").to_numpy().flatten()
|
|
63
|
+
|
|
64
|
+
wd = wasserstein_distance(original_dist, reconstructed_dist)
|
|
65
|
+
ks_stat, _ = ks_2samp(original_dist, reconstructed_dist)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"wasserstein_distance": wd,
|
|
69
|
+
"ks_statistic": ks_stat
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def plot_medoid_validation(df: pl.DataFrame, medoids_df: pl.DataFrame,
|
|
73
|
+
cluster_col: str, target_col: str,
|
|
74
|
+
output_path: str = None,
|
|
75
|
+
max_value: float = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Plots a 2D Histogram of actual target_col vs predicted target_col from medoids,
|
|
79
|
+
with the R2 of the regression on the plot.
|
|
80
|
+
"""
|
|
81
|
+
# Original values
|
|
82
|
+
y_true = df.select(target_col).to_numpy().flatten()
|
|
83
|
+
|
|
84
|
+
# Predicted values (medoid values)
|
|
85
|
+
medoid_values = medoids_df.select([cluster_col, target_col]).rename({target_col: "medoid_val"})
|
|
86
|
+
reconstructed_df = df.join(medoid_values, on=cluster_col, how="left")
|
|
87
|
+
y_pred = reconstructed_df.select("medoid_val").to_numpy().flatten()
|
|
88
|
+
|
|
89
|
+
# Calculate R2
|
|
90
|
+
r2 = r2_score(y_true, y_pred)
|
|
91
|
+
|
|
92
|
+
# Prepare data
|
|
93
|
+
x = y_true
|
|
94
|
+
y = y_pred
|
|
95
|
+
if max_value is not None:
|
|
96
|
+
mask = (x <= max_value) & (y <= max_value)
|
|
97
|
+
x = x[mask]
|
|
98
|
+
y = y[mask]
|
|
99
|
+
|
|
100
|
+
# Plot
|
|
101
|
+
plt.figure(figsize=(10, 8))
|
|
102
|
+
|
|
103
|
+
nbins = 50
|
|
104
|
+
plt.hist2d(x, y, bins=nbins, cmap=plt.cm.BuGn)
|
|
105
|
+
|
|
106
|
+
# Add diagonal line
|
|
107
|
+
min_val = min(x.min(), y.min())
|
|
108
|
+
max_val = max(x.max(), y.max())
|
|
109
|
+
plt.plot([min_val, max_val], [min_val, max_val], 'r--', lw=2, label='Identity')
|
|
110
|
+
|
|
111
|
+
plt.colorbar(label='Count')
|
|
112
|
+
plt.xlabel(f"Actual {target_col}")
|
|
113
|
+
plt.ylabel(f"Predicted {target_col} (Medoid)")
|
|
114
|
+
plt.title(f"Actual vs Predicted {target_col} 2D Histogram (R2 = {r2:.4f})")
|
|
115
|
+
plt.legend()
|
|
116
|
+
|
|
117
|
+
if output_path:
|
|
118
|
+
plt.savefig(output_path)
|
|
119
|
+
plt.close()
|
|
120
|
+
else:
|
|
121
|
+
plt.show()
|
|
122
|
+
|
|
123
|
+
def plot_distribution_comparison(df: pl.DataFrame, medoids_df: pl.DataFrame,
|
|
124
|
+
cluster_col: str, target_col: str,
|
|
125
|
+
output_path: str = None, max_value: float = None):
|
|
126
|
+
"""
|
|
127
|
+
Plots histograms of the distribution of the target_col for the original data
|
|
128
|
+
and the medoids of the cluster, taking into account the size of the cluster as weights.
|
|
129
|
+
"""
|
|
130
|
+
# Original distribution
|
|
131
|
+
original_data = df.select(target_col).to_numpy().flatten()
|
|
132
|
+
|
|
133
|
+
# Medoids distribution with weights
|
|
134
|
+
# Calculate cluster sizes
|
|
135
|
+
cluster_counts = df.group_by(cluster_col).count()
|
|
136
|
+
|
|
137
|
+
# Join counts to medoids
|
|
138
|
+
medoids_with_counts = medoids_df.join(cluster_counts, on=cluster_col, how="left")
|
|
139
|
+
|
|
140
|
+
if max_value is not None:
|
|
141
|
+
medoids_with_counts = medoids_with_counts.filter(pl.col(target_col) <= max_value)
|
|
142
|
+
original_data = original_data[original_data <= max_value]
|
|
143
|
+
|
|
144
|
+
medoid_values = medoids_with_counts.select(target_col).to_numpy().flatten()
|
|
145
|
+
weights = medoids_with_counts.select("count").to_numpy().flatten()
|
|
146
|
+
|
|
147
|
+
plt.figure(figsize=(10, 6))
|
|
148
|
+
|
|
149
|
+
# Plot original data
|
|
150
|
+
plt.hist(original_data, bins=50, alpha=0.5, label='Original Data', density=True)
|
|
151
|
+
|
|
152
|
+
# Plot medoids weighted
|
|
153
|
+
plt.hist(medoid_values, bins=50, weights=weights, alpha=0.5, label='Medoids (Weighted)', density=True)
|
|
154
|
+
|
|
155
|
+
plt.xlabel(target_col)
|
|
156
|
+
plt.ylabel('Density')
|
|
157
|
+
plt.title(f'Distribution Comparison: {target_col}')
|
|
158
|
+
plt.legend()
|
|
159
|
+
|
|
160
|
+
if output_path:
|
|
161
|
+
plt.savefig(output_path)
|
|
162
|
+
plt.close()
|
|
163
|
+
else:
|
|
164
|
+
plt.show()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: baticlust
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Building stock archetype generation using stratified clustering
|
|
5
|
+
Author-email: Yassine Abdelouadoud <yassine.abdelouadoud@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://gitlab.com/energytransition/baticlust
|
|
8
|
+
Keywords: buildings,archetypes,clustering,energy,building stock
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: polars
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
Requires-Dist: faiss-cpu
|
|
19
|
+
Requires-Dist: fastcluster
|
|
20
|
+
Requires-Dist: scipy
|
|
21
|
+
Requires-Dist: matplotlib
|
|
22
|
+
Requires-Dist: scikit-learn
|
|
23
|
+
Requires-Dist: joblib
|
|
24
|
+
Requires-Dist: tqdm
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# **BatiClust 🏘️**
|
|
30
|
+
|
|
31
|
+
**BatiClust** is a Python framework designed to generate and validate building stock archetypes. It leverages unsupervised machine learning to segment residential dwellings into representative groups and provides a rigorous external validation suite using Energy Performance Certificate (EPC) data.
|
|
32
|
+
Unlike static typologies, **BatiClust** allows researchers to define archetypes at any level of granularity, quantifying the trade-off between model simplicity and its accuracy in representing national energy signatures.
|
|
33
|
+
|
|
34
|
+
It is built on top of **Polars** for high-performance data manipulation and uses **Faiss** and **Fastcluster** for efficient clustering.
|
|
35
|
+
|
|
36
|
+
## **Key Features**
|
|
37
|
+
|
|
38
|
+
* **Stratified Subsampling:** Automatically split large building databases into homogeneous subsamples based on categorical attributes (e.g., construction era, heating system) before clustering.
|
|
39
|
+
* **Scalable Clustering:** Implements flexible clustering algorithms (K-Means via Faiss, Hierarchical via Fastcluster) to identify natural archetypes within building stocks.
|
|
40
|
+
* **External Validation:** A dedicated module to compare cluster-derived distributions against real-world energy performance data.
|
|
41
|
+
* **Medoid Selection:** Identifies representative building archetypes (medoids) for each cluster.
|
|
42
|
+
|
|
43
|
+
## **Installation**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install baticlust
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Note: This package requires `polars`, `numpy`, `faiss-cpu`, `fastcluster`, `scipy`, `matplotlib`, and `scikit-learn`.
|
|
50
|
+
|
|
51
|
+
## **Quick Start**
|
|
52
|
+
|
|
53
|
+
### **1. Data Preparation**
|
|
54
|
+
|
|
55
|
+
Prepare your dataset with descriptive features and target performance metrics. BatiClust uses **Polars** DataFrames.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import polars as pl
|
|
59
|
+
from baticlust import scale_features
|
|
60
|
+
|
|
61
|
+
# Load your data
|
|
62
|
+
df = pl.read_parquet("housing_stock.parquet")
|
|
63
|
+
|
|
64
|
+
# Define features
|
|
65
|
+
strata_cols = ['construction_year_class', 'heating_energy_type']
|
|
66
|
+
features = ['living_area', 'wall_insulation_thickness']
|
|
67
|
+
|
|
68
|
+
# Scale numerical features
|
|
69
|
+
df_scaled = scale_features(df, features, method="standard")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### **2. Stratified Clustering**
|
|
73
|
+
|
|
74
|
+
BatiClust splits your database into strategic subsamples before applying clustering.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from baticlust import generate_archetypes
|
|
78
|
+
|
|
79
|
+
# Generate archetypes
|
|
80
|
+
clustered_df = generate_archetypes(
|
|
81
|
+
df_scaled,
|
|
82
|
+
strata_cols=strata_cols,
|
|
83
|
+
features=features,
|
|
84
|
+
method="kmeans",
|
|
85
|
+
total_n_clusters=1000,
|
|
86
|
+
niter=20,
|
|
87
|
+
verbose=True
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# The result is the original DataFrame with a new 'cluster_id' column
|
|
91
|
+
print(clustered_df.head())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### **3. Medoid Selection & Validation**
|
|
95
|
+
|
|
96
|
+
Identify representative archetypes and validate them against actual performance data.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from baticlust import select_medoids, validate_typology_with_medoids
|
|
100
|
+
|
|
101
|
+
# Create a unique cluster identifier if needed (combining strata and cluster_id)
|
|
102
|
+
clustered_df = clustered_df.with_columns(
|
|
103
|
+
pl.concat_str([pl.col(c) for c in strata_cols] + [pl.col("cluster_id").cast(pl.Utf8)], separator="_")
|
|
104
|
+
.alias("unique_cluster_id")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Select medoids (representative buildings)
|
|
108
|
+
medoids_df = select_medoids(clustered_df, "unique_cluster_id", features)
|
|
109
|
+
|
|
110
|
+
# Validate using a target column (e.g., energy consumption)
|
|
111
|
+
metrics = validate_typology_with_medoids(
|
|
112
|
+
clustered_df,
|
|
113
|
+
medoids_df,
|
|
114
|
+
cluster_col="unique_cluster_id",
|
|
115
|
+
target_col="energy_consumption"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
print(f"Wasserstein Distance: {metrics['wasserstein_distance']}")
|
|
119
|
+
print(f"KS Statistic: {metrics['ks_statistic']}")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## **Methodology**
|
|
123
|
+
|
|
124
|
+
The BatiClust workflow follows the methodology established in recent building stock research:
|
|
125
|
+
|
|
126
|
+
1. **Pre-processing:** Cleaning and scaling of building characteristics using Polars.
|
|
127
|
+
2. **Stratification:** Subdividing the stock into logical categories to ensure rare but important building types are not lost.
|
|
128
|
+
3. **Clustering:** Applying algorithms like K-Means (Faiss) or Hierarchical Clustering (Fastcluster) to find representative centroids within each stratum.
|
|
129
|
+
4. **Validation:** Using Kolmogorov-Smirnov tests and Wasserstein distance to verify if the clusters accurately mirror the energy performance distribution of the original stock.
|
|
130
|
+
|
|
131
|
+
## **Citing BatiClust**
|
|
132
|
+
|
|
133
|
+
If you use this package in your research, please cite the following paper:
|
|
134
|
+
*A Scalable Framework for Generating and Validating Dwelling Archetypes: A Clustering Approach for Energy Performance Analysis* (2026). \[Link to Paper\]
|
|
135
|
+
|
|
136
|
+
## **License**
|
|
137
|
+
|
|
138
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
baticlust/__init__.py,sha256=4f9mCJZrr3lj-w-g25zfAZrGWPHxQZMieUv08U0diG0,826
|
|
2
|
+
baticlust/clustering.py,sha256=f9tIS_Fsues6BK9qLyJi1YJSN4Y8lHRLWQqOljQzR-E,4853
|
|
3
|
+
baticlust/preprocessing.py,sha256=PeDbj6r9xMvfA64QRT1AGp_wWorob05BXhqUMO66G3E,837
|
|
4
|
+
baticlust/validation.py,sha256=9VLKHIO1ERExt_PnvhukJugsfyuYtNTbWzEKYHNIKPo,6316
|
|
5
|
+
baticlust-0.1.0.dist-info/licenses/LICENSE,sha256=wt5buagmQdj-zdsTpwY-IEOhYHda7UVdlD-fnvT3x1Q,1077
|
|
6
|
+
baticlust-0.1.0.dist-info/METADATA,sha256=rjq2qODON6IUhTo29N7fOYF4IEXAAEhHfepr1M9VSZ4,5359
|
|
7
|
+
baticlust-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
baticlust-0.1.0.dist-info/top_level.txt,sha256=4augevclK7P2_VvCRxiG9nAYWN6HfgelKpEyXQVrqtM,10
|
|
9
|
+
baticlust-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yassine Abdelouadoud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
baticlust
|