baticlust 0.1.0__tar.gz

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.
@@ -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,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,110 @@
1
+ # **BatiClust 🏘️**
2
+
3
+ **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.
4
+ 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.
5
+
6
+ It is built on top of **Polars** for high-performance data manipulation and uses **Faiss** and **Fastcluster** for efficient clustering.
7
+
8
+ ## **Key Features**
9
+
10
+ * **Stratified Subsampling:** Automatically split large building databases into homogeneous subsamples based on categorical attributes (e.g., construction era, heating system) before clustering.
11
+ * **Scalable Clustering:** Implements flexible clustering algorithms (K-Means via Faiss, Hierarchical via Fastcluster) to identify natural archetypes within building stocks.
12
+ * **External Validation:** A dedicated module to compare cluster-derived distributions against real-world energy performance data.
13
+ * **Medoid Selection:** Identifies representative building archetypes (medoids) for each cluster.
14
+
15
+ ## **Installation**
16
+
17
+ ```bash
18
+ pip install baticlust
19
+ ```
20
+
21
+ Note: This package requires `polars`, `numpy`, `faiss-cpu`, `fastcluster`, `scipy`, `matplotlib`, and `scikit-learn`.
22
+
23
+ ## **Quick Start**
24
+
25
+ ### **1. Data Preparation**
26
+
27
+ Prepare your dataset with descriptive features and target performance metrics. BatiClust uses **Polars** DataFrames.
28
+
29
+ ```python
30
+ import polars as pl
31
+ from baticlust import scale_features
32
+
33
+ # Load your data
34
+ df = pl.read_parquet("housing_stock.parquet")
35
+
36
+ # Define features
37
+ strata_cols = ['construction_year_class', 'heating_energy_type']
38
+ features = ['living_area', 'wall_insulation_thickness']
39
+
40
+ # Scale numerical features
41
+ df_scaled = scale_features(df, features, method="standard")
42
+ ```
43
+
44
+ ### **2. Stratified Clustering**
45
+
46
+ BatiClust splits your database into strategic subsamples before applying clustering.
47
+
48
+ ```python
49
+ from baticlust import generate_archetypes
50
+
51
+ # Generate archetypes
52
+ clustered_df = generate_archetypes(
53
+ df_scaled,
54
+ strata_cols=strata_cols,
55
+ features=features,
56
+ method="kmeans",
57
+ total_n_clusters=1000,
58
+ niter=20,
59
+ verbose=True
60
+ )
61
+
62
+ # The result is the original DataFrame with a new 'cluster_id' column
63
+ print(clustered_df.head())
64
+ ```
65
+
66
+ ### **3. Medoid Selection & Validation**
67
+
68
+ Identify representative archetypes and validate them against actual performance data.
69
+
70
+ ```python
71
+ from baticlust import select_medoids, validate_typology_with_medoids
72
+
73
+ # Create a unique cluster identifier if needed (combining strata and cluster_id)
74
+ clustered_df = clustered_df.with_columns(
75
+ pl.concat_str([pl.col(c) for c in strata_cols] + [pl.col("cluster_id").cast(pl.Utf8)], separator="_")
76
+ .alias("unique_cluster_id")
77
+ )
78
+
79
+ # Select medoids (representative buildings)
80
+ medoids_df = select_medoids(clustered_df, "unique_cluster_id", features)
81
+
82
+ # Validate using a target column (e.g., energy consumption)
83
+ metrics = validate_typology_with_medoids(
84
+ clustered_df,
85
+ medoids_df,
86
+ cluster_col="unique_cluster_id",
87
+ target_col="energy_consumption"
88
+ )
89
+
90
+ print(f"Wasserstein Distance: {metrics['wasserstein_distance']}")
91
+ print(f"KS Statistic: {metrics['ks_statistic']}")
92
+ ```
93
+
94
+ ## **Methodology**
95
+
96
+ The BatiClust workflow follows the methodology established in recent building stock research:
97
+
98
+ 1. **Pre-processing:** Cleaning and scaling of building characteristics using Polars.
99
+ 2. **Stratification:** Subdividing the stock into logical categories to ensure rare but important building types are not lost.
100
+ 3. **Clustering:** Applying algorithms like K-Means (Faiss) or Hierarchical Clustering (Fastcluster) to find representative centroids within each stratum.
101
+ 4. **Validation:** Using Kolmogorov-Smirnov tests and Wasserstein distance to verify if the clusters accurately mirror the energy performance distribution of the original stock.
102
+
103
+ ## **Citing BatiClust**
104
+
105
+ If you use this package in your research, please cite the following paper:
106
+ *A Scalable Framework for Generating and Validating Dwelling Archetypes: A Clustering Approach for Energy Performance Analysis* (2026). \[Link to Paper\]
107
+
108
+ ## **License**
109
+
110
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -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
+ }
@@ -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)
@@ -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,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ baticlust/__init__.py
5
+ baticlust/clustering.py
6
+ baticlust/preprocessing.py
7
+ baticlust/validation.py
8
+ baticlust.egg-info/PKG-INFO
9
+ baticlust.egg-info/SOURCES.txt
10
+ baticlust.egg-info/dependency_links.txt
11
+ baticlust.egg-info/requires.txt
12
+ baticlust.egg-info/top_level.txt
@@ -0,0 +1,12 @@
1
+ polars
2
+ numpy
3
+ faiss-cpu
4
+ fastcluster
5
+ scipy
6
+ matplotlib
7
+ scikit-learn
8
+ joblib
9
+ tqdm
10
+
11
+ [dev]
12
+ pytest
@@ -0,0 +1 @@
1
+ baticlust
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "baticlust"
7
+ version = "0.1.0"
8
+ description = "Building stock archetype generation using stratified clustering"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ {name = "Yassine Abdelouadoud", email = "yassine.abdelouadoud@gmail.com"}
15
+ ]
16
+ keywords = ["buildings", "archetypes", "clustering", "energy", "building stock"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Scientific/Engineering",
22
+ ]
23
+ dependencies = [
24
+ "polars",
25
+ "numpy",
26
+ "faiss-cpu",
27
+ "fastcluster",
28
+ "scipy",
29
+ "matplotlib",
30
+ "scikit-learn",
31
+ "joblib",
32
+ "tqdm",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest",
38
+ ]
39
+
40
+ [project.urls]
41
+ Repository = "https://gitlab.com/energytransition/baticlust"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["."]
45
+ include = ["baticlust*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+