augmask 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.
- augmask/__init__.py +5 -0
- augmask/augment.py +192 -0
- augmask/baselines/__init__.py +1 -0
- augmask/baselines/forestdiff.py +73 -0
- augmask/baselines/miwae.py +110 -0
- augmask/data/__init__.py +7 -0
- augmask/data/datasets.py +190 -0
- augmask/data/missingness.py +96 -0
- augmask/data/scenario.py +221 -0
- augmask/data/schema.py +60 -0
- augmask/data/split.py +32 -0
- augmask/data/transforms.py +78 -0
- augmask/evaluate.py +164 -0
- augmask/models/__init__.py +1 -0
- augmask/models/cdtd.py +301 -0
- augmask/models/common.py +63 -0
- augmask/models/tabddpm.py +211 -0
- augmask/models/tabdiff.py +223 -0
- augmask/run.py +122 -0
- augmask-0.1.0.dist-info/METADATA +326 -0
- augmask-0.1.0.dist-info/RECORD +24 -0
- augmask-0.1.0.dist-info/WHEEL +5 -0
- augmask-0.1.0.dist-info/licenses/LICENSE +21 -0
- augmask-0.1.0.dist-info/top_level.txt +1 -0
augmask/__init__.py
ADDED
augmask/augment.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Step 5: fill the missing entries of the (encoded) inputs.
|
|
2
|
+
|
|
3
|
+
``LGBMSAugmenter`` is the paper's factorized conditional stochastic augmentation
|
|
4
|
+
(Algorithm 1, "LGBM-S"); ``MeanModeFill`` is the mean/mode fill of NoAug and MeanMask.
|
|
5
|
+
Any object with the ``Augmenter`` interface can be used instead.
|
|
6
|
+
Both are fit on the train split and fill train and any further splits (val).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from typing import NamedTuple, Protocol, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from lightgbm import LGBMClassifier, LGBMRegressor
|
|
17
|
+
|
|
18
|
+
# -E[log chi^2_1]: for Gaussian residuals r, E[log r^2] = log sigma^2 - LOG_CHI2_BIAS
|
|
19
|
+
LOG_CHI2_BIAS = float(np.euler_gamma + np.log(2))
|
|
20
|
+
|
|
21
|
+
# Table 4 of the paper; the last four entries only set threading, determinism and logging.
|
|
22
|
+
LGBM_PARAMS = dict(
|
|
23
|
+
boosting_type="gbdt",
|
|
24
|
+
num_leaves=31,
|
|
25
|
+
max_depth=-1,
|
|
26
|
+
learning_rate=0.1,
|
|
27
|
+
n_estimators=50,
|
|
28
|
+
subsample_for_bin=200000,
|
|
29
|
+
min_split_gain=0.0,
|
|
30
|
+
min_child_weight=1e-3,
|
|
31
|
+
min_child_samples=20,
|
|
32
|
+
subsample=1.0,
|
|
33
|
+
subsample_freq=0,
|
|
34
|
+
colsample_bytree=1.0,
|
|
35
|
+
reg_alpha=0.0,
|
|
36
|
+
reg_lambda=0.0,
|
|
37
|
+
random_state=42,
|
|
38
|
+
importance_type="split",
|
|
39
|
+
n_jobs=1,
|
|
40
|
+
deterministic=True,
|
|
41
|
+
force_row_wise=True,
|
|
42
|
+
verbosity=-1,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Encoded(NamedTuple):
|
|
47
|
+
"""One split in encoded space. Masks are True where observed."""
|
|
48
|
+
|
|
49
|
+
X_num: np.ndarray # float, quantile-normal values (NaN where missing)
|
|
50
|
+
X_cat: np.ndarray # int codes 0..K_j-1 (-1 where missing)
|
|
51
|
+
M_num: np.ndarray # bool
|
|
52
|
+
M_cat: np.ndarray # bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Augmenter(Protocol):
|
|
56
|
+
"""Fills the missing entries: fit on ``train``, then return filled (X_num, X_cat) for ``train``
|
|
57
|
+
followed by each of ``others``. Observed entries stay unchanged; a missing numerical entry gets a
|
|
58
|
+
float and a missing categorical entry a code in 0..K_j-1. With several fills per scenario
|
|
59
|
+
(``n_fills`` > 1), ``augment`` is called once per fill and should return a new random draw each time."""
|
|
60
|
+
|
|
61
|
+
def augment(self, train: Encoded, others: Sequence[Encoded], n_categories: Sequence[int]
|
|
62
|
+
) -> list[tuple[np.ndarray, np.ndarray]]: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MeanModeFill:
|
|
66
|
+
"""Train mean (numerical) and train mode (categorical) of the observed entries."""
|
|
67
|
+
|
|
68
|
+
def augment(self, train: Encoded, others: Sequence[Encoded], n_categories: Sequence[int]
|
|
69
|
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
70
|
+
means = np.nan_to_num(np.nanmean(np.where(train.M_num, train.X_num, np.nan), axis=0))
|
|
71
|
+
modes = np.array([np.bincount(train.X_cat[train.M_cat[:, k], k], minlength=max(K, 1)).argmax()
|
|
72
|
+
for k, K in enumerate(n_categories)], dtype=np.int64)
|
|
73
|
+
return [(np.where(b.M_num, b.X_num, means), np.where(b.M_cat, b.X_cat, modes)) for b in (train, *others)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _design(block: Encoded, n_categories: Sequence[int]) -> pd.DataFrame:
|
|
77
|
+
"""LightGBM inputs u = (x~, m): values with NaN where missing, plus mask indicators."""
|
|
78
|
+
cols = {}
|
|
79
|
+
for k in range(block.X_num.shape[1]):
|
|
80
|
+
cols[f"n{k}"] = np.where(block.M_num[:, k], block.X_num[:, k], np.nan)
|
|
81
|
+
for k, K in enumerate(n_categories):
|
|
82
|
+
codes = np.where(block.M_cat[:, k], block.X_cat[:, k], -1)
|
|
83
|
+
cols[f"c{k}"] = pd.Categorical.from_codes(codes, categories=np.arange(K))
|
|
84
|
+
for k in range(block.M_num.shape[1]):
|
|
85
|
+
cols[f"mn{k}"] = block.M_num[:, k].astype(np.uint8)
|
|
86
|
+
for k in range(block.M_cat.shape[1]):
|
|
87
|
+
cols[f"mc{k}"] = block.M_cat[:, k].astype(np.uint8)
|
|
88
|
+
return pd.DataFrame(cols)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LGBMSAugmenter:
|
|
92
|
+
"""Factorized conditional stochastic augmentation with per-feature LightGBM models.
|
|
93
|
+
|
|
94
|
+
For each feature j, models are fit on the train rows where j is observed, with
|
|
95
|
+
inputs u = (x~_{-j}, m_{-j}). Numerical features are sampled from
|
|
96
|
+
N(mu_j(u), exp(f_j(u) + c)), where f_j regresses log(r^2 + eps) on u and r are the
|
|
97
|
+
residuals of mu_j. The default c = 0 is Algorithm 1; ``log_var_offset=LOG_CHI2_BIAS``
|
|
98
|
+
removes the bias of the log-scale fit (Harvey, 1976), which widens the fills by about
|
|
99
|
+
1.9x without consistently improving the generators. Categorical features are sampled from the predicted class
|
|
100
|
+
probabilities after label smoothing with alpha over the K_j categories.
|
|
101
|
+
Every model conditions on the incomplete data as observed (no chaining).
|
|
102
|
+
Features are fit in parallel on ``n_jobs`` threads (-1: all cores); the fills do not depend on it.
|
|
103
|
+
``draw`` indexes independent fills from the same models (draw 0 is the default stream).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, alpha: float = 0.05, eps: float = 1e-6, log_var_offset: float = 0.0,
|
|
107
|
+
seed: int = 42, n_jobs: int = -1, lgbm_params: dict | None = None, draw: int = 0):
|
|
108
|
+
self.alpha = alpha
|
|
109
|
+
self.eps = eps
|
|
110
|
+
self.log_var_offset = log_var_offset
|
|
111
|
+
self.seed = seed
|
|
112
|
+
self.draw = draw
|
|
113
|
+
self.n_jobs = (os.cpu_count() or 1) if n_jobs == -1 else n_jobs
|
|
114
|
+
self.lgbm_params = {**LGBM_PARAMS, **(lgbm_params or {})}
|
|
115
|
+
|
|
116
|
+
def augment(self, train: Encoded, others: Sequence[Encoded], n_categories: Sequence[int]
|
|
117
|
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
118
|
+
"""Return filled (X_num, X_cat) for ``train`` followed by each of ``others``."""
|
|
119
|
+
blocks = [train, *others]
|
|
120
|
+
designs = [_design(b, n_categories) for b in blocks]
|
|
121
|
+
d_num = train.X_num.shape[1]
|
|
122
|
+
features = [("num", k) for k in range(d_num)] + [("cat", k) for k in range(len(n_categories))]
|
|
123
|
+
root = np.random.SeedSequence(self.seed if self.draw == 0 else [self.seed, self.draw])
|
|
124
|
+
rngs = [np.random.default_rng(s) for s in root.spawn(len(features))]
|
|
125
|
+
|
|
126
|
+
def run(i):
|
|
127
|
+
kind, k = features[i]
|
|
128
|
+
return self._fill_feature(kind, k, blocks, designs, n_categories, rngs[i])
|
|
129
|
+
|
|
130
|
+
if self.n_jobs > 1:
|
|
131
|
+
with ThreadPoolExecutor(self.n_jobs) as pool:
|
|
132
|
+
results = list(pool.map(run, range(len(features))))
|
|
133
|
+
else:
|
|
134
|
+
results = [run(i) for i in range(len(features))]
|
|
135
|
+
|
|
136
|
+
out = []
|
|
137
|
+
for b_idx, block in enumerate(blocks):
|
|
138
|
+
X_num = np.where(block.M_num, block.X_num, np.nan)
|
|
139
|
+
X_cat = np.where(block.M_cat, block.X_cat, -1)
|
|
140
|
+
for (kind, k), fills in zip(features, results):
|
|
141
|
+
if fills is None:
|
|
142
|
+
continue
|
|
143
|
+
if kind == "num":
|
|
144
|
+
X_num[~block.M_num[:, k], k] = fills[b_idx]
|
|
145
|
+
else:
|
|
146
|
+
X_cat[~block.M_cat[:, k], k] = fills[b_idx]
|
|
147
|
+
out.append((X_num, X_cat))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
def _fill_feature(self, kind, k, blocks, designs, n_categories, rng):
|
|
151
|
+
num = kind == "num"
|
|
152
|
+
name = f"n{k}" if num else f"c{k}"
|
|
153
|
+
drop = [name, f"m{name}"]
|
|
154
|
+
masks = [b.M_num[:, k] if num else b.M_cat[:, k] for b in blocks]
|
|
155
|
+
if not any((~m).any() for m in masks):
|
|
156
|
+
return None
|
|
157
|
+
obs = masks[0]
|
|
158
|
+
X_fit = designs[0].loc[obs].drop(columns=drop)
|
|
159
|
+
X_miss = [d.loc[~m].drop(columns=drop) for d, m in zip(designs, masks)]
|
|
160
|
+
|
|
161
|
+
if num:
|
|
162
|
+
y = blocks[0].X_num[obs, k]
|
|
163
|
+
mu_model = LGBMRegressor(**self.lgbm_params).fit(X_fit, y)
|
|
164
|
+
log_var = np.log((y - mu_model.predict(X_fit)) ** 2 + self.eps)
|
|
165
|
+
var_model = LGBMRegressor(**self.lgbm_params).fit(X_fit, log_var)
|
|
166
|
+
fills = []
|
|
167
|
+
for X in X_miss:
|
|
168
|
+
if len(X) == 0:
|
|
169
|
+
fills.append(np.empty(0))
|
|
170
|
+
continue
|
|
171
|
+
mu, sigma = mu_model.predict(X), np.exp(0.5 * (var_model.predict(X) + self.log_var_offset))
|
|
172
|
+
fills.append(mu + sigma * rng.standard_normal(len(X)))
|
|
173
|
+
return fills
|
|
174
|
+
|
|
175
|
+
K = n_categories[k]
|
|
176
|
+
y = blocks[0].X_cat[obs, k]
|
|
177
|
+
classes = np.unique(y)
|
|
178
|
+
clf = LGBMClassifier(**self.lgbm_params).fit(X_fit, y) if len(classes) > 1 else None
|
|
179
|
+
fills = []
|
|
180
|
+
for X in X_miss:
|
|
181
|
+
if len(X) == 0:
|
|
182
|
+
fills.append(np.empty(0, dtype=np.int64))
|
|
183
|
+
continue
|
|
184
|
+
probs = np.zeros((len(X), K))
|
|
185
|
+
if clf is None:
|
|
186
|
+
probs[:, classes[0]] = 1.0
|
|
187
|
+
else:
|
|
188
|
+
probs[:, clf.classes_] = clf.predict_proba(X)
|
|
189
|
+
probs = (1 - self.alpha) * probs + self.alpha / K
|
|
190
|
+
u = rng.random(len(X))[:, None]
|
|
191
|
+
fills.append(np.minimum((probs.cumsum(axis=1) < u).sum(axis=1), K - 1))
|
|
192
|
+
return fills
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Missing-aware baselines: ForestDiff (official package) and MIWAE."""
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""ForestDiff baseline: the official ForestDiffusion package with the paper's settings.
|
|
2
|
+
|
|
3
|
+
Appendix C.6: n_t = 20 and duplicate_K = 10 (the defaults 50/100 did not finish
|
|
4
|
+
within 3600 s); on News the training data is streamed in chunks of 8,000 rows.
|
|
5
|
+
Everything else uses the package defaults (flow matching, XGBoost hyperparameters).
|
|
6
|
+
The model is trained in data space on the incomplete train table (NaN = missing).
|
|
7
|
+
|
|
8
|
+
One fix: the package one-hot encodes categoricals with >= 3 levels via
|
|
9
|
+
pd.get_dummies, which turns a missing entry into the reference level. We keep
|
|
10
|
+
those entries missing (NaN in every dummy), so they are skipped as targets and
|
|
11
|
+
treated as missing inputs, as for numerical and binary columns.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from ..data.schema import Schema
|
|
21
|
+
|
|
22
|
+
CHUNK_SIZE = {"news": 8000}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _model_class():
|
|
26
|
+
from ForestDiffusion import ForestDiffusionModel
|
|
27
|
+
|
|
28
|
+
class _Model(ForestDiffusionModel):
|
|
29
|
+
def dummify(self, X):
|
|
30
|
+
out, before, after = super().dummify(X)
|
|
31
|
+
for i in self.cat_indexes:
|
|
32
|
+
dummies = [k for k, name in enumerate(after) if name.startswith(f"{i}_")]
|
|
33
|
+
out[np.ix_(np.isnan(X[:, i]), dummies)] = np.nan
|
|
34
|
+
return out, before, after
|
|
35
|
+
|
|
36
|
+
return _Model
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ForestDiff:
|
|
40
|
+
def __init__(self, n_t: int = 20, duplicate_K: int = 10, chunk_size: int | None = None,
|
|
41
|
+
n_jobs: int = -1, seed: int = 0, **forest_kwargs):
|
|
42
|
+
self.n_t, self.duplicate_K, self.chunk_size = n_t, duplicate_K, chunk_size
|
|
43
|
+
# one XGBoost thread per model: the package already runs n_jobs models in parallel
|
|
44
|
+
self.n_jobs, self.seed, self.forest_kwargs = n_jobs, seed, {"nthread": 1, **forest_kwargs}
|
|
45
|
+
|
|
46
|
+
def fit(self, table: pd.DataFrame, schema: Schema) -> "ForestDiff":
|
|
47
|
+
self.schema = schema
|
|
48
|
+
num, cat = list(schema.num_block), list(schema.cat_block)
|
|
49
|
+
self.vocab_ = [sorted(table[c].dropna().unique().tolist()) for c in cat]
|
|
50
|
+
codes = [table[c].map({v: i for i, v in enumerate(voc)}).to_numpy(np.float64)
|
|
51
|
+
for c, voc in zip(cat, self.vocab_)]
|
|
52
|
+
X = np.column_stack([table[num].to_numpy(np.float64), *codes]) if codes else table[num].to_numpy(np.float64)
|
|
53
|
+
d_num = len(num)
|
|
54
|
+
K = [len(v) for v in self.vocab_]
|
|
55
|
+
int_idx = [j for j, c in enumerate(num) if (table[c].dropna() % 1 == 0).all()] # rounded when sampling
|
|
56
|
+
bin_idx = [d_num + k for k, n in enumerate(K) if n <= 2]
|
|
57
|
+
cat_idx = [d_num + k for k, n in enumerate(K) if n > 2]
|
|
58
|
+
n_batch = math.ceil(len(X) / self.chunk_size) if self.chunk_size else 1 # the package default
|
|
59
|
+
self.model_ = _model_class()(
|
|
60
|
+
X, n_t=self.n_t, duplicate_K=self.duplicate_K, bin_indexes=bin_idx, cat_indexes=cat_idx, int_indexes=int_idx,
|
|
61
|
+
n_jobs=self.n_jobs, n_batch=n_batch, seed=self.seed, **self.forest_kwargs,
|
|
62
|
+
)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def sample(self, n: int) -> pd.DataFrame:
|
|
66
|
+
np.random.seed(self.seed)
|
|
67
|
+
Xs = self.model_.generate(batch_size=n)
|
|
68
|
+
d_num = len(self.schema.num_block)
|
|
69
|
+
out = pd.DataFrame(Xs[:, :d_num], columns=list(self.schema.num_block))
|
|
70
|
+
for k, c in enumerate(self.schema.cat_block):
|
|
71
|
+
codes = np.clip(np.rint(Xs[:, d_num + k]).astype(np.int64), 0, len(self.vocab_[k]) - 1)
|
|
72
|
+
out[c] = np.asarray(self.vocab_[k], dtype=object)[codes]
|
|
73
|
+
return out[list(self.schema.columns)]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""MIWAE baseline (Mattei & Frellsen, 2019) used as a generator.
|
|
2
|
+
|
|
3
|
+
Architecture and training follow the hyperimpute MIWAE imputer: two-layer ReLU
|
|
4
|
+
encoder/decoder, Gaussian q(z|x), Student-t p(x|z) for numerical features, the
|
|
5
|
+
importance-weighted bound over the observed entries, Adam (lr 1e-3), batch 256,
|
|
6
|
+
zero-filled inputs. Categorical features get a softmax head and one-hot inputs.
|
|
7
|
+
Settings from Appendix C.6: 800 epochs, latent size 64, hidden width 256, K = 40.
|
|
8
|
+
Samples are generated by drawing z from the prior and x from p(x|z).
|
|
9
|
+
|
|
10
|
+
Inputs and outputs live in the scenario's model space (see augmask.data.scenario).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import torch
|
|
18
|
+
import torch.nn.functional as F
|
|
19
|
+
from torch import nn
|
|
20
|
+
from torch.distributions import Normal, StudentT
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mlp(d_in: int, h: int, d_out: int) -> nn.Sequential:
|
|
25
|
+
net = nn.Sequential(nn.Linear(d_in, h), nn.ReLU(), nn.Linear(h, h), nn.ReLU(), nn.Linear(h, d_out))
|
|
26
|
+
for m in net:
|
|
27
|
+
if isinstance(m, nn.Linear):
|
|
28
|
+
nn.init.xavier_uniform_(m.weight)
|
|
29
|
+
return net
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _MIWAENet(nn.Module):
|
|
33
|
+
def __init__(self, d_num: int, n_categories: list[int], latent: int, hidden: int):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.d_num, self.n_categories, self.latent = d_num, list(n_categories), latent
|
|
36
|
+
d_in = d_num + sum(n_categories)
|
|
37
|
+
self.encoder = _mlp(d_in, hidden, 2 * latent)
|
|
38
|
+
self.decoder = _mlp(latent, hidden, 3 * d_num + sum(n_categories))
|
|
39
|
+
|
|
40
|
+
def _inputs(self, x_num, x_cat, m_num, m_cat):
|
|
41
|
+
parts = [x_num * m_num]
|
|
42
|
+
for k, n in enumerate(self.n_categories):
|
|
43
|
+
parts.append(F.one_hot(x_cat[:, k].clamp(min=0), n).float() * m_cat[:, k:k + 1])
|
|
44
|
+
return torch.cat(parts, dim=1)
|
|
45
|
+
|
|
46
|
+
def _decode(self, z):
|
|
47
|
+
out = self.decoder(z)
|
|
48
|
+
d = self.d_num
|
|
49
|
+
loc, scale, df = out[..., :d], F.softplus(out[..., d:2 * d]) + 1e-3, F.softplus(out[..., 2 * d:3 * d]) + 3
|
|
50
|
+
logits = torch.split(out[..., 3 * d:], self.n_categories, dim=-1) if self.n_categories else ()
|
|
51
|
+
return loc, scale, df, logits
|
|
52
|
+
|
|
53
|
+
def neg_bound(self, x_num, x_cat, m_num, m_cat, K: int):
|
|
54
|
+
enc = self.encoder(self._inputs(x_num, x_cat, m_num, m_cat))
|
|
55
|
+
q = Normal(enc[:, :self.latent], F.softplus(enc[:, self.latent:]) + 1e-4)
|
|
56
|
+
z = q.rsample((K,)) # [K, B, latent]
|
|
57
|
+
loc, scale, df, logits = self._decode(z)
|
|
58
|
+
log_px = (StudentT(df, loc, scale).log_prob(x_num) * m_num).sum(-1)
|
|
59
|
+
for k, lg in enumerate(logits):
|
|
60
|
+
ll = torch.log_softmax(lg, -1).gather(-1, x_cat[:, k].clamp(min=0).expand(K, -1).unsqueeze(-1)).squeeze(-1)
|
|
61
|
+
log_px = log_px + ll * m_cat[:, k]
|
|
62
|
+
log_pz = Normal(0.0, 1.0).log_prob(z).sum(-1)
|
|
63
|
+
log_qz = q.log_prob(z).sum(-1)
|
|
64
|
+
return -(torch.logsumexp(log_px + log_pz - log_qz, dim=0) - math.log(K)).mean()
|
|
65
|
+
|
|
66
|
+
@torch.no_grad()
|
|
67
|
+
def sample(self, n: int, device):
|
|
68
|
+
z = torch.randn(n, self.latent, device=device)
|
|
69
|
+
loc, scale, df, logits = self._decode(z)
|
|
70
|
+
x_num = StudentT(df, loc, scale).sample()
|
|
71
|
+
x_cat = [torch.distributions.Categorical(logits=lg).sample() for lg in logits]
|
|
72
|
+
x_cat = torch.stack(x_cat, 1) if x_cat else torch.zeros(n, 0, dtype=torch.long, device=device)
|
|
73
|
+
return x_num, x_cat
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MIWAE:
|
|
77
|
+
def __init__(self, n_epochs: int = 800, latent_size: int = 64, n_hidden: int = 256, K: int = 40,
|
|
78
|
+
batch_size: int = 256, lr: float = 1e-3, seed: int = 0, device: str | None = None):
|
|
79
|
+
self.n_epochs, self.latent_size, self.n_hidden, self.K = n_epochs, latent_size, n_hidden, K
|
|
80
|
+
self.batch_size, self.lr, self.seed = batch_size, lr, seed
|
|
81
|
+
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
|
82
|
+
|
|
83
|
+
def fit(self, X_num, X_cat, M_num, M_cat, n_categories) -> "MIWAE":
|
|
84
|
+
torch.manual_seed(self.seed)
|
|
85
|
+
rng = np.random.default_rng(self.seed)
|
|
86
|
+
dev = self.device
|
|
87
|
+
t = lambda a, dt: torch.as_tensor(np.asarray(a), dtype=dt, device=dev)
|
|
88
|
+
x_num, x_cat = t(np.where(M_num, X_num, 0.0), torch.float32), t(np.where(M_cat, X_cat, 0), torch.long)
|
|
89
|
+
m_num, m_cat = t(M_num, torch.float32), t(M_cat, torch.float32)
|
|
90
|
+
self.net_ = _MIWAENet(x_num.shape[1], list(n_categories), self.latent_size, self.n_hidden).to(dev)
|
|
91
|
+
opt = torch.optim.Adam(self.net_.parameters(), lr=self.lr)
|
|
92
|
+
n = len(x_num)
|
|
93
|
+
self.history_ = []
|
|
94
|
+
for epoch in range(self.n_epochs):
|
|
95
|
+
perm = torch.as_tensor(rng.permutation(n), device=dev)
|
|
96
|
+
total = torch.zeros((), device=dev)
|
|
97
|
+
for idx in torch.split(perm, min(self.batch_size, n)):
|
|
98
|
+
loss = self.net_.neg_bound(x_num[idx], x_cat[idx], m_num[idx], m_cat[idx], self.K)
|
|
99
|
+
opt.zero_grad()
|
|
100
|
+
loss.backward()
|
|
101
|
+
opt.step()
|
|
102
|
+
total += loss.detach() * len(idx)
|
|
103
|
+
self.history_.append({"epoch": epoch + 1, "neg_bound": (total / n).item()})
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def sample(self, n: int) -> tuple[np.ndarray, np.ndarray]:
|
|
107
|
+
torch.manual_seed(self.seed)
|
|
108
|
+
self.net_.eval()
|
|
109
|
+
x_num, x_cat = self.net_.sample(n, self.device)
|
|
110
|
+
return x_num.cpu().numpy(), x_cat.cpu().numpy()
|
augmask/data/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .missingness import make_masks
|
|
2
|
+
from .scenario import Scenario, build_scenario, load_artifact, save_artifact
|
|
3
|
+
from .schema import Schema
|
|
4
|
+
from .split import split_indices
|
|
5
|
+
|
|
6
|
+
__all__ = ["Schema", "Scenario", "build_scenario", "load_artifact", "save_artifact",
|
|
7
|
+
"make_masks", "split_indices"]
|
augmask/data/datasets.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Step 1: download and clean the six UCI benchmark tables.
|
|
2
|
+
|
|
3
|
+
Each loader returns the complete table (no NaN) and its Schema. Numerical
|
|
4
|
+
columns are float64; categorical columns and a classification target are str.
|
|
5
|
+
Native missing categories (Adult's "?") are kept as their own category; rows
|
|
6
|
+
with a missing numerical value or target are dropped (Beijing's pm2.5).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import urllib.request
|
|
14
|
+
import zipfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from .schema import Schema
|
|
21
|
+
|
|
22
|
+
DATASETS = ("adult", "default", "shoppers", "magic", "beijing", "news")
|
|
23
|
+
|
|
24
|
+
# archive URL and sha256 of the data file(s) used inside it
|
|
25
|
+
SOURCES = {
|
|
26
|
+
"adult": ("https://archive.ics.uci.edu/static/public/2/adult.zip", {
|
|
27
|
+
"adult.data": "5b00264637dbfec36bdeaab5676b0b309ff9eb788d63554ca0a249491c86603d",
|
|
28
|
+
"adult.test": "a2a9044bc167a35b2361efbabec64e89d69ce82d9790d2980119aac5fd7e9c05"}),
|
|
29
|
+
"default": ("https://archive.ics.uci.edu/static/public/350/default+of+credit+card+clients.zip", {
|
|
30
|
+
"default of credit card clients.xls": "30c6be3abd8dcfd3e6096c828bad8c2f011238620f5369220bd60cfc82700933"}),
|
|
31
|
+
"shoppers": ("https://archive.ics.uci.edu/static/public/468/online+shoppers+purchasing+intention+dataset.zip", {
|
|
32
|
+
"online_shoppers_intention.csv": "b3055ee355f59134d851d32641183cb4a8b45def7124d2f50442a042f358e0d9"}),
|
|
33
|
+
"magic": ("https://archive.ics.uci.edu/static/public/159/magic+gamma+telescope.zip", {
|
|
34
|
+
"magic04.data": "e9314b7ebd4b4b59a3b3d65f7316663963777b16a46786877651dbbaa640b36a"}),
|
|
35
|
+
"beijing": ("https://archive.ics.uci.edu/static/public/381/beijing+pm2+5+data.zip", {
|
|
36
|
+
"PRSA_data_2010.1.1-2014.12.31.csv": "4127f868775e31b3956522adc0ec75af8937dde6a3896e8beed3a376c6d27f1c"}),
|
|
37
|
+
"news": ("https://archive.ics.uci.edu/static/public/332/online+news+popularity.zip", {
|
|
38
|
+
"OnlineNewsPopularity/OnlineNewsPopularity.csv":
|
|
39
|
+
"b66d9088632308cc27fa35af847650d174a5a50503987c4e511de94a99d1c218"}),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def data_root() -> Path:
|
|
44
|
+
return Path(os.environ.get("AUGMASK_DATA", "datasets"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_dataset(name: str, root: str | Path | None = None, verify: bool = True) -> tuple[pd.DataFrame, Schema]:
|
|
48
|
+
"""Complete table and schema of ``name``; downloads and caches on first use."""
|
|
49
|
+
if name not in DATASETS:
|
|
50
|
+
raise ValueError(f"unknown dataset {name!r}; choose from {DATASETS}")
|
|
51
|
+
root = Path(root) if root is not None else data_root()
|
|
52
|
+
out = root / name
|
|
53
|
+
if (out / "complete.csv").exists() and (out / "schema.json").exists():
|
|
54
|
+
schema = Schema.from_json(out / "schema.json")
|
|
55
|
+
df = pd.read_csv(out / "complete.csv", dtype={c: str for c in schema.cat_block}, keep_default_na=False)
|
|
56
|
+
return _finalize(df, schema), schema
|
|
57
|
+
raw = _download(name, root / "raw", verify)
|
|
58
|
+
df, schema = LOADERS[name](raw)
|
|
59
|
+
df = _finalize(df, schema)
|
|
60
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
df.to_csv(out / "complete.csv", index=False)
|
|
62
|
+
schema.to_json(out / "schema.json")
|
|
63
|
+
return df, schema
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _finalize(df: pd.DataFrame, schema: Schema) -> pd.DataFrame:
|
|
67
|
+
df = df[list(schema.columns)].reset_index(drop=True)
|
|
68
|
+
for c in schema.num_block:
|
|
69
|
+
df[c] = df[c].astype(np.float64)
|
|
70
|
+
for c in schema.cat_block:
|
|
71
|
+
df[c] = df[c].astype(str)
|
|
72
|
+
if df.isna().any().any():
|
|
73
|
+
raise ValueError(f"{schema.name}: complete table contains NaN")
|
|
74
|
+
return df
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sha256(path: Path) -> str:
|
|
78
|
+
h = hashlib.sha256()
|
|
79
|
+
with open(path, "rb") as f:
|
|
80
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
81
|
+
h.update(chunk)
|
|
82
|
+
return h.hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _download(name: str, raw_root: Path, verify: bool) -> Path:
|
|
86
|
+
url, files = SOURCES[name]
|
|
87
|
+
dest = raw_root / name
|
|
88
|
+
if not all((dest / f).exists() for f in files):
|
|
89
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
archive = dest / "archive.zip"
|
|
91
|
+
with urllib.request.urlopen(url) as r, open(archive, "wb") as f:
|
|
92
|
+
shutil.copyfileobj(r, f)
|
|
93
|
+
_safe_extract(archive, dest)
|
|
94
|
+
for nested in list(dest.rglob("*.zip")):
|
|
95
|
+
if nested != archive:
|
|
96
|
+
_safe_extract(nested, nested.parent)
|
|
97
|
+
if verify:
|
|
98
|
+
for f, digest in files.items():
|
|
99
|
+
if _sha256(dest / f) != digest:
|
|
100
|
+
raise ValueError(f"{name}: {f} does not match the expected sha256; the source may have changed")
|
|
101
|
+
return dest
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _safe_extract(archive: Path, dest: Path) -> None:
|
|
105
|
+
root = dest.resolve()
|
|
106
|
+
with zipfile.ZipFile(archive) as z:
|
|
107
|
+
for member in z.namelist():
|
|
108
|
+
if not (root / member).resolve().is_relative_to(root):
|
|
109
|
+
raise ValueError(f"unsafe path in archive: {member}")
|
|
110
|
+
z.extractall(root)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cat_str(s: pd.Series) -> pd.Series:
|
|
114
|
+
"""Categories as strings; integer-valued codes without a trailing '.0'."""
|
|
115
|
+
if pd.api.types.is_bool_dtype(s):
|
|
116
|
+
return s.map({True: "True", False: "False"})
|
|
117
|
+
if pd.api.types.is_numeric_dtype(s):
|
|
118
|
+
return s.astype(np.int64).astype(str)
|
|
119
|
+
return s.astype(str).str.strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ----------------------------------------------------------------------------- loaders
|
|
123
|
+
def _adult(raw: Path):
|
|
124
|
+
cols = ["age", "workclass", "fnlwgt", "education", "education.num", "marital.status", "occupation",
|
|
125
|
+
"relationship", "race", "sex", "capital.gain", "capital.loss", "hours.per.week",
|
|
126
|
+
"native.country", "income"]
|
|
127
|
+
read = dict(header=None, names=cols, skipinitialspace=True, keep_default_na=False)
|
|
128
|
+
df = pd.concat([pd.read_csv(raw / "adult.data", **read),
|
|
129
|
+
pd.read_csv(raw / "adult.test", skiprows=1, **read)], ignore_index=True)
|
|
130
|
+
df["income"] = df["income"].str.rstrip(".")
|
|
131
|
+
num = ("age", "fnlwgt", "education.num", "capital.gain", "capital.loss", "hours.per.week")
|
|
132
|
+
cat = tuple(c for c in cols[:-1] if c not in num)
|
|
133
|
+
for c in cat + ("income",):
|
|
134
|
+
df[c] = _cat_str(df[c])
|
|
135
|
+
return df, Schema("adult", "binclass", tuple(cols), num, cat, "income")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _default(raw: Path):
|
|
139
|
+
df = pd.read_excel(raw / "default of credit card clients.xls", sheet_name="Data", header=1).drop(columns="ID")
|
|
140
|
+
target = "default payment next month"
|
|
141
|
+
cat = ("SEX", "EDUCATION", "MARRIAGE", "PAY_0", "PAY_2", "PAY_3", "PAY_4", "PAY_5", "PAY_6")
|
|
142
|
+
num = tuple(c for c in df.columns if c not in cat + (target,))
|
|
143
|
+
for c in cat + (target,):
|
|
144
|
+
df[c] = _cat_str(df[c])
|
|
145
|
+
return df, Schema("default", "binclass", tuple(df.columns), num, cat, target)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _shoppers(raw: Path):
|
|
149
|
+
df = pd.read_csv(raw / "online_shoppers_intention.csv")
|
|
150
|
+
cat = ("Month", "OperatingSystems", "Browser", "Region", "TrafficType", "VisitorType", "Weekend")
|
|
151
|
+
num = tuple(df.columns[:10])
|
|
152
|
+
for c in cat + ("Revenue",):
|
|
153
|
+
df[c] = _cat_str(df[c])
|
|
154
|
+
return df, Schema("shoppers", "binclass", tuple(df.columns), num, cat, "Revenue")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _magic(raw: Path):
|
|
158
|
+
cols = ["Length", "Width", "Size", "Conc", "Conc1", "Asym", "M3Long", "M3Trans", "Alpha", "Dist", "class"]
|
|
159
|
+
df = pd.read_csv(raw / "magic04.data", header=None, names=cols)
|
|
160
|
+
df["class"] = _cat_str(df["class"])
|
|
161
|
+
return df, Schema("magic", "binclass", tuple(cols), tuple(cols[:-1]), (), "class")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _beijing(raw: Path):
|
|
165
|
+
df = pd.read_csv(raw / "PRSA_data_2010.1.1-2014.12.31.csv").drop(columns="No")
|
|
166
|
+
df = df.dropna(subset=["pm2.5"]).reset_index(drop=True)
|
|
167
|
+
cat = ("year", "month", "day", "hour", "cbwd")
|
|
168
|
+
num = ("DEWP", "TEMP", "PRES", "Iws", "Is", "Ir")
|
|
169
|
+
for c in cat:
|
|
170
|
+
df[c] = _cat_str(df[c])
|
|
171
|
+
return df, Schema("beijing", "regression", tuple(df.columns), num, cat, "pm2.5")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _news(raw: Path):
|
|
175
|
+
df = pd.read_csv(raw / "OnlineNewsPopularity" / "OnlineNewsPopularity.csv", skipinitialspace=True)
|
|
176
|
+
df.columns = [c.strip() for c in df.columns]
|
|
177
|
+
channels = [c for c in df.columns if c.startswith("data_channel_is_")]
|
|
178
|
+
days = [c for c in df.columns if c.startswith("weekday_is_")]
|
|
179
|
+
onehot = df[channels].to_numpy()
|
|
180
|
+
df["data_channel"] = np.where(onehot.sum(1) == 0, "none",
|
|
181
|
+
np.array([c.removeprefix("data_channel_is_") for c in channels])[onehot.argmax(1)])
|
|
182
|
+
df["weekday"] = np.array([d.removeprefix("weekday_is_") for d in days])[df[days].to_numpy().argmax(1)]
|
|
183
|
+
df = df.drop(columns=["url", "is_weekend", *channels, *days])
|
|
184
|
+
num = tuple(c for c in df.columns if c not in ("shares", "data_channel", "weekday"))
|
|
185
|
+
cols = num + ("shares", "data_channel", "weekday")
|
|
186
|
+
return df[list(cols)], Schema("news", "regression", cols, num, ("data_channel", "weekday"), "shares")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
LOADERS = {"adult": _adult, "default": _default, "shoppers": _shoppers,
|
|
190
|
+
"magic": _magic, "beijing": _beijing, "news": _news}
|