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 +7 -0
- ccpfn/continuous_causal_estimator.py +295 -0
- ccpfn/models/__init__.py +2 -0
- ccpfn/models/continuous_icl_model.py +405 -0
- ccpfn/models/loading.py +53 -0
- ccpfn/models/model.py +223 -0
- ccpfn/models/transformer_layer.py +84 -0
- ccpfn/models/utils.py +20 -0
- ccpfn-0.0.1.dist-info/METADATA +51 -0
- ccpfn-0.0.1.dist-info/RECORD +12 -0
- ccpfn-0.0.1.dist-info/WHEEL +4 -0
- ccpfn-0.0.1.dist-info/licenses/LICENSE +201 -0
ccpfn/__init__.py
ADDED
|
@@ -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]
|
ccpfn/models/__init__.py
ADDED