ccpfn 0.0.1__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.
ccpfn/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .continuous_causal_estimator import CEPOEstimator, Prescriber
2
+
3
+ __all__ = [
4
+ "CEPOEstimator",
5
+ "Prescriber",
6
+ "calculate_rmse",
7
+ ]
@@ -0,0 +1,295 @@
1
+ import math
2
+ import os
3
+ from abc import ABC
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import torch
8
+ from huggingface_hub import hf_hub_download
9
+ from sklearn.decomposition import TruncatedSVD
10
+ from sklearn.preprocessing import FunctionTransformer
11
+ from tqdm import tqdm
12
+
13
+ from .models import ContinuousInContextModel
14
+
15
+ def is_hf_model_path(model_path: str) -> bool:
16
+ """
17
+ Check if a given path is a Hugging Face model path (repo_id/model_name).
18
+
19
+ Args:
20
+ model_path: The model path to check
21
+
22
+ Returns:
23
+ bool: True if it's a Hugging Face model path, False otherwise
24
+ """
25
+ # Check if path doesn't exist locally but follows the pattern of org/repo or user/repo
26
+ if not os.path.exists(model_path) and "/" in model_path and model_path.count("/") == 1:
27
+ return True
28
+ return False
29
+
30
+ def download_from_hf_hub(model_path: str, cache_dir: str) -> str:
31
+ """
32
+ Download a model from the Hugging Face Hub.
33
+
34
+ Args:
35
+ model_path: The model path in format 'org/repo' or 'user/repo'
36
+ cache_dir: Optional directory to cache the downloaded model
37
+
38
+ Returns:
39
+ str: Path to the downloaded model file
40
+ """
41
+ # Download the model
42
+ local_path = hf_hub_download(
43
+ repo_id=model_path,
44
+ filename="ccpfn_v1.pt",
45
+ cache_dir=cache_dir,
46
+ )
47
+ return local_path
48
+
49
+
50
+ class ContinuousCausalEstimator(ABC):
51
+ def __init__(
52
+ self,
53
+ device: str,
54
+ model_path: str = "Layer6/CCPFN",
55
+ max_context_length: int = 4096,
56
+ max_query_length: int = 4096,
57
+ verbose: bool = False,
58
+ cache_dir: str | None = None,
59
+ icl_model: ContinuousInContextModel | None = None,
60
+ ):
61
+ self.model_path = model_path
62
+ self.cache_dir = cache_dir if cache_dir is not None else os.path.join(Path.home(), ".cache", "ccpfn")
63
+ self.icl_model: ContinuousInContextModel = icl_model
64
+
65
+ self.device = device
66
+ self.max_context_length = max_context_length
67
+ self.max_query_length = max_query_length
68
+
69
+ # The maximum number of features to use for the model. If the number of features are
70
+ # larger than this value, the model will apply PCA to reduce the dimensionality.
71
+ self.max_feature_size = None
72
+ self.x_dim_transformer = FunctionTransformer() # identity transformer by default
73
+
74
+ self.X_train, self.t_train, self.y_train = None, None, None
75
+ self.temperature = 1.0
76
+ self.prediction_temperature = 1.0
77
+
78
+ self.verbose = verbose
79
+
80
+ def _check_fitted(self):
81
+ if self.X_train is None or self.t_train is None or self.y_train is None or self.icl_model is None:
82
+ raise ValueError("The estimator must be fitted before calling the estimate function.")
83
+
84
+ def load_model(self):
85
+ """
86
+ Load the model from the specified path or download it from Hugging Face.
87
+ """
88
+ if self.model_path is not None:
89
+ model_path = self.model_path
90
+
91
+ # Check if the model path is a Hugging Face model path
92
+ if is_hf_model_path(model_path):
93
+ model_path = download_from_hf_hub(model_path, self.cache_dir)
94
+
95
+ # Load the model from the local path
96
+ ckpt = torch.load(model_path, weights_only=False, map_location="cpu")
97
+ model_state = ckpt["model_state_dict"]
98
+ config = ckpt["model_config"]
99
+
100
+ self.icl_model = ContinuousInContextModel.load(model_state=model_state, model_config=config).to(self.device)
101
+ elif self.icl_model is not None:
102
+ # If icl_model is provided, use it directly
103
+ self.icl_model.to(self.device)
104
+ config = self.icl_model.model_config
105
+ else:
106
+ raise ValueError("Either model_path or icl_model must be provided.")
107
+
108
+ if config["model_type"] == "tabdpt":
109
+ self.max_feature_size = config["model"]["max_num_covariates"]
110
+ self.x_dim_transformer = TruncatedSVD(n_components=self.max_feature_size, algorithm="arpack")
111
+
112
+ @torch.no_grad()
113
+ def _predict_cepo(
114
+ self,
115
+ X_context: np.ndarray,
116
+ t_context: np.ndarray,
117
+ y_context: np.ndarray,
118
+ X_query: np.ndarray,
119
+ t_query: np.ndarray,
120
+ temperature: float,
121
+ n_samples: int | None = None,
122
+ seed: int | None = None,
123
+ ) -> np.ndarray:
124
+ if self.icl_model is None:
125
+ raise ValueError("CausalEstimator must be fitted before calling _predict_cepo.")
126
+
127
+ temperature = torch.tensor([temperature], device=self.device)
128
+ self.icl_model: ContinuousInContextModel
129
+ self.icl_model.eval()
130
+
131
+ # list all of the point estimates as well as distributional estimates
132
+ # of the CEPO in all_cepo and all_samples, respectively
133
+ all_cepo = np.zeros((X_query.shape[0],), dtype=X_query.dtype)
134
+ if n_samples is not None:
135
+ all_samples = np.zeros((X_query.shape[0], n_samples), dtype=X_query.dtype)
136
+
137
+ if X_context.shape[0] > self.max_context_length:
138
+ if seed is not None:
139
+ np.random.seed(seed)
140
+ idx_c = np.random.choice(X_context.shape[0], self.max_context_length)
141
+ x_c = X_context[idx_c]
142
+ t_c = t_context[idx_c]
143
+ y_c = y_context[idx_c]
144
+ else:
145
+ x_c = X_context
146
+ t_c = t_context
147
+ y_c = y_context
148
+
149
+ # If the query is large, we split it into batches
150
+ pbar = tqdm(range(X_query.shape[0]), desc="Predicting CEPO", total=X_query.shape[0], disable=not self.verbose)
151
+ start_idx = 0
152
+ while start_idx < X_query.shape[0]:
153
+ end_idx = min(start_idx + self.max_query_length, X_query.shape[0])
154
+ x_q = X_query[start_idx:end_idx]
155
+ t_q = t_query[start_idx:end_idx]
156
+ res = self.icl_model.predict_cepo(
157
+ # shape: (1, context_size, num_features)
158
+ X_context=torch.from_numpy(x_c).to(self.device).unsqueeze(0).float(),
159
+ # shape: (1, context_size)
160
+ t_context=torch.from_numpy(t_c).to(self.device).unsqueeze(0).float(),
161
+ y_context=torch.from_numpy(y_c).to(self.device).unsqueeze(0).float(),
162
+ # shape: (1, query_size, num_features)
163
+ X_query=torch.from_numpy(x_q).to(self.device).unsqueeze(0).float(),
164
+ # shape: (1, query_size)
165
+ t_query=torch.from_numpy(t_q).to(self.device).unsqueeze(0).float(),
166
+ n_samples=n_samples,
167
+ temperature=temperature,
168
+ )
169
+ if n_samples is None:
170
+ cepo = res.squeeze(0).squeeze(0) # shape: (query_size,)
171
+ else:
172
+ cepo, samples = (
173
+ res[0].squeeze(0).squeeze(0),
174
+ res[1].squeeze(0).squeeze(0),
175
+ ) # shapes: (query_size,), (query_size, n_samples)
176
+ all_samples[start_idx:end_idx] = samples.cpu().numpy()
177
+ all_cepo[start_idx:end_idx] = cepo.cpu().numpy()
178
+ pbar.update(end_idx - start_idx)
179
+ start_idx = end_idx
180
+ pbar.close()
181
+
182
+ if n_samples is not None:
183
+ return all_cepo, all_samples
184
+
185
+ return all_cepo
186
+
187
+ def fit(self, X: np.ndarray, t: np.ndarray, y: np.ndarray) -> "ContinuousCausalEstimator":
188
+ """
189
+ Fit the model using the provided data.
190
+
191
+ Args:
192
+ X (np.ndarray): The observational covariate data with shape [N, D].
193
+ t (np.ndarray): The observational treatment data with shape [N].
194
+ y (np.ndarray): The observational outcome data with shape [N].
195
+ """
196
+ self.temperature = 1.0
197
+
198
+ # load the model
199
+ self.load_model()
200
+
201
+ # set the x_dim_transform and transform the data
202
+ if self.max_feature_size is not None and X.shape[1] > self.max_feature_size:
203
+ X = self.x_dim_transformer.fit_transform(X)
204
+
205
+ self.X_train = X
206
+ self.t_train = t
207
+ self.y_train = y
208
+
209
+ return self
210
+
211
+ class CEPOEstimator(ContinuousCausalEstimator):
212
+ def estimate_cepo(
213
+ self,
214
+ X: np.ndarray,
215
+ t: np.ndarray
216
+ ) -> np.ndarray:
217
+ """
218
+ Estimate the conditional expected potential outcome (CEPO) using the fitted model.
219
+
220
+ Args:
221
+ X (np.ndarray): The input data (covariates) with shape [N', D]
222
+ t (np.ndarray): The input data (treatment values) with shape [N']
223
+
224
+ Returns:
225
+ all_cepo: The 1-D array of all expected potential outcomes mu_t(X), where X =
226
+ input covariates, t = input treatments.
227
+ """
228
+ self._check_fitted()
229
+
230
+ X_context = self.X_train
231
+ t_context = self.t_train
232
+ y_context = self.y_train
233
+ X_query = X
234
+ if self.max_feature_size is not None and X_query.shape[1] > self.max_feature_size:
235
+ X_query = self.x_dim_transformer.transform(X_query)
236
+
237
+ t_query = t
238
+
239
+ all_cepo = self._predict_cepo(
240
+ X_context=X_context,
241
+ t_context=t_context,
242
+ y_context=y_context,
243
+ X_query=X_query,
244
+ t_query=t_query,
245
+ temperature=self.prediction_temperature
246
+ )
247
+
248
+ return all_cepo
249
+
250
+ class Prescriber(ContinuousCausalEstimator):
251
+ def estimate_optimal_treatment(
252
+ self,
253
+ X: np.ndarray,
254
+ n_grid: int,
255
+ t_min: float,
256
+ t_max: float,
257
+ lower_is_better: bool,
258
+ ) -> np.ndarray:
259
+ """
260
+ For each row of X, evaluate the model's CEPO on a uniform grid of
261
+ n_grid treatments in [t_min, t_max] and return the argmin/argmax.
262
+
263
+ Args:
264
+ X (np.ndarray): Covariates with shape [N, D].
265
+ n_grid (int): Number of grid points in [t_min, t_max].
266
+ t_min (float): Lower bound of treatment grid.
267
+ t_max (float): Upper bound of treatment grid.
268
+ lower_is_better (bool): If True, choose the treatment minimizing CEPO,
269
+ otherwise the maximizing one.
270
+
271
+ Returns:
272
+ t_hat (np.ndarray): Per-row optimal treatment, shape [N].
273
+ """
274
+ self._check_fitted()
275
+ if n_grid < 2:
276
+ raise ValueError(f"n_grid must be >= 2, got {n_grid}")
277
+
278
+ t_grid = np.linspace(t_min, t_max, n_grid)
279
+ n = X.shape[0]
280
+ if self.max_feature_size is not None and X.shape[1] > self.max_feature_size:
281
+ X = self.x_dim_transformer.transform(X)
282
+ X_query = np.repeat(X, n_grid, axis=0)
283
+ t_query = np.tile(t_grid, n)
284
+
285
+ all_cepo = self._predict_cepo(
286
+ X_context=self.X_train,
287
+ t_context=self.t_train,
288
+ y_context=self.y_train,
289
+ X_query=X_query,
290
+ t_query=t_query,
291
+ temperature=self.prediction_temperature,
292
+ ).reshape(n, n_grid)
293
+
294
+ best = all_cepo.argmin(axis=1) if lower_is_better else all_cepo.argmax(axis=1)
295
+ return t_grid[best]
@@ -0,0 +1,2 @@
1
+ from .continuous_icl_model import ContinuousInContextModel
2
+ from .model import TabDPTLongContextModel