From 10f5e1ad1b3a370d9f16139a7f971aca04ab306b Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Thu, 10 Sep 2026 22:19:36 -0600 Subject: [PATCH 01/10] Make gamma and G_loss_S/G_loss_V weights configurable in train_timegan_timed Both were hardcoded (gamma=1, both loss weights=100, matching the original TimeGAN paper) with no way to override via the parameters dict, even though every other hyperparameter already flows through it. G_loss_S (supervised: MSE between a real sequence's next embedded step and the generator's teacher-forced prediction of it) and G_loss_V (moment matching: batch-aggregate mean/std alignment) are both regression-style losses, not adversarial ones -- for any timestep where multiple real sequences share a similar state but diverge afterward, their MSE-optimal target is the conditional average of those futures, not a faithful sample of one. At the paper's default weight (100x) this can dominate the adversarial term (G_loss_U) in the generator's total loss, biasing it toward "the average-looking sequence" regardless of its noise input -- a plausible explanation for mode collapse observed training against Traces_GAN's pulse data despite reasonable-looking phase 3 loss curves. Uses parameters.setdefault(...) rather than a plain local default (same pattern already used for parameters["dim"] = dim just above) so the resolved value -- whether caller-supplied or default -- lands back in the caller's own parameters dict. Callers that log/persist that dict (e.g. into a run's metadata, as Traces_GAN's alderaan_debug_run scripts do) get these recorded automatically, with no additional plumbing. Scoped to train_timegan_timed only, the function every current pipeline script actually calls. train_timegan and load_timegan have their own separate (unused by train_timegan_timed) hardcoded gamma=1 and were left alone -- train_timegan is still exercised by this repo's own test.py/ tutorial.py, and load_timegan only rebuilds the graph for checkpoint restore/inference, where gamma's value is never evaluated. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PF8L7MkAiLARxWd2dSGSmq --- timegan/timegan.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/timegan/timegan.py b/timegan/timegan.py index b6b845c..4572e1e 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -502,6 +502,25 @@ def train_timegan_timed( original one-to-one behavior. Useful to request fewer (e.g. for a quick quality check) or more than the training set size; ori_data/ori_time are cycled with modulo indexing to support either direction. + - parameters may also optionally include (defaults match the original TimeGAN paper's + fixed values, so omitting them reproduces prior behavior exactly): + - gamma: weight on the embedding-space adversarial terms (G_loss_U_e, D_loss_fake_e). + Default 1. + - g_loss_s_weight, g_loss_v_weight: weight on G_loss_S (supervised: MSE between a real + sequence's next embedded step and the generator's teacher-forced prediction of it) + and G_loss_V (moment matching: batch-aggregate mean/std alignment) in the generator's + total loss. Default 100 each, matching the original paper. Both are regression-style + losses, not adversarial ones -- for any timestep where multiple real sequences share + a similar state but diverge afterward, their MSE-optimal target is the conditional + *average* of those futures, not a faithful sample of one. At the paper's default + weight this term can dominate the adversarial one (G_loss_U) in the total gradient, + biasing the generator toward "the average-looking sequence" regardless of its noise + input -- mode collapse that per-batch loss values alone don't clearly reveal. Lower + these (the two terms independently, since they push in the same mean-seeking + direction for different reasons) if generated output looks collapsed toward one or a + few dominant modes despite reasonable-looking loss curves; going to zero removes the + stabilizing effect these terms provide over pure adversarial training on sequences, + so tune down rather than eliminate. - on_training_complete: optional zero-argument callback invoked right after phase 3's post-training checkpoint save, before generation begins. Lets a caller record that training is done (e.g. write its own progress marker) in case generation itself gets @@ -566,7 +585,15 @@ def log_history(record): module_name = parameters["module"] parameters["dim"] = dim z_dim = dim - gamma = 1 + # setdefault (not a plain local default) so the resolved value -- whether + # the caller supplied one or this default -- lands back in the caller's + # own parameters dict, the same way `parameters["dim"] = dim` above does. + # Callers that log/persist their parameters dict (e.g. into a run's + # metadata) get these recorded automatically, with no extra plumbing. + parameters.setdefault("gamma", 1) + parameters.setdefault("g_loss_s_weight", 100.0) + parameters.setdefault("g_loss_v_weight", 100.0) + gamma = parameters["gamma"] # Input place holders X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") @@ -624,7 +651,9 @@ def log_history(record): G_loss_V = G_loss_V1 + G_loss_V2 # 4. Summation - G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V + G_loss = (G_loss_U + gamma * G_loss_U_e + + parameters["g_loss_s_weight"] * tf.sqrt(G_loss_S + 1e-6) + + parameters["g_loss_v_weight"] * G_loss_V) # Embedder network loss E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) From 9d835eae9738fe38c5bf36c913ac8bf9b9e9996e Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Mon, 14 Sep 2026 13:51:41 -0600 Subject: [PATCH 02/10] Fix timegan_static.py's static-feature path; add real training tests timegan_static.py's static-feature path had never actually run successfully, in this repo's history: - Broken package import: `from utils import ...` collided with an unrelated PyPI package named `utils` instead of resolving to timegan.utils. - unpack_parameters() was never updated for the extra static_dim/ static_z_dim params embedder/recovery/generator/discriminator all try to unpack -- immediate ValueError. - recovery()'s signature never gained the `S` param its own body referenced -- NameError. - Malformed tf.concat() calls (missing axis argument, and mixing a per-timestep tensor with a per-event one with no broadcast/tiling scheme), a typo'd biderectional_dynamic_rnn (no such method), and a bare undefined `sigmoid` activation function. - train_timegan/load_timegan never accepted static feature data as an argument at all, and called the (now-fixed) component functions with the wrong number of arguments. Fixed all of the above. Static features are now genuinely embedded/ discriminated (HS/ES/XS_tilde/XS_hat/YS_* are real graph nodes), but intentionally don't participate in any loss term yet -- that's a separate design decision (how should static reconstruction/ adversarial terms combine with the temporal ones?) left for later. The temporal path's own computation is unchanged from before this fix in every case, so this can't regress non-static behavior. Also fixes a smaller pre-existing bug: data_loading.py's real_data_loading() used a bare relative path for the energy dataset ("data/energy_data.csv") that only worked by accident depending on caller cwd, unlike the stock branch's proper package-resource loading. Adds tests/test_training.py: real short training runs against the bundled stock/energy datasets plus synthetic data for the static path, using statistical/structural assertions (shape, finite, sigmoid- bounded range) rather than exact golden values -- verified directly that this TF1 graph-mode code is NOT bit-reproducible in this environment even with every seed fixed and execution forced single- threaded, so an exact-match test would be flaky by construction. Adds report_training_comparison.py: a manual capture/compare tool for the planned embedder/recovery/generator/discriminator consolidation between timegan.py and timegan_static.py. Captures N repeated runs (same code, different seeds) to characterize normal run-to-run noise on each side of a change, then reports whether the "after" side looks like more of the same noise or a real shift -- a single before/after run would be indistinguishable from noise given the non-determinism above. Removes tests/test_foo.py and timegan/foo.py, unused cookiecutter scaffold left over from this project's template. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DbnvNbSohNTTvL8MDx5RUU --- report_training_comparison.py | 173 +++++++++++++++++++++++++++++++ tests/test_foo.py | 5 - tests/test_training.py | 153 +++++++++++++++++++++++++++ timegan/data_loading.py | 9 +- timegan/foo.py | 17 --- timegan/timegan_static.py | 188 ++++++++++++++++++++++------------ 6 files changed, 454 insertions(+), 91 deletions(-) create mode 100644 report_training_comparison.py delete mode 100644 tests/test_foo.py create mode 100644 tests/test_training.py delete mode 100644 timegan/foo.py diff --git a/report_training_comparison.py b/report_training_comparison.py new file mode 100644 index 0000000..9bb5267 --- /dev/null +++ b/report_training_comparison.py @@ -0,0 +1,173 @@ +"""Capture/compare tool for checking whether a code change (e.g. the +embedder/recovery/generator/discriminator consolidation planned for +timegan.py + timegan_static.py) altered training behavior. + +Why this exists instead of a golden-value test: verified directly that +this TF1 graph-mode code is NOT bit-reproducible in this environment even +with every seed fixed and execution forced single-threaded -- two +consecutive runs of the identical script, same seed, same config, produced +different generated output (see tests/test_training.py's module docstring +for the actual numbers). A single before/after run is indistinguishable +from ordinary noise. This tool instead captures several repeated runs on +each side to characterize that noise, then reports whether the "after" +side's values look like more of the same noise, or like a real shift. + +Two-step workflow, run manually at refactor time -- NOT wired into pytest +or CI, since it does real training and isn't asserting anything on its +own, just presenting numbers for a human to judge: + + # Before touching the code: + python3 report_training_comparison.py capture --label before + + # After the change: + python3 report_training_comparison.py capture --label after + + # Compare: + python3 report_training_comparison.py compare before after + +Tracks generated-output summary statistics (mean/std/min/max), the one +thing all three training entry points (train_timegan_timed, the base +train_timegan, and timegan_static's train_timegan) return in common. +""" +import argparse +import json +import os +import subprocess +import time + +import numpy as np +import tensorflow as tf + +from timegan.data_loading import real_data_loading +from timegan.timegan import train_timegan as train_timegan_base +from timegan.timegan import train_timegan_timed +from timegan.timegan_static import train_timegan as train_timegan_static + +OUT_DIR = ".training_comparison" +TINY_PARAMS = dict(hidden_dim=4, num_layer=2, batch_size=8, module="gru", iterations=2) + + +def _git_commit(): + try: + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL, + ).strip() + except Exception: + return None + + +def _summary_stats(generated): + flat = np.concatenate([np.asarray(seq).ravel() for seq in generated]) + return dict( + mean=float(flat.mean()), std=float(flat.std()), + min=float(flat.min()), max=float(flat.max()), + ) + + +def _run_once(fn_name, seed): + np.random.seed(seed) + tf.compat.v1.set_random_seed(seed) + + if fn_name in ("timed", "base"): + ori_data = np.asarray(real_data_loading("stock", seq_len=8)[:40], dtype=np.float32) + if fn_name == "timed": + phase, generated = train_timegan_timed( + ori_data, dict(TINY_PARAMS), in_filename=f"/tmp/report_cmp_{fn_name}_{seed}", + seconds=120, phase=1, new=True, num_generate=10, + ) + if phase != 4: + raise RuntimeError(f"train_timegan_timed didn't finish in one call (phase={phase})") + else: + generated = train_timegan_base( + ori_data, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", + ) + elif fn_name == "static": + no, seq_len, dim = 32, 6, 3 + ori_data = np.random.uniform(0, 1, [no, seq_len, dim]).astype(np.float32) + ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) + generated = train_timegan_static( + ori_data, ori_data_static, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", + ) + else: + raise ValueError(f"unknown fn: {fn_name}") + + return _summary_stats(generated) + + +def capture(label, fn_name, runs): + os.makedirs(OUT_DIR, exist_ok=True) + run_stats = [] + for i in range(runs): + seed = 1000 * hash(label) % 10_000 + i # varies by label so before/after don't share exact seeds + seed = abs(seed) % (2**31) + stats = _run_once(fn_name, seed) + print(f" run {i + 1}/{runs} (seed={seed}): {stats}") + run_stats.append(stats) + + record = dict( + label=label, fn=fn_name, runs=runs, git_commit=_git_commit(), + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), params=TINY_PARAMS, + run_stats=run_stats, + ) + out_path = os.path.join(OUT_DIR, f"{label}.json") + with open(out_path, "w") as f: + json.dump(record, f, indent=2) + print(f"wrote {out_path} ({runs} runs)") + + +def compare(label_a, label_b): + with open(os.path.join(OUT_DIR, f"{label_a}.json")) as f: + rec_a = json.load(f) + with open(os.path.join(OUT_DIR, f"{label_b}.json")) as f: + rec_b = json.load(f) + + if rec_a["fn"] != rec_b["fn"]: + print(f"WARNING: comparing different functions ({rec_a['fn']} vs {rec_b['fn']}) -- " + f"not a meaningful before/after comparison") + + print(f"{label_a}: fn={rec_a['fn']} commit={rec_a.get('git_commit')} runs={rec_a['runs']}") + print(f"{label_b}: fn={rec_b['fn']} commit={rec_b.get('git_commit')} runs={rec_b['runs']}") + print() + + metrics = ["mean", "std", "min", "max"] + any_no_overlap = False + for metric in metrics: + a_vals = [r[metric] for r in rec_a["run_stats"]] + b_vals = [r[metric] for r in rec_b["run_stats"]] + a_lo, a_hi = min(a_vals), max(a_vals) + b_lo, b_hi = min(b_vals), max(b_vals) + overlap = max(a_lo, b_lo) <= min(a_hi, b_hi) + flag = "" if overlap else " <-- NO OVERLAP, worth a closer look" + if not overlap: + any_no_overlap = True + print(f"{metric:5s} {label_a} range=[{a_lo:.4f}, {a_hi:.4f}] " + f"{label_b} range=[{b_lo:.4f}, {b_hi:.4f}]{flag}") + + print() + if any_no_overlap: + print("At least one metric's ranges don't overlap across runs -- run more repeats on " + "each side before concluding this is a real behavior change rather than the " + "handful-of-runs sample just missing each other by chance.") + else: + print("All tracked metrics' ranges overlap -- no evidence of a behavior change beyond " + "ordinary run-to-run noise, at this sample size.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + p_capture = sub.add_parser("capture", help="run repeated trainings and save their stats") + p_capture.add_argument("--label", required=True, help="e.g. 'before' or 'after'") + p_capture.add_argument("--fn", choices=["timed", "base", "static"], default="timed") + p_capture.add_argument("--runs", type=int, default=10) + + p_compare = sub.add_parser("compare", help="compare two captured labels") + p_compare.add_argument("label_a") + p_compare.add_argument("label_b") + + args = parser.parse_args() + if args.command == "capture": + capture(args.label, args.fn, args.runs) + elif args.command == "compare": + compare(args.label_a, args.label_b) diff --git a/tests/test_foo.py b/tests/test_foo.py deleted file mode 100644 index 0d015e6..0000000 --- a/tests/test_foo.py +++ /dev/null @@ -1,5 +0,0 @@ -from timegan.foo import foo - - -def test_foo(): - assert foo() == "foo" diff --git a/tests/test_training.py b/tests/test_training.py new file mode 100644 index 0000000..89affde --- /dev/null +++ b/tests/test_training.py @@ -0,0 +1,153 @@ +"""Real, short training-run tests against the bundled small datasets +(timegan/data/stock_data.csv, energy_data.csv) and synthetic data, rather +than shape-only unit tests. + +Why real training, not mocks: this codebase's actual failure modes are +things like "the graph doesn't build" (wrong arg count, undefined name, +malformed tf.concat -- all found and fixed in timegan_static.py while +writing these) or "training silently produces NaN/garbage" -- none of +which a mocked-out training loop would ever exercise. + +Why statistical/structural assertions, not exact golden values: verified +directly (see report_training_comparison.py and the module docstring +there) that this TF1 graph-mode code is NOT bit-reproducible in this +environment even with every seed fixed and execution forced single- +threaded -- two consecutive runs of the identical script, same seed, same +config, produced different generated output. An exact-match test would be +flaky by construction, not a real regression signal. These tests instead +check what should hold true regardless of that run-to-run noise: shape, +finiteness, and that values land in the range the network's own +architecture guarantees (sigmoid-activated outputs, so roughly [0, 1]). + +Small/fast by design: tiny hidden_dim, 2 iterations per phase, a handful +of events. These are meant to run in a couple seconds each, not to +produce a meaningful trained model. +""" +import numpy as np +import pytest +import tensorflow as tf + +from timegan.data_loading import real_data_loading +from timegan.timegan import train_timegan as train_timegan_base +from timegan.timegan import train_timegan_timed +from timegan.timegan_static import load_timegan as load_timegan_static +from timegan.timegan_static import train_timegan as train_timegan_static + +TINY_PARAMS = dict(hidden_dim=4, num_layer=2, batch_size=8, module="gru") +# supervisor() builds num_layers - 1 RNN cells -- num_layer=1 would pass an +# empty cell list to MultiRNNCell and fail with an unrelated-looking error +# ("Must specify at least one cell"). Found while writing this test; not a +# bug in these tests' config, a real constraint on the network itself. + + +def _subsample(data_name, seq_len, n_events): + """A small, fast slice of a bundled real dataset -- real data loading + all the way through the same real_data_loading() any actual caller + uses, just fewer events.""" + data = real_data_loading(data_name, seq_len) + return np.asarray(data[:n_events], dtype=np.float32) + + +def _assert_sane_generated_output(generated, expected_shape): + generated = np.asarray(generated) + assert generated.shape == expected_shape + assert np.isfinite(generated).all(), "generated output contains NaN/Inf" + # Every output path here ends in a sigmoid-activated layer (recovery()), + # so values should land in roughly [0, 1] regardless of run-to-run + # noise -- a real bound the architecture guarantees, not a tuned + # tolerance. + assert generated.min() >= -0.05 + assert generated.max() <= 1.05 + + +def test_train_timegan_timed_on_stock(): + """The function Traces_GAN (and presumably every other real caller) + actually uses.""" + np.random.seed(0) + tf.compat.v1.set_random_seed(0) + ori_data = _subsample("stock", seq_len=8, n_events=40) + + phase, info = train_timegan_timed( + ori_data, dict(TINY_PARAMS, iterations=2), in_filename="/tmp/test_timed_stock", + seconds=120, phase=1, new=True, num_generate=5, + ) + + assert phase == 4, f"expected all phases to finish within the time budget, got phase {phase}" + _assert_sane_generated_output(info, expected_shape=(5, 8, ori_data.shape[-1])) + + +def test_train_timegan_timed_on_energy_smoke(): + """Different dataset/dimensionality -- lighter check, just confirms + the shape/no-NaN properties hold beyond the one dataset above.""" + np.random.seed(1) + tf.compat.v1.set_random_seed(1) + ori_data = _subsample("energy", seq_len=8, n_events=40) + + phase, info = train_timegan_timed( + ori_data, dict(TINY_PARAMS, iterations=2), in_filename="/tmp/test_timed_energy", + seconds=120, phase=1, new=True, num_generate=5, + ) + + assert phase == 4 + _assert_sane_generated_output(info, expected_shape=(5, 8, ori_data.shape[-1])) + + +def test_train_timegan_base_smoke(): + """timegan.py's original (non-timed) train_timegan -- the other half + of the duplication pattern flagged alongside timegan_static.py. Not + used by Traces_GAN, but same-treatment coverage costs little.""" + np.random.seed(2) + tf.compat.v1.set_random_seed(2) + ori_data = _subsample("stock", seq_len=8, n_events=24) + + generated = train_timegan_base( + ori_data, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_base_stock", + ) + + _assert_sane_generated_output(generated, expected_shape=(24, 8, ori_data.shape[-1])) + + +def test_train_timegan_static_on_synthetic(): + """First real test of timegan_static.py's train_timegan, ever -- this + function could not previously be imported (unrelated `utils` package + shadowing its intended local import) or called (wrong argument counts + throughout; unpack_parameters never updated for the extra static- + feature params; a bare undefined `sigmoid`; a typo'd TF method name; + malformed tf.concat calls) -- all fixed to get to this point. Static + features are genuinely embedded/discriminated (HS/ES/XS_tilde/XS_hat/ + YS_* below are real graph nodes), but intentionally don't participate + in the loss yet -- see train_timegan's docstring. + + Synthetic data, not a bundled dataset: none of the bundled data ships + a static-feature column, so this constructs its own small windowed + array plus a random per-sequence label (agreed as the stand-in for + "real" static features for this first test). + """ + np.random.seed(3) + tf.compat.v1.set_random_seed(3) + no, seq_len, dim = 32, 6, 3 + ori_data = np.random.uniform(0, 1, [no, seq_len, dim]).astype(np.float32) + ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) + + generated = train_timegan_static( + ori_data, ori_data_static, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_static_synth", + ) + + _assert_sane_generated_output(generated, expected_shape=(no, seq_len, dim)) + + +def test_load_timegan_static_round_trip(): + """train then immediately load the same checkpoint -- confirms + load_timegan's identical fix (same bugs, same treatment) actually + works, not just train_timegan's.""" + np.random.seed(4) + tf.compat.v1.set_random_seed(4) + no, seq_len, dim = 32, 6, 3 + ori_data = np.random.uniform(0, 1, [no, seq_len, dim]).astype(np.float32) + ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) + params = dict(TINY_PARAMS, iterations=2) + + train_timegan_static(ori_data, ori_data_static, dict(params), filename="/tmp/test_roundtrip") + generated = load_timegan_static(ori_data, ori_data_static, dict(params), filename="/tmp/test_roundtrip-0") + + _assert_sane_generated_output(generated, expected_shape=(no, seq_len, dim)) diff --git a/timegan/data_loading.py b/timegan/data_loading.py index 3186dac..16d8b91 100644 --- a/timegan/data_loading.py +++ b/timegan/data_loading.py @@ -97,7 +97,14 @@ def real_data_loading(data_name, seq_len): importlib.resources.open_binary("timegan.data", "stock_data.csv"), delimiter=",", skiprows=1 ) elif data_name == "energy": - ori_data = np.loadtxt("data/energy_data.csv", delimiter=",", skiprows=1) + # Was a bare relative path ("data/energy_data.csv"), unlike the + # stock branch above -- only worked if the caller's cwd happened to + # have a data/ subdirectory, which isn't true from the repo root + # (the real file lives at timegan/data/energy_data.csv). Fixed to + # use the same package-resource loading as stock. + ori_data = np.loadtxt( + importlib.resources.open_binary("timegan.data", "energy_data.csv"), delimiter=",", skiprows=1 + ) # Flip the data to make chronological data ori_data = ori_data[::-1] diff --git a/timegan/foo.py b/timegan/foo.py deleted file mode 100644 index 35fd0ac..0000000 --- a/timegan/foo.py +++ /dev/null @@ -1,17 +0,0 @@ -def foo() -> str: - """Summary line. - - Extended description of function. - - Args: - foo (str): Description of arg1 - - Returns: - str: Description of return value - """ - - return "foo" - - -if __name__ == "__main__": # pragma: no cover - pass diff --git a/timegan/timegan_static.py b/timegan/timegan_static.py index ccd701f..db96a9c 100644 --- a/timegan/timegan_static.py +++ b/timegan/timegan_static.py @@ -21,7 +21,7 @@ import numpy as np import tensorflow as tf from tf_slim.layers import layers as _layers -from utils import batch_generator, extract_time, random_generator, rnn_cell +from timegan.utils import batch_generator, extract_time, random_generator, rnn_cell tf.compat.v1.disable_eager_execution() tf.compat.v1.disable_v2_behavior() @@ -50,14 +50,19 @@ def MinMaxScaler(data): # Unpack parameters def unpack_parameters(parameters): - # hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma + # hidden_dim, static_dim, num_layers, iterations, batch_size, module_name, + # z_dim, static_z_dim, gamma -- static_dim doubles as static_z_dim, same + # way dim doubles as z_dim below: the static noise fed to the generator + # has the same width as the real static features it's standing in for. return ( parameters["hidden_dim"], + parameters["static_dim"], parameters["num_layer"], parameters["iterations"], parameters["batch_size"], parameters["module"], parameters["dim"], + parameters["static_dim"], 1, ) @@ -88,23 +93,26 @@ def embedder(X, T, S, param): ) = unpack_parameters(param) dim = z_dim with tf.compat.v1.variable_scope("embedder", reuse=tf.compat.v1.AUTO_REUSE): - print("e_cell") # TEMP e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - print("e_output") # TEMP e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, X, dtype=tf.float32, sequence_length=T) - print("H") # TEMP - # H = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) - - # Static features + HT = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) + + # Static features, embedded independently of the temporal branch + # for now (not concatenated into HT): merging a per-timestep tensor + # (e_outputs) with a per-event one (HS) needs a broadcast/tiling + # scheme this file never designed -- the original attempt here + # called tf.concat(e_outputs, HS), which isn't valid usage (missing + # an axis, and shapes don't line up regardless). HT is exactly the + # non-static computation, so this can't regress the temporal path; + # HS is real and valid but not yet consumed by any loss term -- see + # train_timegan. HS = _layers.stack( S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid ) - h_inputs = tf.concat(e_outputs, HS) - HT = _layers.fully_connected(h_inputs, hidden_dim, activation_fn=tf.nn.sigmoid) return HT, HS -def recovery(H, T, param): +def recovery(H, T, S, param): """Recovery network from latent space to original space. Args: @@ -234,36 +242,45 @@ def discriminator(H, T, S, param): ) = unpack_parameters(param) dim = z_dim with tf.compat.v1.variable_scope("discriminator", reuse=tf.compat.v1.AUTO_REUSE): - """ - d_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - d_outputs, d_last_states = tf.compat.v1.nn.dynamic_rnn(d_cell, H, dtype=tf.float32, sequence_length = T) - YT_hat = _layers.fully_connected(d_outputs, 1, activation_fn=None) - """ - # Bidirectional rnn cell - fw_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - bw_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - c_inputs = tf.concat(H, S) - c_outputs, c_last_states = tf.compat.v1.nn.biderectional_dynamic_rnn( - fw_cell, bw_cell, c_inputs, dtype=tf.float32, sequence_length=T - ) - - # Temporal discriminator - d_inputs = tf.concat(c_outputs, 2) + # Temporal discriminator -- reverted to the simple, single- + # dynamic_rnn form this file already had preserved as a comment, + # rather than the bidirectional/concat-with-S attempt that replaced + # it: tf.concat(H, S) isn't valid (missing an axis, and mixing a + # per-timestep tensor with a per-event one needs a broadcast/tiling + # scheme this file never designed), and biderectional_dynamic_rnn + # doesn't exist (typo for bidirectional_dynamic_rnn) regardless. + # YT_hat is unaffected by S for now, same reasoning as embedder(). d_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - dt_outputs, dt_last_states = tf.compat.v1.nn.dynamic_rnn(d_cell, d_inputs, dtype=tf.float32, sequence_length=T) - YT_hat = _layers.fully_connected(dt_outputs, 1, activation_fn=None) + d_outputs, d_last_states = tf.compat.v1.nn.dynamic_rnn(d_cell, H, dtype=tf.float32, sequence_length=T) + YT_hat = _layers.fully_connected(d_outputs, 1, activation_fn=None) - # Static discriminator + # Static discriminator -- real and valid, but (like HS/ES/XS_tilde) + # not yet wired into any loss term; also fixes a bare, undefined + # `sigmoid` name (was never tf.nn.sigmoid or tf.sigmoid). ds_outputs = _layers.stack( S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid ) - YS_hat = _layers.fully_connected(ds_outputs, 1, activation_fn=sigmoid) + YS_hat = _layers.fully_connected(ds_outputs, 1, activation_fn=tf.nn.sigmoid) return YT_hat, YS_hat -# TODO: UPDATE train_timegan AND load_timegan TO USE STATIC FEATURES -def train_timegan(ori_data, parameters, filename="timegan_save", version=0): +def _batch_generator_with_static(data, time, static, batch_size): + """Same draw as timegan.utils.batch_generator, but also slices static + features by the identical indices so S_mb stays aligned with X_mb/T_mb. + Kept local to this file (not added to timegan.utils) since the non- + static train_timegan/train_timegan_timed never need a static slice. + """ + no = len(data) + idx = np.random.permutation(no) + train_idx = idx[:batch_size] + X_mb = list(data[i] for i in train_idx) + T_mb = list(time[i] for i in train_idx) + S_mb = list(static[i] for i in train_idx) + return X_mb, T_mb, S_mb + + +def train_timegan(ori_data, ori_data_static, parameters, filename="timegan_save", version=0): """TimeGAN training function. Use original data as training set to generater synthetic data (time-series) @@ -271,18 +288,28 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): Args: - ori_data: original time-series data + - ori_data_static: per-event static features, shape (no, static_dim). + Embedded/discriminated for real (HS/ES/XS_tilde/XS_hat/YS_* below + are genuine graph nodes, not stubs), but not yet wired into any + loss term -- training only optimizes the temporal reconstruction/ + adversarial objective, same as the non-static implementation. + Finishing that (deciding how static loss terms should combine + with the temporal ones) is separate follow-up work; this just + makes the function callable and correct for what it does do. - parameters: TimeGAN network parameters - filename: filename to save the model in, default "timegan_save" - version: version of the snapshot, default 0 Returns: - - generated_data: generated time-series data + - generated_data: generated time-series data (temporal only, same + shape/meaning as the non-static implementation's output) """ # Initialization on the Graph tf.compat.v1.reset_default_graph() # Basic Parameters no, seq_len, dim = np.asarray(ori_data).shape + static_dim = np.asarray(ori_data_static).shape[-1] # Maximum sequence length and each sequence length ori_time, max_seq_len = extract_time(ori_data) @@ -299,6 +326,7 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): batch_size = parameters["batch_size"] module_name = parameters["module"] parameters["dim"] = dim + parameters["static_dim"] = static_dim z_dim = dim gamma = 1 @@ -306,23 +334,25 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") + S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") + S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") # Embedder & Recovery - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) + HT, HS = embedder(X, T, S, parameters) + XT_tilde, XS_tilde = recovery(HT, T, HS, parameters) # Generator - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) + ET, ES = generator(Z, T, S_z, parameters) + H_hat = supervisor(ET, T, parameters) + H_hat_supervise = supervisor(HT, T, parameters) # Synthetic data - X_hat = recovery(H_hat, T, parameters) + X_hat, XS_hat = recovery(H_hat, T, ES, parameters) # Discriminator - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) + Y_fake, YS_fake = discriminator(H_hat, T, ES, parameters) + Y_real, YS_real = discriminator(HT, T, HS, parameters) + Y_fake_e, YS_fake_e = discriminator(ET, T, ES, parameters) # Variables e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] @@ -343,7 +373,7 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(H[:, 1:, :], H_hat_supervise[:, :-1, :]) + G_loss_S = tf.compat.v1.losses.mean_squared_error(HT[:, 1:, :], H_hat_supervise[:, :-1, :]) # 3. Two Momments G_loss_V1 = tf.reduce_mean( @@ -361,7 +391,7 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) + E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, XT_tilde) E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) E_loss = E_loss0 + 0.1 * G_loss_S @@ -384,9 +414,9 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): for itt in range(iterations): # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) # Train embedder - _, step_e_loss = sess.run([E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb}) + _, step_e_loss = sess.run([E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb, S: S_mb}) # Checkpoint if itt % 1000 == 0: print( @@ -405,11 +435,14 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): for itt in range(iterations): # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) # Random vector generation Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) + Sz_mb = np.random.uniform(0.0, 1, [batch_size, static_dim]) # Train generator - _, step_g_loss_s = sess.run([GS_solver, G_loss_S], feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) + _, step_g_loss_s = sess.run( + [GS_solver, G_loss_S], feed_dict={Z: Z_mb, X: X_mb, T: T_mb, S: S_mb, S_z: Sz_mb} + ) # Checkpoint if itt % 1000 == 0: print( @@ -430,26 +463,33 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): # Generator training (twice more than discriminator training) for kk in range(2): # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) # Random vector generation Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) + Sz_mb = np.random.uniform(0.0, 1, [batch_size, static_dim]) # Train generator _, step_g_loss_u, step_g_loss_s, step_g_loss_v = sess.run( - [G_solver, G_loss_U, G_loss_S, G_loss_V], feed_dict={Z: Z_mb, X: X_mb, T: T_mb} + [G_solver, G_loss_U, G_loss_S, G_loss_V], + feed_dict={Z: Z_mb, X: X_mb, T: T_mb, S: S_mb, S_z: Sz_mb}, ) # Train embedder - _, step_e_loss_t0 = sess.run([E_solver, E_loss_T0], feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) + _, step_e_loss_t0 = sess.run( + [E_solver, E_loss_T0], feed_dict={Z: Z_mb, X: X_mb, T: T_mb, S: S_mb, S_z: Sz_mb} + ) # Discriminator training # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) # Random vector generation Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) + Sz_mb = np.random.uniform(0.0, 1, [batch_size, static_dim]) # Check discriminator loss before updating - check_d_loss = sess.run(D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb}) + check_d_loss = sess.run(D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb, S: S_mb, S_z: Sz_mb}) # Train discriminator (only when the discriminator does not work well) if check_d_loss > 0.15: - _, step_d_loss = sess.run([D_solver, D_loss], feed_dict={X: X_mb, T: T_mb, Z: Z_mb}) + _, step_d_loss = sess.run( + [D_solver, D_loss], feed_dict={X: X_mb, T: T_mb, Z: Z_mb, S: S_mb, S_z: Sz_mb} + ) # Print multiple checkpoints if itt % 1000 == 0: @@ -473,7 +513,10 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): ## Synthetic data generation Z_mb = random_generator(no, z_dim, ori_time, max_seq_len) - generated_data_curr = sess.run(X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time}) + Sz_all = np.random.uniform(0.0, 1, [no, static_dim]) + generated_data_curr = sess.run( + X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time, S: ori_data_static, S_z: Sz_all} + ) generated_data = list() @@ -491,7 +534,7 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): return generated_data -def load_timegan(ori_data, parameters, filename): +def load_timegan(ori_data, ori_data_static, parameters, filename): """TimeGAN function. Use original data as training set to generater synthetic data (time-series) @@ -499,6 +542,8 @@ def load_timegan(ori_data, parameters, filename): Args: - ori_data: original time-series data + - ori_data_static: per-event static features, shape (no, static_dim) -- + see train_timegan's docstring for what this does and doesn't do yet - parameters: TimeGAN network parameters - filename: filename of the snapshot to load @@ -510,6 +555,7 @@ def load_timegan(ori_data, parameters, filename): # Basic Parameters no, seq_len, dim = np.asarray(ori_data).shape + static_dim = np.asarray(ori_data_static).shape[-1] # Maximum sequence length and each sequence length ori_time, max_seq_len = extract_time(ori_data) @@ -526,6 +572,7 @@ def load_timegan(ori_data, parameters, filename): batch_size = parameters["batch_size"] module_name = parameters["module"] parameters["dim"] = dim + parameters["static_dim"] = static_dim z_dim = dim gamma = 1 @@ -533,23 +580,25 @@ def load_timegan(ori_data, parameters, filename): X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") + S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") + S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") # Embedder & Recovery - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) + HT, HS = embedder(X, T, S, parameters) + XT_tilde, XS_tilde = recovery(HT, T, HS, parameters) # Generator - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) + ET, ES = generator(Z, T, S_z, parameters) + H_hat = supervisor(ET, T, parameters) + H_hat_supervise = supervisor(HT, T, parameters) # Synthetic data - X_hat = recovery(H_hat, T, parameters) + X_hat, XS_hat = recovery(H_hat, T, ES, parameters) # Discriminator - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) + Y_fake, YS_fake = discriminator(H_hat, T, ES, parameters) + Y_real, YS_real = discriminator(HT, T, HS, parameters) + Y_fake_e, YS_fake_e = discriminator(ET, T, ES, parameters) # Variables e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] @@ -570,7 +619,7 @@ def load_timegan(ori_data, parameters, filename): G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(H[:, 1:, :], H_hat_supervise[:, :-1, :]) + G_loss_S = tf.compat.v1.losses.mean_squared_error(HT[:, 1:, :], H_hat_supervise[:, :-1, :]) # 3. Two Momments G_loss_V1 = tf.reduce_mean( @@ -588,7 +637,7 @@ def load_timegan(ori_data, parameters, filename): G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) + E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, XT_tilde) E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) E_loss = E_loss0 + 0.1 * G_loss_S @@ -606,7 +655,10 @@ def load_timegan(ori_data, parameters, filename): # Synthetic data generation Z_mb = random_generator(no, z_dim, ori_time, max_seq_len) - generated_data_curr = sess.run(X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time}) + Sz_all = np.random.uniform(0.0, 1, [no, static_dim]) + generated_data_curr = sess.run( + X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time, S: ori_data_static, S_z: Sz_all} + ) generated_data = list() From dd73ace0e64c3c29f30b977e927c2f4c664af500 Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Mon, 14 Sep 2026 13:55:51 -0600 Subject: [PATCH 03/10] Consolidate train_timegan into a thin wrapper around train_timegan_timed train_timegan was a full standalone copy of train_timegan_timed's graph- building/training-loop code, predating the phase/resume/timing additions there -- the same "same fix needs applying in two places" risk that let timegan_static.py's copy of this same code drift until it no longer even ran (fixed in the previous commit). Confirmed train_timegan_timed's defaults (gamma=1, g_loss_s_weight=g_loss_v_weight=100, num_generate=None -> one generated sequence per training event) already reproduce train_timegan's previously-hardcoded behavior exactly, so this changes nothing for any existing caller not passing those parameter keys. train_timegan now just calls train_timegan_timed with seconds=inf (so the time-budget check can never trigger an early return), phase=1, new=True, and unwraps the (phase, generated_data) result -- raising instead of silently returning partial output in the two cases that would indicate something unexpected (phase==-1 error, or, if the seconds=inf assumption is ever wrong, any phase != 4). Two side effects, both additive: also writes _history.jsonl now (harmless extra output), and names the Saver checkpoint pointer file after `filename` instead of TF's shared default bare "checkpoint" (an improvement -- that shared pointer file is exactly what used to get clobbered across different runs/filenames). Verified via tests/test_training.py's test_train_timegan_base_smoke and report_training_comparison.py (capture --fn base): output shape/dtype and the "num_generate defaults to matching training-event count" behavior are unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DbnvNbSohNTTvL8MDx5RUU --- tests/test_training.py | 9 +- timegan/timegan.py | 246 ++++++----------------------------------- 2 files changed, 40 insertions(+), 215 deletions(-) diff --git a/tests/test_training.py b/tests/test_training.py index 89affde..79d04fd 100644 --- a/tests/test_training.py +++ b/tests/test_training.py @@ -93,9 +93,12 @@ def test_train_timegan_timed_on_energy_smoke(): def test_train_timegan_base_smoke(): - """timegan.py's original (non-timed) train_timegan -- the other half - of the duplication pattern flagged alongside timegan_static.py. Not - used by Traces_GAN, but same-treatment coverage costs little.""" + """timegan.py's train_timegan -- now a thin wrapper around + train_timegan_timed (was previously a full standalone copy of its + graph-building/training-loop code, the same "same fix needs applying + twice" risk that let timegan_static.py's copy silently break). Not + used by Traces_GAN directly, but this is the test that actually + exercises the wrapper end to end.""" np.random.seed(2) tf.compat.v1.set_random_seed(2) ori_data = _subsample("stock", seq_len=8, n_events=24) diff --git a/timegan/timegan.py b/timegan/timegan.py index 4572e1e..0b31b96 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -234,10 +234,23 @@ def discriminator(H, T, param): def train_timegan(ori_data, parameters, filename="timegan_save", version=0): - """TimeGAN training function. + """TimeGAN training function -- one-shot, non-resumable convenience + wrapper around train_timegan_timed(). + + This used to be a full, independent copy of train_timegan_timed's graph- + building/training-loop code (predating the phase/resume/timing additions + there), duplicating everything including its own copies of every + embedder/generator/discriminator call and loss formula. That's the exact + kind of drift risk that let timegan_static.py's copy of the same code + silently break (see that file's history) -- two places to apply the same + fix, easy to only remember one. train_timegan_timed's defaults + (gamma=1, g_loss_s_weight=g_loss_v_weight=100) already reproduce this + function's previously-hardcoded loss weights exactly, so delegating + changes no behavior for any existing caller that isn't passing those + keys. Use original data as training set to generater synthetic data (time-series) - Trains timegan from scratch + Trains timegan from scratch, in a single call, to completion. Args: - ori_data: original time-series data @@ -247,218 +260,27 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): Returns: - generated_data: generated time-series data - """ - # Initialization on the Graph - tf.compat.v1.reset_default_graph() - - # Basic Parameters - no, seq_len, dim = np.asarray(ori_data).shape - - # Maximum sequence length and each sequence length - ori_time, max_seq_len = extract_time(ori_data) - - # Normalization - ori_data, min_val, max_val = MinMaxScaler(ori_data) - - ## Build a RNN networks - - # Network Parameters - hidden_dim = parameters["hidden_dim"] - num_layers = parameters["num_layer"] - iterations = parameters["iterations"] - batch_size = parameters["batch_size"] - module_name = parameters["module"] - parameters["dim"] = dim - z_dim = dim - gamma = 1 - # Input place holders - X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") - Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") - T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - - # Embedder & Recovery - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) - - # Generator - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) - - # Synthetic data - X_hat = recovery(H_hat, T, parameters) - - # Discriminator - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) - - # Variables - e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] - r_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("recovery")] - g_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("generator")] - s_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("supervisor")] - d_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("discriminator")] - - # Discriminator loss - D_loss_real = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_real), Y_real) - D_loss_fake = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake), Y_fake) - D_loss_fake_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake_e), Y_fake_e) - D_loss = D_loss_real + D_loss_fake + gamma * D_loss_fake_e - - # Generator loss - # 1. Adversarial loss - G_loss_U = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake), Y_fake) - G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) - - # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(H[:, 1:, :], H_hat_supervise[:, :-1, :]) - - # 3. Two Momments - G_loss_V1 = tf.reduce_mean( - input_tensor=tf.abs( - tf.sqrt(tf.nn.moments(x=X_hat, axes=[0])[1] + 1e-6) - tf.sqrt(tf.nn.moments(x=X, axes=[0])[1] + 1e-6) - ) - ) - G_loss_V2 = tf.reduce_mean( - input_tensor=tf.abs((tf.nn.moments(x=X_hat, axes=[0])[0]) - (tf.nn.moments(x=X, axes=[0])[0])) + Side effects new since the standalone-copy version: also writes + `_history.jsonl` (loss/variance_check history -- harmless + additional output, nothing removed) and names the Saver's checkpoint + pointer file after `filename` instead of TF's shared default bare + "checkpoint" (an improvement, not a regression -- that shared pointer + file is exactly what used to get clobbered across different runs/ + filenames, which is why train_timegan_timed stopped using it). + """ + phase, info = train_timegan_timed( + ori_data, parameters, in_filename=filename, out_filename=filename, + seconds=float("inf"), phase=1, current_iter=0, new=True, version=version, ) - - G_loss_V = G_loss_V1 + G_loss_V2 - - # 4. Summation - G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V - - # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) - E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) - E_loss = E_loss0 + 0.1 * G_loss_S - - # optimizer - E0_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss0, var_list=e_vars + r_vars) - E_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss, var_list=e_vars + r_vars) - D_solver = tf.compat.v1.train.AdamOptimizer().minimize(D_loss, var_list=d_vars) - G_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss, var_list=g_vars + s_vars) - GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) - - ## TimeGAN training - sess = tf.compat.v1.Session() - sess.run(tf.compat.v1.global_variables_initializer()) - - # Create Saver - saver = tf.compat.v1.train.Saver() - - # 1. Embedding network training - print("Start Embedding Network Training") - - for itt in range(iterations): - # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) - # Train embedder - _, step_e_loss = sess.run([E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb}) - # Checkpoint - if itt % 10 == 0: # Formerly 100 or 1000 - print( - "step: " - + str(itt) - + "/" - + str(iterations) - + ", e_loss: " - + str(np.round(np.sqrt(step_e_loss + 1e-6), 4)) - ) - - print("Finish Embedding Network Training") - - # 2. Training only with supervised loss - print("Start Training with Supervised Loss Only") - - for itt in range(iterations): - # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) - # Random vector generation - Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) - # Train generator - _, step_g_loss_s = sess.run([GS_solver, G_loss_S], feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) - # Checkpoint - if itt % 10 == 0: # Formerly 100 or 1000 - print( - "step: " - + str(itt) - + "/" - + str(iterations) - + ", s_loss: " - + str(np.round(np.sqrt(step_g_loss_s + 1e-6), 4)) - ) - - print("Finish Training with Supervised Loss Only") - - # 3. Joint Training - print("Start Joint Training") - - for itt in range(iterations): - # Generator training (twice more than discriminator training) - for kk in range(2): - # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) - # Random vector generation - Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) - # Train generator - _, step_g_loss_u, step_g_loss_s, step_g_loss_v = sess.run( - [G_solver, G_loss_U, G_loss_S, G_loss_V], feed_dict={Z: Z_mb, X: X_mb, T: T_mb} - ) - # Train embedder - _, step_e_loss_t0 = sess.run([E_solver, E_loss_T0], feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) - - # Discriminator training - # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) - # Random vector generation - Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) - # Check discriminator loss before updating - check_d_loss = sess.run(D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb}) - # Train discriminator (only when the discriminator does not work well) - if check_d_loss > 0.15: - _, step_d_loss = sess.run([D_solver, D_loss], feed_dict={X: X_mb, T: T_mb, Z: Z_mb}) - - # Print multiple checkpoints - if itt % 10 == 0: # Formerly 100 or 1000 - print( - "step: " - + str(itt) - + "/" - + str(iterations) - + ", d_loss: " - + str(np.round(step_d_loss, 4)) - + ", g_loss_u: " - + str(np.round(step_g_loss_u, 4)) - + ", g_loss_s: " - + str(np.round(np.sqrt(step_g_loss_s + 1e-6), 4)) - + ", g_loss_v: " - + str(np.round(step_g_loss_v, 4)) - + ", e_loss_t0: " - + str(np.round(np.sqrt(step_e_loss_t0 + 1e-6), 4)) - ) - print("Finish Joint Training") - - ## Synthetic data generation - Z_mb = random_generator(no, z_dim, ori_time, max_seq_len) - generated_data_curr = sess.run(X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time}) - - generated_data = list() - - for i in range(no): - temp = generated_data_curr[i, : ori_time[i], :] - generated_data.append(temp) - - # Renormalization - generated_data = generated_data * max_val - generated_data = generated_data + min_val - - # Save - saver.save(sess, filename, global_step=version) - - return generated_data + if phase == -1: + raise RuntimeError(f"train_timegan_timed failed: {info}") + if phase != 4: + # Unreachable with seconds=inf (the time-budget check can never + # trip), but fail loudly rather than silently returning partial + # training if that assumption is ever wrong. + raise RuntimeError(f"train_timegan_timed did not reach phase 4 (got phase={phase}) despite seconds=inf") + return info def train_timegan_timed( From 6fa7c803b5928aa928d158baf2418f1faa78dd42 Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Mon, 14 Sep 2026 14:10:02 -0600 Subject: [PATCH 04/10] Delete timegan_static.py: fold static-feature support into timegan.py Merges embedder/recovery/generator/discriminator's static-feature capability (fixed in the previous commits) directly into timegan.py's own versions of those functions, via a new optional S=None parameter: when S is None, behavior is byte-for-byte unchanged from before this had any static-feature support at all (same graph ops built, nothing extra even constructed); when S is given, the function also embeds/ discriminates it and returns a (temporal, static) tuple instead of a single value. Existing callers passing no S are completely unaffected. Extends train_timegan_timed, train_timegan, and load_timegan the same way via an optional ori_data_static=None parameter -- when given, builds S/S_z placeholders, threads them through batching (via batch_generator's new optional `static` param, itself backward compatible: omitting it keeps the original 2-tuple return) and every feed_dict, and wires up the branch-specific graph construction. Static features are genuinely embedded/discriminated (HS/ES/XS_tilde/XS_hat/ YS_* are real graph nodes), but still intentionally don't participate in any loss term -- that's the same open design decision noted in the previous commits, not resolved here. With that, timegan_static.py has nothing left that isn't now also in timegan.py, so it's deleted outright -- not kept as a compatibility shim, since (a) it never successfully ran in this repo's history until the previous two commits fixed it, so there's no working behavior for a shim to preserve, and (b) a GitHub code search turned up zero public repos importing `timegan.timegan_static` specifically. Updates tests/test_training.py and report_training_comparison.py to call the merged functions with ori_data_static= instead of importing from the now-deleted module. All 5 tests still pass; report tool verified against all three training entry points (timed/base/static). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DbnvNbSohNTTvL8MDx5RUU --- report_training_comparison.py | 21 +- tests/test_training.py | 71 ++-- timegan/timegan.py | 349 +++++++++++------- timegan/timegan_static.py | 673 ---------------------------------- timegan/utils.py | 12 +- 5 files changed, 287 insertions(+), 839 deletions(-) delete mode 100644 timegan/timegan_static.py diff --git a/report_training_comparison.py b/report_training_comparison.py index 9bb5267..7a9a2a8 100644 --- a/report_training_comparison.py +++ b/report_training_comparison.py @@ -1,6 +1,7 @@ -"""Capture/compare tool for checking whether a code change (e.g. the -embedder/recovery/generator/discriminator consolidation planned for -timegan.py + timegan_static.py) altered training behavior. +"""Capture/compare tool for checking whether a code change (e.g. to +timegan.py's embedder/recovery/generator/discriminator, now the only +copy of that logic after folding in timegan_static.py's static-feature +support and deleting that file) altered training behavior. Why this exists instead of a golden-value test: verified directly that this TF1 graph-mode code is NOT bit-reproducible in this environment even @@ -26,8 +27,8 @@ python3 report_training_comparison.py compare before after Tracks generated-output summary statistics (mean/std/min/max), the one -thing all three training entry points (train_timegan_timed, the base -train_timegan, and timegan_static's train_timegan) return in common. +thing train_timegan_timed and train_timegan (with or without +ori_data_static) all return in common. """ import argparse import json @@ -39,9 +40,8 @@ import tensorflow as tf from timegan.data_loading import real_data_loading -from timegan.timegan import train_timegan as train_timegan_base +from timegan.timegan import train_timegan from timegan.timegan import train_timegan_timed -from timegan.timegan_static import train_timegan as train_timegan_static OUT_DIR = ".training_comparison" TINY_PARAMS = dict(hidden_dim=4, num_layer=2, batch_size=8, module="gru", iterations=2) @@ -78,15 +78,16 @@ def _run_once(fn_name, seed): if phase != 4: raise RuntimeError(f"train_timegan_timed didn't finish in one call (phase={phase})") else: - generated = train_timegan_base( + generated = train_timegan( ori_data, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", ) elif fn_name == "static": no, seq_len, dim = 32, 6, 3 ori_data = np.random.uniform(0, 1, [no, seq_len, dim]).astype(np.float32) ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) - generated = train_timegan_static( - ori_data, ori_data_static, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", + generated = train_timegan( + ori_data, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", + ori_data_static=ori_data_static, ) else: raise ValueError(f"unknown fn: {fn_name}") diff --git a/tests/test_training.py b/tests/test_training.py index 79d04fd..e603dbc 100644 --- a/tests/test_training.py +++ b/tests/test_training.py @@ -4,9 +4,13 @@ Why real training, not mocks: this codebase's actual failure modes are things like "the graph doesn't build" (wrong arg count, undefined name, -malformed tf.concat -- all found and fixed in timegan_static.py while -writing these) or "training silently produces NaN/garbage" -- none of -which a mocked-out training loop would ever exercise. +malformed tf.concat -- all found while writing these, when this repo's +static-feature support lived in a second, entirely separate file, +timegan_static.py, since deleted and folded into timegan.py's own +embedder/recovery/generator/discriminator/train_timegan/train_timegan_timed/ +load_timegan as an optional S/ori_data_static parameter) or "training +silently produces NaN/garbage" -- none of which a mocked-out training loop +would ever exercise. Why statistical/structural assertions, not exact golden values: verified directly (see report_training_comparison.py and the module docstring @@ -28,10 +32,9 @@ import tensorflow as tf from timegan.data_loading import real_data_loading -from timegan.timegan import train_timegan as train_timegan_base +from timegan.timegan import load_timegan +from timegan.timegan import train_timegan from timegan.timegan import train_timegan_timed -from timegan.timegan_static import load_timegan as load_timegan_static -from timegan.timegan_static import train_timegan as train_timegan_static TINY_PARAMS = dict(hidden_dim=4, num_layer=2, batch_size=8, module="gru") # supervisor() builds num_layers - 1 RNN cells -- num_layer=1 would pass an @@ -93,33 +96,37 @@ def test_train_timegan_timed_on_energy_smoke(): def test_train_timegan_base_smoke(): - """timegan.py's train_timegan -- now a thin wrapper around + """timegan.py's train_timegan -- a thin wrapper around train_timegan_timed (was previously a full standalone copy of its - graph-building/training-loop code, the same "same fix needs applying - twice" risk that let timegan_static.py's copy silently break). Not - used by Traces_GAN directly, but this is the test that actually - exercises the wrapper end to end.""" + graph-building/training-loop code -- the same "same fix needs applying + in more than one place" risk that let timegan_static.py's copy of this + code drift until it no longer even ran). Not used by Traces_GAN + directly, but this is the test that actually exercises the wrapper end + to end.""" np.random.seed(2) tf.compat.v1.set_random_seed(2) ori_data = _subsample("stock", seq_len=8, n_events=24) - generated = train_timegan_base( + generated = train_timegan( ori_data, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_base_stock", ) _assert_sane_generated_output(generated, expected_shape=(24, 8, ori_data.shape[-1])) -def test_train_timegan_static_on_synthetic(): - """First real test of timegan_static.py's train_timegan, ever -- this - function could not previously be imported (unrelated `utils` package - shadowing its intended local import) or called (wrong argument counts - throughout; unpack_parameters never updated for the extra static- - feature params; a bare undefined `sigmoid`; a typo'd TF method name; - malformed tf.concat calls) -- all fixed to get to this point. Static - features are genuinely embedded/discriminated (HS/ES/XS_tilde/XS_hat/ - YS_* below are real graph nodes), but intentionally don't participate - in the loss yet -- see train_timegan's docstring. +def test_train_timegan_with_static_features_on_synthetic(): + """Static-feature support, folded into timegan.py's own train_timegan + (via the optional ori_data_static param) from what used to be a + second, entirely separate file (timegan_static.py) that could not + previously be imported (unrelated `utils` package shadowing its + intended local import) or called (wrong argument counts throughout; + unpack_parameters never updated for the extra static-feature params; a + bare undefined `sigmoid`; a typo'd TF method name; malformed + tf.concat calls) -- all fixed, then merged, to get to this point. + Static features are genuinely embedded/discriminated (HS/ES/XS_tilde/ + XS_hat/YS_* in timegan.py's component functions are real graph nodes), + but intentionally don't participate in the loss yet -- see + train_timegan_timed's docstring. Synthetic data, not a bundled dataset: none of the bundled data ships a static-feature column, so this constructs its own small windowed @@ -132,16 +139,18 @@ def test_train_timegan_static_on_synthetic(): ori_data = np.random.uniform(0, 1, [no, seq_len, dim]).astype(np.float32) ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) - generated = train_timegan_static( - ori_data, ori_data_static, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_static_synth", + generated = train_timegan( + ori_data, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_static_synth", + ori_data_static=ori_data_static, ) _assert_sane_generated_output(generated, expected_shape=(no, seq_len, dim)) -def test_load_timegan_static_round_trip(): - """train then immediately load the same checkpoint -- confirms - load_timegan's identical fix (same bugs, same treatment) actually +def test_load_timegan_with_static_features_round_trip(): + """train then immediately load the same checkpoint, with static + features -- confirms load_timegan's identical treatment (same + optional ori_data_static param, same two-branch graph wiring) also works, not just train_timegan's.""" np.random.seed(4) tf.compat.v1.set_random_seed(4) @@ -150,7 +159,11 @@ def test_load_timegan_static_round_trip(): ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) params = dict(TINY_PARAMS, iterations=2) - train_timegan_static(ori_data, ori_data_static, dict(params), filename="/tmp/test_roundtrip") - generated = load_timegan_static(ori_data, ori_data_static, dict(params), filename="/tmp/test_roundtrip-0") + train_timegan( + ori_data, dict(params), filename="/tmp/test_roundtrip", ori_data_static=ori_data_static, + ) + generated = load_timegan( + ori_data, dict(params), filename="/tmp/test_roundtrip-0", ori_data_static=ori_data_static, + ) _assert_sane_generated_output(generated, expected_shape=(no, seq_len, dim)) diff --git a/timegan/timegan.py b/timegan/timegan.py index 0b31b96..9584f6b 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -68,121 +68,86 @@ def unpack_parameters(parameters): # Components of the network -def embedder(X, T, param): +def embedder(X, T, param, S=None): """Embedding network between original feature space to latent space. Args: - X: input time-series features - T: input time information + - S: optional per-event static features, shape (batch, static_dim). + When given, also embeds them (independently of the temporal + branch -- see timegan_static.py's git history, folded into this + file, for why: merging a per-timestep tensor with a per-event + one needs a broadcast/tiling scheme nothing here has designed). + param["static_dim"] must be set when S is given. Returns: - - H: embeddings - - Update: - Attempting to update tensorflow v1.x code to v2.x - Currently able to do so with e_outputs, but may lose e_last_states along the way + - H: embeddings, if S is None (unchanged from before this had any + static-feature support -- existing callers are unaffected) + - (H, HS): embeddings and static embeddings, if S is given """ hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) dim = z_dim with tf.compat.v1.variable_scope("embedder", reuse=tf.compat.v1.AUTO_REUSE): - # e_cell = StackedRNNCells([LSTMCell(hidden_dim) for _ in range(num_layers)]) e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - - # print("E cell: " , e_cell, "\nX: ", X) - - # rnn_layer = tf.keras.layers.RNN(e_cell, return_state=True) - - # e_outputs, e_last_states = rnn_layer(X) - - # e_outputs = tf.keras.layers.RNN(e_cell)(X) - # e_last_states = X - - # print("e out: " , e_outputs, "\n X ", e_last_states) - - # e_outputs, e_last_states = tf.keras.layers.RNN(e_cell, return_sequences=True, return_state=True)(X) e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, X, dtype=tf.float32, sequence_length=T) - - # H = tf.keras.layers.Dense(hidden_dim, activation=tf.nn.sigmoid)(e_outputs) H = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) - print("H: ", H) - return H - - -''' -# Incorperate static features -def embedder (X, T, S, param): - """Embedding network between original feature space to latent space. - - Args: - - X: input time-series features - - T: input time information - - S: static features - - Returns: - - HT: time-series embeddings - - HS: static embeddings - """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - dim = z_dim - with tf.compat.v1.variable_scope("embedder", reuse = tf.compat.v1.AUTO_REUSE): - print("e_cell") # TEMP - e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - print("e_output") # TEMP - e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, X, dtype=tf.float32, sequence_length = T) - # TODO: Implement seperate hiddem dim and num layers for static features - HS = _layers.stack(S, _layers.fully_connected, [hidden_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid) - # TODO, concatinate e_outputs and HS - h_inputs = # TODO - H = _layers.fully_connected(h_inputs, hidden_dim, activation_fn=tf.nn.sigmoid) + if S is None: + return H + static_dim = param["static_dim"] + HS = _layers.stack( + S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid + ) return H, HS -''' -def recovery(H, T, param): +def recovery(H, T, param, S=None): """Recovery network from latent space to original space. Args: - H: latent representation - T: input time information + - S: optional static-feature latent representation (e.g. HS from + embedder(), or ES from generator()) to recover back to raw + static-feature space. param["static_dim"] must be set when + given. Returns: - - X_tilde: recovered data + - X_tilde: recovered data, if S is None (unchanged from before this + had any static-feature support) + - (X_tilde, XS_tilde): recovered temporal and static data, if S is + given """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) dim = z_dim - - # print("H shape: ", X.shape) with tf.compat.v1.variable_scope("recovery", reuse=tf.compat.v1.AUTO_REUSE): - # r_cell = StackedRNNCells([LSTMCell(hidden_dim) for _ in range(num_layers)]) - # print("hidden_dim: ", hidden_dim, "num_layers: ", num_layers) - # print("R cell: ", r_cell) - # print("H shape: ", X.shape) - # r_outputs = tf.keras.layers.RNN(r_cell)(H) - # X_tilde = tf.keras.layers.Dense(dim, activation=tf.nn.sigmoid)(r_outputs) r_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) r_outputs, r_last_states = tf.compat.v1.nn.dynamic_rnn(r_cell, H, dtype=tf.float32, sequence_length=T) X_tilde = _layers.fully_connected(r_outputs, dim, activation_fn=tf.nn.sigmoid) - - return X_tilde - - # hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - # dim = z_dim - # with tf.compat.v1.variable_scope("recovery", reuse=tf.compat.v1.AUTO_REUSE): - - # - # return X_tilde + if S is None: + return X_tilde + static_dim = param["static_dim"] + rs_outputs = _layers.stack( + S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid + ) + XS_tilde = _layers.fully_connected(rs_outputs, static_dim, activation_fn=tf.nn.sigmoid) + return X_tilde, XS_tilde -def generator(Z, T, param): +def generator(Z, T, param, S=None): """Generator function: Generate time-series data in latent space. Args: - Z: random variables - T: input time information + - S: optional per-event random static-noise input, shape + (batch, static_dim) -- the static-feature analog of Z. + param["static_dim"] must be set when given. Returns: - - E: generated embedding + - E: generated embedding, if S is None (unchanged from before this + had any static-feature support) + - (E, ES): generated temporal and static embeddings, if S is given """ hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) dim = z_dim @@ -190,7 +155,13 @@ def generator(Z, T, param): e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, Z, dtype=tf.float32, sequence_length=T) E = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) - return E + if S is None: + return E + static_dim = param["static_dim"] + ES = _layers.stack( + S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid + ) + return E, ES def supervisor(H, T, param): @@ -214,15 +185,23 @@ def supervisor(H, T, param): return S -def discriminator(H, T, param): +def discriminator(H, T, param, S=None): """Discriminate the original and synthetic time-series data. Args: - H: latent representation - T: input time information + - S: optional static-feature latent representation (e.g. HS from + embedder(), or ES from generator()) to discriminate real vs. + synthetic static features. param["static_dim"] must be set + when given. Returns: - - Y_hat: classification results between original and synthetic time-series + - Y_hat: classification results between original and synthetic + time-series, if S is None (unchanged from before this had any + static-feature support) + - (Y_hat, YS_hat): temporal and static classification results, if S + is given """ hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) dim = z_dim @@ -230,10 +209,17 @@ def discriminator(H, T, param): d_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) d_outputs, d_last_states = tf.compat.v1.nn.dynamic_rnn(d_cell, H, dtype=tf.float32, sequence_length=T) Y_hat = _layers.fully_connected(d_outputs, 1, activation_fn=None) - return Y_hat + if S is None: + return Y_hat + static_dim = param["static_dim"] + ds_outputs = _layers.stack( + S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid + ) + YS_hat = _layers.fully_connected(ds_outputs, 1, activation_fn=tf.nn.sigmoid) + return Y_hat, YS_hat -def train_timegan(ori_data, parameters, filename="timegan_save", version=0): +def train_timegan(ori_data, parameters, filename="timegan_save", version=0, ori_data_static=None): """TimeGAN training function -- one-shot, non-resumable convenience wrapper around train_timegan_timed(). @@ -257,6 +243,8 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): - parameters: TimeGAN network parameters - filename: filename to save the model in, default "timegan_save" - version: version of the snapshot, default 0 + - ori_data_static: optional per-event static features -- see + train_timegan_timed's docstring, passed straight through Returns: - generated_data: generated time-series data @@ -272,6 +260,7 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): phase, info = train_timegan_timed( ori_data, parameters, in_filename=filename, out_filename=filename, seconds=float("inf"), phase=1, current_iter=0, new=True, version=version, + ori_data_static=ori_data_static, ) if phase == -1: raise RuntimeError(f"train_timegan_timed failed: {info}") @@ -285,7 +274,7 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0): def train_timegan_timed( ori_data, parameters, in_filename, out_filename=None, seconds=3600, phase=1, current_iter=0, new=False, version=0, - num_generate=None, on_training_complete=None, + num_generate=None, on_training_complete=None, ori_data_static=None, ): """Trains a TimeGAN model for a specific number of seconds, then stops and saves the session. @@ -348,6 +337,16 @@ def train_timegan_timed( training is done (e.g. write its own progress marker) in case generation itself gets killed before this function returns -- a resubmit with phase=4 and new=False resumes directly at generation using that checkpoint, skipping training entirely. + - ori_data_static: optional per-event static features, shape + (no, static_dim). When given, static features are genuinely + embedded/discriminated (real graph nodes throughout: HS/ES/ + XS_tilde/XS_hat/YS_* below), but intentionally don't participate + in any loss term yet -- training still only optimizes the + temporal reconstruction/adversarial objective. Wiring static + loss terms in is separate follow-up work (how should they + combine with the temporal ones?); this makes the plumbing + correct for what it does do, without changing anything about + the temporal path when omitted (the default). Returns: A tuple of variable length; the first element of the tuple determines its length and content. @@ -390,6 +389,7 @@ def log_history(record): # Basic Parameters no, seq_len, dim = np.asarray(ori_data).shape + static_dim = np.asarray(ori_data_static).shape[-1] if ori_data_static is not None else None # Maximum sequence length and each sequence length ori_time, max_seq_len = extract_time(ori_data) @@ -406,6 +406,7 @@ def log_history(record): batch_size = parameters["batch_size"] module_name = parameters["module"] parameters["dim"] = dim + parameters["static_dim"] = static_dim z_dim = dim # setdefault (not a plain local default) so the resolved value -- whether # the caller supplied one or this default -- lands back in the caller's @@ -422,22 +423,74 @@ def log_history(record): Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - # Embedder & Recovery - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) - - # Generator - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) - - # Synthetic data - X_hat = recovery(H_hat, T, parameters) - - # Discriminator - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) + # Embedder, Recovery, Generator, Discriminator -- two branches (rather + # than always passing S=None and letting each function's own S-is-None + # check no-op) because embedder/recovery/generator/discriminator return + # a plain value when S is None and a tuple when it's given: forcing the + # same code to handle both would mean every line here unpacking a tuple + # or not depending on a value only known at runtime. This is the one + # place that fork needs to happen, now that it isn't duplicated across + # two entire files. + if ori_data_static is not None: + S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") + S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") + + H, HS = embedder(X, T, parameters, S=S) + X_tilde, XS_tilde = recovery(H, T, parameters, S=HS) + + E_hat, ES = generator(Z, T, parameters, S=S_z) + H_hat = supervisor(E_hat, T, parameters) + H_hat_supervise = supervisor(H, T, parameters) + + X_hat, XS_hat = recovery(H_hat, T, parameters, S=ES) + + Y_fake, YS_fake = discriminator(H_hat, T, parameters, S=ES) + Y_real, YS_real = discriminator(H, T, parameters, S=HS) + Y_fake_e, YS_fake_e = discriminator(E_hat, T, parameters, S=ES) + else: + H = embedder(X, T, parameters) + X_tilde = recovery(H, T, parameters) + + E_hat = generator(Z, T, parameters) + H_hat = supervisor(E_hat, T, parameters) + H_hat_supervise = supervisor(H, T, parameters) + + X_hat = recovery(H_hat, T, parameters) + + Y_fake = discriminator(H_hat, T, parameters) + Y_real = discriminator(H, T, parameters) + Y_fake_e = discriminator(E_hat, T, parameters) + + def _draw_batch(): + """batch_generator(), plus a static-feature slice (aligned to the + same drawn indices) when ori_data_static was given -- always + returns a 3-tuple so every call site below can unpack the same + way regardless, with S_mb simply None when there's nothing to + slice.""" + if ori_data_static is not None: + return batch_generator(ori_data, ori_time, batch_size, static=ori_data_static) + x_mb, t_mb = batch_generator(ori_data, ori_time, batch_size) + return x_mb, t_mb, None + + def _draw_static_noise(n): + """Sz_mb: the static-feature analog of Z_mb (random_generator) -- + no time dimension, since static features aren't per-timestep.""" + if ori_data_static is None: + return None + return np.random.uniform(0.0, 1, [n, static_dim]) + + def _static_extras(s_mb=None, sz_mb=None): + """Extra feed_dict entries for S/S_z, or {} when there's nothing + static in play -- merge into a feed_dict with **, safe to call + unconditionally (only references S/S_z, which only exist as names + when ori_data_static was given, if s_mb/sz_mb are actually not + None, which only happens in that same case).""" + extras = {} + if s_mb is not None: + extras[S] = s_mb + if sz_mb is not None: + extras[S_z] = sz_mb + return extras # Variables e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] @@ -513,9 +566,11 @@ def log_history(record): for itt in range(current_iter, iterations): # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _draw_batch() # Train embedder - _, step_e_loss = sess.run([E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb}) + _, step_e_loss = sess.run( + [E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb, **_static_extras(S_mb)} + ) if itt % 50 == 0: elapsed = (time_ns() - start_time) / 1e9 @@ -539,11 +594,15 @@ def log_history(record): for itt in range(current_iter, iterations): # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _draw_batch() # Random vector generation Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) + Sz_mb = _draw_static_noise(batch_size) # Train generator - _, step_g_loss_s = sess.run([GS_solver, G_loss_S], feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) + _, step_g_loss_s = sess.run( + [GS_solver, G_loss_S], + feed_dict={Z: Z_mb, X: X_mb, T: T_mb, **_static_extras(S_mb, Sz_mb)}, + ) if itt % 50 == 0: elapsed = (time_ns() - start_time) / 1e9 @@ -569,26 +628,37 @@ def log_history(record): # Generator training (twice more than discriminator training) for kk in range(2): # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _draw_batch() # Random vector generation Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) + Sz_mb = _draw_static_noise(batch_size) # Train generator _, step_g_loss_u, step_g_loss_s, step_g_loss_v = sess.run( - [G_solver, G_loss_U, G_loss_S, G_loss_V], feed_dict={Z: Z_mb, X: X_mb, T: T_mb} + [G_solver, G_loss_U, G_loss_S, G_loss_V], + feed_dict={Z: Z_mb, X: X_mb, T: T_mb, **_static_extras(S_mb, Sz_mb)}, ) # Train embedder - _, step_e_loss_t0 = sess.run([E_solver, E_loss_T0], feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) + _, step_e_loss_t0 = sess.run( + [E_solver, E_loss_T0], + feed_dict={Z: Z_mb, X: X_mb, T: T_mb, **_static_extras(S_mb, Sz_mb)}, + ) # Discriminator training # Set mini-batch - X_mb, T_mb = batch_generator(ori_data, ori_time, batch_size) + X_mb, T_mb, S_mb = _draw_batch() # Random vector generation Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) + Sz_mb = _draw_static_noise(batch_size) # Check discriminator loss before updating - check_d_loss = sess.run(D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb}) + check_d_loss = sess.run( + D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb, **_static_extras(S_mb, Sz_mb)} + ) # Train discriminator (only when the discriminator does not work well) if check_d_loss > 0.15: - _, step_d_loss = sess.run([D_solver, D_loss], feed_dict={X: X_mb, T: T_mb, Z: Z_mb}) + _, step_d_loss = sess.run( + [D_solver, D_loss], + feed_dict={X: X_mb, T: T_mb, Z: Z_mb, **_static_extras(S_mb, Sz_mb)}, + ) if itt % 50 == 0: elapsed = (time_ns() - start_time) / 1e9 @@ -604,7 +674,9 @@ def log_history(record): # -- a stable-looking d_loss/g_loss_u is consistent with # both a healthy adversarial game AND a generator that's # settled for reproducing the dominant mode. - gen_batch = sess.run(X_hat, feed_dict={Z: Z_mb, X: X_mb, T: T_mb}) + gen_batch = sess.run( + X_hat, feed_dict={Z: Z_mb, X: X_mb, T: T_mb, **_static_extras(S_mb, Sz_mb)} + ) real_std = np.std(X_mb, axis=0) gen_std = np.std(gen_batch, axis=0) ratio = gen_std / (real_std + 1e-8) @@ -676,8 +748,10 @@ def log_history(record): T_chunk = gen_time[start:end] Z_chunk = random_generator(end - start, z_dim, T_chunk, max_seq_len) X_chunk = ori_data[[i % no for i in range(start, end)]] + S_chunk = ori_data_static[[i % no for i in range(start, end)]] if ori_data_static is not None else None + Sz_chunk = _draw_static_noise(end - start) generated_data_curr[start:end] = sess.run( - X_hat, feed_dict={Z: Z_chunk, X: X_chunk, T: T_chunk} + X_hat, feed_dict={Z: Z_chunk, X: X_chunk, T: T_chunk, **_static_extras(S_chunk, Sz_chunk)} ) # Chunks, not individual sequences, so this can't reuse the `itt % 50` @@ -703,7 +777,7 @@ def log_history(record): return (4, generated_data) -def load_timegan(ori_data, parameters, filename): +def load_timegan(ori_data, parameters, filename, ori_data_static=None): """TimeGAN function. Use original data as training set to generater synthetic data (time-series) @@ -713,6 +787,11 @@ def load_timegan(ori_data, parameters, filename): - ori_data: original time-series data - parameters: TimeGAN network parameters - filename: filename of the snapshot to load + - ori_data_static: optional per-event static features, shape + (no, static_dim) -- see train_timegan_timed's docstring for what + this does and doesn't do. Only matters here insofar as the + checkpoint being restored was itself trained with (or without) + static features -- pass whatever was used for training. Returns: - generated_data: generated time-series data @@ -722,6 +801,7 @@ def load_timegan(ori_data, parameters, filename): # Basic Parameters no, seq_len, dim = np.asarray(ori_data).shape + static_dim = np.asarray(ori_data_static).shape[-1] if ori_data_static is not None else None # Maximum sequence length and each sequence length ori_time, max_seq_len = extract_time(ori_data) @@ -738,6 +818,7 @@ def load_timegan(ori_data, parameters, filename): batch_size = parameters["batch_size"] module_name = parameters["module"] parameters["dim"] = dim + parameters["static_dim"] = static_dim z_dim = dim gamma = 1 @@ -746,22 +827,39 @@ def load_timegan(ori_data, parameters, filename): Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - # Embedder & Recovery - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) + # Embedder, Recovery, Generator, Discriminator -- same two-branch + # reasoning as train_timegan_timed above: the checkpoint being restored + # must have been built with the exact same graph shape it was saved + # with, so this needs to match whichever branch actually trained it. + if ori_data_static is not None: + S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") + S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") + + H, HS = embedder(X, T, parameters, S=S) + X_tilde, XS_tilde = recovery(H, T, parameters, S=HS) + + E_hat, ES = generator(Z, T, parameters, S=S_z) + H_hat = supervisor(E_hat, T, parameters) + H_hat_supervise = supervisor(H, T, parameters) + + X_hat, XS_hat = recovery(H_hat, T, parameters, S=ES) + + Y_fake, YS_fake = discriminator(H_hat, T, parameters, S=ES) + Y_real, YS_real = discriminator(H, T, parameters, S=HS) + Y_fake_e, YS_fake_e = discriminator(E_hat, T, parameters, S=ES) + else: + H = embedder(X, T, parameters) + X_tilde = recovery(H, T, parameters) - # Generator - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) + E_hat = generator(Z, T, parameters) + H_hat = supervisor(E_hat, T, parameters) + H_hat_supervise = supervisor(H, T, parameters) - # Synthetic data - X_hat = recovery(H_hat, T, parameters) + X_hat = recovery(H_hat, T, parameters) - # Discriminator - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) + Y_fake = discriminator(H_hat, T, parameters) + Y_real = discriminator(H, T, parameters) + Y_fake_e = discriminator(E_hat, T, parameters) # Variables e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] @@ -818,7 +916,10 @@ def load_timegan(ori_data, parameters, filename): # Synthetic data generation Z_mb = random_generator(no, z_dim, ori_time, max_seq_len) - generated_data_curr = sess.run(X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time}) + static_extras = {} + if ori_data_static is not None: + static_extras = {S: ori_data_static, S_z: np.random.uniform(0.0, 1, [no, static_dim])} + generated_data_curr = sess.run(X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time, **static_extras}) generated_data = list() diff --git a/timegan/timegan_static.py b/timegan/timegan_static.py deleted file mode 100644 index db96a9c..0000000 --- a/timegan/timegan_static.py +++ /dev/null @@ -1,673 +0,0 @@ -"""Time-series Generative Adversarial Networks (TimeGAN) Codebase. - -Reference: Jinsung Yoon, Daniel Jarrett, Mihaela van der Schaar, -"Time-series Generative Adversarial Networks," -Neural Information Processing Systems (NeurIPS), 2019. - -Paper link: https://papers.nips.cc/paper/8789-time-series-generative-adversarial-networks - -Last updated Date: April 24th 2020 -Code author: Jinsung Yoon (jsyoon0823@gmail.com) - ------------------------------ - -timegan.py - -Note: Use original data as training set to generater synthetic data (time-series) -""" - -# mypy: ignore-errors -# Necessary Packages -import numpy as np -import tensorflow as tf -from tf_slim.layers import layers as _layers -from timegan.utils import batch_generator, extract_time, random_generator, rnn_cell - -tf.compat.v1.disable_eager_execution() -tf.compat.v1.disable_v2_behavior() - - -# Min Max Scaler -def MinMaxScaler(data): - """Min-Max Normalizer. - - Args: - - data: raw data - - Returns: - - norm_data: normalized data - - min_val: minimum values (for renormalization) - - max_val: maximum values (for renormalization) - """ - min_val = np.min(np.min(data, axis=0), axis=0) - data = data - min_val - - max_val = np.max(np.max(data, axis=0), axis=0) - norm_data = data / (max_val + 1e-7) - - return norm_data, min_val, max_val - - -# Unpack parameters -def unpack_parameters(parameters): - # hidden_dim, static_dim, num_layers, iterations, batch_size, module_name, - # z_dim, static_z_dim, gamma -- static_dim doubles as static_z_dim, same - # way dim doubles as z_dim below: the static noise fed to the generator - # has the same width as the real static features it's standing in for. - return ( - parameters["hidden_dim"], - parameters["static_dim"], - parameters["num_layer"], - parameters["iterations"], - parameters["batch_size"], - parameters["module"], - parameters["dim"], - parameters["static_dim"], - 1, - ) - - -# Components of the network -def embedder(X, T, S, param): - """Embedding network between original feature space to latent space. - - Args: - - X: input time-series features - - T: input time information - - S: static features - - Returns: - - HT: temporal embeddings - - HS: static embeddings - """ - ( - hidden_dim, - static_dim, - num_layers, - iterations, - batch_size, - module_name, - z_dim, - static_z_dim, - gamma, - ) = unpack_parameters(param) - dim = z_dim - with tf.compat.v1.variable_scope("embedder", reuse=tf.compat.v1.AUTO_REUSE): - e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, X, dtype=tf.float32, sequence_length=T) - HT = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) - - # Static features, embedded independently of the temporal branch - # for now (not concatenated into HT): merging a per-timestep tensor - # (e_outputs) with a per-event one (HS) needs a broadcast/tiling - # scheme this file never designed -- the original attempt here - # called tf.concat(e_outputs, HS), which isn't valid usage (missing - # an axis, and shapes don't line up regardless). HT is exactly the - # non-static computation, so this can't regress the temporal path; - # HS is real and valid but not yet consumed by any loss term -- see - # train_timegan. - HS = _layers.stack( - S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid - ) - return HT, HS - - -def recovery(H, T, S, param): - """Recovery network from latent space to original space. - - Args: - - H: latent representation - - T: input time information - - S: latent representation of static features - - Returns: - - XT_tilde: recovered temporal data - - XS_tilde: recovered static data - """ - ( - hidden_dim, - static_dim, - num_layers, - iterations, - batch_size, - module_name, - z_dim, - static_z_dim, - gamma, - ) = unpack_parameters(param) - dim = z_dim - with tf.compat.v1.variable_scope("recovery", reuse=tf.compat.v1.AUTO_REUSE): - r_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - r_outputs, r_last_states = tf.compat.v1.nn.dynamic_rnn(r_cell, H, dtype=tf.float32, sequence_length=T) - XT_tilde = _layers.fully_connected(r_outputs, dim, activation_fn=tf.nn.sigmoid) - - # Static features - rs_outputs = _layers.stack( - S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid - ) - XS_tilde = _layers.fully_connected(rs_outputs, static_z_dim, activation_fn=tf.nn.sigmoid) - return XT_tilde, XS_tilde - - -def generator(Z, T, S, param): - """Generator function: Generate time-series data in latent space. - - Args: - - Z: random temporal variables - - T: input time information - - S: random variables (static features) - - Returns: - - ET: generated temporal embedding - - ES: generated static embedding - """ - ( - hidden_dim, - static_dim, - num_layers, - iterations, - batch_size, - module_name, - z_dim, - static_z_dim, - gamma, - ) = unpack_parameters(param) - dim = z_dim - with tf.compat.v1.variable_scope("generator", reuse=tf.compat.v1.AUTO_REUSE): - e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, Z, dtype=tf.float32, sequence_length=T) - ET = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) - - # Static features - ES = _layers.stack( - S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid - ) - return ET, ES - - -# I don't think this needs the static features... -def supervisor(H, T, param): - """Generate next sequence using the previous sequence. - - Args: - - H: latent representation - - T: input time information - - Returns: - - S: generated sequence based on the latent representations generated by the generator - """ - ( - hidden_dim, - static_dim, - num_layers, - iterations, - batch_size, - module_name, - z_dim, - static_z_dim, - gamma, - ) = unpack_parameters(param) - dim = z_dim - with tf.compat.v1.variable_scope("supervisor", reuse=tf.compat.v1.AUTO_REUSE): - e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell( - [rnn_cell(module_name, hidden_dim) for _ in range(num_layers - 1)] - ) - e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, H, dtype=tf.float32, sequence_length=T) - S = _layers.fully_connected(e_outputs, hidden_dim, activation_fn=tf.nn.sigmoid) - return S - - -def discriminator(H, T, S, param): - """Discriminate the original and synthetic time-series data. - - Args: - - H: latent representation of temporal data - - T: input time information - - S: latent representation of static data - - Returns: - - YT_hat: classification results between original and synthetic time-series - - YS_hat: classification results between original and synthetic static features - """ - ( - hidden_dim, - static_dim, - num_layers, - iterations, - batch_size, - module_name, - z_dim, - static_z_dim, - gamma, - ) = unpack_parameters(param) - dim = z_dim - with tf.compat.v1.variable_scope("discriminator", reuse=tf.compat.v1.AUTO_REUSE): - # Temporal discriminator -- reverted to the simple, single- - # dynamic_rnn form this file already had preserved as a comment, - # rather than the bidirectional/concat-with-S attempt that replaced - # it: tf.concat(H, S) isn't valid (missing an axis, and mixing a - # per-timestep tensor with a per-event one needs a broadcast/tiling - # scheme this file never designed), and biderectional_dynamic_rnn - # doesn't exist (typo for bidirectional_dynamic_rnn) regardless. - # YT_hat is unaffected by S for now, same reasoning as embedder(). - d_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) - d_outputs, d_last_states = tf.compat.v1.nn.dynamic_rnn(d_cell, H, dtype=tf.float32, sequence_length=T) - YT_hat = _layers.fully_connected(d_outputs, 1, activation_fn=None) - - # Static discriminator -- real and valid, but (like HS/ES/XS_tilde) - # not yet wired into any loss term; also fixes a bare, undefined - # `sigmoid` name (was never tf.nn.sigmoid or tf.sigmoid). - ds_outputs = _layers.stack( - S, _layers.fully_connected, [static_dim for _ in range(num_layers)], activation_fn=tf.nn.sigmoid - ) - YS_hat = _layers.fully_connected(ds_outputs, 1, activation_fn=tf.nn.sigmoid) - - return YT_hat, YS_hat - - -def _batch_generator_with_static(data, time, static, batch_size): - """Same draw as timegan.utils.batch_generator, but also slices static - features by the identical indices so S_mb stays aligned with X_mb/T_mb. - Kept local to this file (not added to timegan.utils) since the non- - static train_timegan/train_timegan_timed never need a static slice. - """ - no = len(data) - idx = np.random.permutation(no) - train_idx = idx[:batch_size] - X_mb = list(data[i] for i in train_idx) - T_mb = list(time[i] for i in train_idx) - S_mb = list(static[i] for i in train_idx) - return X_mb, T_mb, S_mb - - -def train_timegan(ori_data, ori_data_static, parameters, filename="timegan_save", version=0): - """TimeGAN training function. - - Use original data as training set to generater synthetic data (time-series) - Trains timegan from scratch - - Args: - - ori_data: original time-series data - - ori_data_static: per-event static features, shape (no, static_dim). - Embedded/discriminated for real (HS/ES/XS_tilde/XS_hat/YS_* below - are genuine graph nodes, not stubs), but not yet wired into any - loss term -- training only optimizes the temporal reconstruction/ - adversarial objective, same as the non-static implementation. - Finishing that (deciding how static loss terms should combine - with the temporal ones) is separate follow-up work; this just - makes the function callable and correct for what it does do. - - parameters: TimeGAN network parameters - - filename: filename to save the model in, default "timegan_save" - - version: version of the snapshot, default 0 - - Returns: - - generated_data: generated time-series data (temporal only, same - shape/meaning as the non-static implementation's output) - """ - # Initialization on the Graph - tf.compat.v1.reset_default_graph() - - # Basic Parameters - no, seq_len, dim = np.asarray(ori_data).shape - static_dim = np.asarray(ori_data_static).shape[-1] - - # Maximum sequence length and each sequence length - ori_time, max_seq_len = extract_time(ori_data) - - # Normalization - ori_data, min_val, max_val = MinMaxScaler(ori_data) - - ## Build a RNN networks - - # Network Parameters - hidden_dim = parameters["hidden_dim"] - num_layers = parameters["num_layer"] - iterations = parameters["iterations"] - batch_size = parameters["batch_size"] - module_name = parameters["module"] - parameters["dim"] = dim - parameters["static_dim"] = static_dim - z_dim = dim - gamma = 1 - - # Input place holders - X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") - Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") - T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") - S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") - - # Embedder & Recovery - HT, HS = embedder(X, T, S, parameters) - XT_tilde, XS_tilde = recovery(HT, T, HS, parameters) - - # Generator - ET, ES = generator(Z, T, S_z, parameters) - H_hat = supervisor(ET, T, parameters) - H_hat_supervise = supervisor(HT, T, parameters) - - # Synthetic data - X_hat, XS_hat = recovery(H_hat, T, ES, parameters) - - # Discriminator - Y_fake, YS_fake = discriminator(H_hat, T, ES, parameters) - Y_real, YS_real = discriminator(HT, T, HS, parameters) - Y_fake_e, YS_fake_e = discriminator(ET, T, ES, parameters) - - # Variables - e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] - r_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("recovery")] - g_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("generator")] - s_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("supervisor")] - d_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("discriminator")] - - # Discriminator loss - D_loss_real = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_real), Y_real) - D_loss_fake = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake), Y_fake) - D_loss_fake_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake_e), Y_fake_e) - D_loss = D_loss_real + D_loss_fake + gamma * D_loss_fake_e - - # Generator loss - # 1. Adversarial loss - G_loss_U = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake), Y_fake) - G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) - - # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(HT[:, 1:, :], H_hat_supervise[:, :-1, :]) - - # 3. Two Momments - G_loss_V1 = tf.reduce_mean( - input_tensor=tf.abs( - tf.sqrt(tf.nn.moments(x=X_hat, axes=[0])[1] + 1e-6) - tf.sqrt(tf.nn.moments(x=X, axes=[0])[1] + 1e-6) - ) - ) - G_loss_V2 = tf.reduce_mean( - input_tensor=tf.abs((tf.nn.moments(x=X_hat, axes=[0])[0]) - (tf.nn.moments(x=X, axes=[0])[0])) - ) - - G_loss_V = G_loss_V1 + G_loss_V2 - - # 4. Summation - G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V - - # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, XT_tilde) - E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) - E_loss = E_loss0 + 0.1 * G_loss_S - - # optimizer - E0_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss0, var_list=e_vars + r_vars) - E_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss, var_list=e_vars + r_vars) - D_solver = tf.compat.v1.train.AdamOptimizer().minimize(D_loss, var_list=d_vars) - G_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss, var_list=g_vars + s_vars) - GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) - - ## TimeGAN training - sess = tf.compat.v1.Session() - sess.run(tf.compat.v1.global_variables_initializer()) - - # Create Saver - saver = tf.compat.v1.train.Saver() - - # 1. Embedding network training - print("Start Embedding Network Training") - - for itt in range(iterations): - # Set mini-batch - X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) - # Train embedder - _, step_e_loss = sess.run([E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb, S: S_mb}) - # Checkpoint - if itt % 1000 == 0: - print( - "step: " - + str(itt) - + "/" - + str(iterations) - + ", e_loss: " - + str(np.round(np.sqrt(step_e_loss + 1e-6), 4)) - ) - - print("Finish Embedding Network Training") - - # 2. Training only with supervised loss - print("Start Training with Supervised Loss Only") - - for itt in range(iterations): - # Set mini-batch - X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) - # Random vector generation - Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) - Sz_mb = np.random.uniform(0.0, 1, [batch_size, static_dim]) - # Train generator - _, step_g_loss_s = sess.run( - [GS_solver, G_loss_S], feed_dict={Z: Z_mb, X: X_mb, T: T_mb, S: S_mb, S_z: Sz_mb} - ) - # Checkpoint - if itt % 1000 == 0: - print( - "step: " - + str(itt) - + "/" - + str(iterations) - + ", s_loss: " - + str(np.round(np.sqrt(step_g_loss_s + 1e-6), 4)) - ) - - print("Finish Training with Supervised Loss Only") - - # 3. Joint Training - print("Start Joint Training") - - for itt in range(iterations): - # Generator training (twice more than discriminator training) - for kk in range(2): - # Set mini-batch - X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) - # Random vector generation - Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) - Sz_mb = np.random.uniform(0.0, 1, [batch_size, static_dim]) - # Train generator - _, step_g_loss_u, step_g_loss_s, step_g_loss_v = sess.run( - [G_solver, G_loss_U, G_loss_S, G_loss_V], - feed_dict={Z: Z_mb, X: X_mb, T: T_mb, S: S_mb, S_z: Sz_mb}, - ) - # Train embedder - _, step_e_loss_t0 = sess.run( - [E_solver, E_loss_T0], feed_dict={Z: Z_mb, X: X_mb, T: T_mb, S: S_mb, S_z: Sz_mb} - ) - - # Discriminator training - # Set mini-batch - X_mb, T_mb, S_mb = _batch_generator_with_static(ori_data, ori_time, ori_data_static, batch_size) - # Random vector generation - Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) - Sz_mb = np.random.uniform(0.0, 1, [batch_size, static_dim]) - # Check discriminator loss before updating - check_d_loss = sess.run(D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb, S: S_mb, S_z: Sz_mb}) - # Train discriminator (only when the discriminator does not work well) - if check_d_loss > 0.15: - _, step_d_loss = sess.run( - [D_solver, D_loss], feed_dict={X: X_mb, T: T_mb, Z: Z_mb, S: S_mb, S_z: Sz_mb} - ) - - # Print multiple checkpoints - if itt % 1000 == 0: - print( - "step: " - + str(itt) - + "/" - + str(iterations) - + ", d_loss: " - + str(np.round(step_d_loss, 4)) - + ", g_loss_u: " - + str(np.round(step_g_loss_u, 4)) - + ", g_loss_s: " - + str(np.round(np.sqrt(step_g_loss_s + 1e-6), 4)) - + ", g_loss_v: " - + str(np.round(step_g_loss_v, 4)) - + ", e_loss_t0: " - + str(np.round(np.sqrt(step_e_loss_t0 + 1e-6), 4)) - ) - print("Finish Joint Training") - - ## Synthetic data generation - Z_mb = random_generator(no, z_dim, ori_time, max_seq_len) - Sz_all = np.random.uniform(0.0, 1, [no, static_dim]) - generated_data_curr = sess.run( - X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time, S: ori_data_static, S_z: Sz_all} - ) - - generated_data = list() - - for i in range(no): - temp = generated_data_curr[i, : ori_time[i], :] - generated_data.append(temp) - - # Renormalization - generated_data = generated_data * max_val - generated_data = generated_data + min_val - - # Save - saver.save(sess, filename, global_step=version) - - return generated_data - - -def load_timegan(ori_data, ori_data_static, parameters, filename): - """TimeGAN function. - - Use original data as training set to generater synthetic data (time-series) - Loads from snapshot - - Args: - - ori_data: original time-series data - - ori_data_static: per-event static features, shape (no, static_dim) -- - see train_timegan's docstring for what this does and doesn't do yet - - parameters: TimeGAN network parameters - - filename: filename of the snapshot to load - - Returns: - - generated_data: generated time-series data - """ - # Initialization on the Graph - tf.compat.v1.reset_default_graph() - - # Basic Parameters - no, seq_len, dim = np.asarray(ori_data).shape - static_dim = np.asarray(ori_data_static).shape[-1] - - # Maximum sequence length and each sequence length - ori_time, max_seq_len = extract_time(ori_data) - - # Normalization - ori_data, min_val, max_val = MinMaxScaler(ori_data) - - ## Build a RNN networks - - # Network Parameters - hidden_dim = parameters["hidden_dim"] - num_layers = parameters["num_layer"] - iterations = parameters["iterations"] - batch_size = parameters["batch_size"] - module_name = parameters["module"] - parameters["dim"] = dim - parameters["static_dim"] = static_dim - z_dim = dim - gamma = 1 - - # Input place holders - X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") - Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") - T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") - S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") - - # Embedder & Recovery - HT, HS = embedder(X, T, S, parameters) - XT_tilde, XS_tilde = recovery(HT, T, HS, parameters) - - # Generator - ET, ES = generator(Z, T, S_z, parameters) - H_hat = supervisor(ET, T, parameters) - H_hat_supervise = supervisor(HT, T, parameters) - - # Synthetic data - X_hat, XS_hat = recovery(H_hat, T, ES, parameters) - - # Discriminator - Y_fake, YS_fake = discriminator(H_hat, T, ES, parameters) - Y_real, YS_real = discriminator(HT, T, HS, parameters) - Y_fake_e, YS_fake_e = discriminator(ET, T, ES, parameters) - - # Variables - e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] - r_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("recovery")] - g_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("generator")] - s_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("supervisor")] - d_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("discriminator")] - - # Discriminator loss - D_loss_real = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_real), Y_real) - D_loss_fake = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake), Y_fake) - D_loss_fake_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake_e), Y_fake_e) - D_loss = D_loss_real + D_loss_fake + gamma * D_loss_fake_e - - # Generator loss - # 1. Adversarial loss - G_loss_U = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake), Y_fake) - G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) - - # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(HT[:, 1:, :], H_hat_supervise[:, :-1, :]) - - # 3. Two Momments - G_loss_V1 = tf.reduce_mean( - input_tensor=tf.abs( - tf.sqrt(tf.nn.moments(x=X_hat, axes=[0])[1] + 1e-6) - tf.sqrt(tf.nn.moments(x=X, axes=[0])[1] + 1e-6) - ) - ) - G_loss_V2 = tf.reduce_mean( - input_tensor=tf.abs((tf.nn.moments(x=X_hat, axes=[0])[0]) - (tf.nn.moments(x=X, axes=[0])[0])) - ) - - G_loss_V = G_loss_V1 + G_loss_V2 - - # 4. Summation - G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V - - # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, XT_tilde) - E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) - E_loss = E_loss0 + 0.1 * G_loss_S - - # optimizer - E0_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss0, var_list=e_vars + r_vars) - E_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss, var_list=e_vars + r_vars) - D_solver = tf.compat.v1.train.AdamOptimizer().minimize(D_loss, var_list=d_vars) - G_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss, var_list=g_vars + s_vars) - GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) - - # Load snapshot - sess = tf.compat.v1.Session() - saver = tf.compat.v1.train.Saver() - saver.restore(sess, filename) - - # Synthetic data generation - Z_mb = random_generator(no, z_dim, ori_time, max_seq_len) - Sz_all = np.random.uniform(0.0, 1, [no, static_dim]) - generated_data_curr = sess.run( - X_hat, feed_dict={Z: Z_mb, X: ori_data, T: ori_time, S: ori_data_static, S_z: Sz_all} - ) - - generated_data = list() - - for i in range(no): - temp = generated_data_curr[i, : ori_time[i], :] - generated_data.append(temp) - - # Renormalization - generated_data = generated_data * max_val - generated_data = generated_data + min_val - - return generated_data diff --git a/timegan/utils.py b/timegan/utils.py index f5a7e31..758b798 100644 --- a/timegan/utils.py +++ b/timegan/utils.py @@ -125,17 +125,21 @@ def random_generator(batch_size, z_dim, T_mb, max_seq_len): return Z_mb -def batch_generator(data, time, batch_size): +def batch_generator(data, time, batch_size, static=None): """Mini-batch generator. Args: - data: time-series data - time: time information - batch_size: the number of samples in each batch + - static: optional per-event static features, sliced by the same + batch indices as data/time so a caller can keep them aligned. + Backward compatible: omitting it returns the original 2-tuple. Returns: - X_mb: time-series data in each batch - T_mb: time information in each batch + - S_mb: static features in each batch (only if `static` was given) """ no = len(data) idx = np.random.permutation(no) @@ -143,5 +147,7 @@ def batch_generator(data, time, batch_size): X_mb = list(data[i] for i in train_idx) T_mb = list(time[i] for i in train_idx) - - return X_mb, T_mb + if static is None: + return X_mb, T_mb + S_mb = list(static[i] for i in train_idx) + return X_mb, T_mb, S_mb From 346b37de4a9359fb7bb91c820a00ff83569819f4 Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Tue, 15 Sep 2026 10:01:14 -0600 Subject: [PATCH 05/10] Fix stale/broken references found in a full-library consistency review Real bugs, would fail if actually exercised: - main_timegan.py: called `timegan(ori_data, parameters)`, treating the imported module as a callable instead of `timegan.train_timegan(...)` -- TypeError on the first real line of main(). - pyproject.toml: removed the `[project.scripts] test = "tests.tutorial: start"` entry point -- tests/tutorial.py has no `start` function (no functions at all, just top-level script code), so this pip-installed `test` command was never callable. Real tests already run via `make test`/pytest; this was a misleadingly-named, non-functional duplicate, not worth resurrecting. - test.sh: `python3 tutorial.py` -- wrong relative path, the file is at tests/tutorial.py. Fixed the path instead of removing the script. - Dockerfile: pinned python:3.9-slim-buster, contradicting pyproject. toml's `requires-python = ">=3.10,<3.11"` and CONTRIBUTING.md's explicit "3.10 only" -- likely fails at `poetry install` or produces an unsupported environment. Also its CMD pointed at timegan/foo.py, deleted by an earlier commit on this same branch -- pointed it at main_timegan.py (the package's actual demo entry point) instead. Stale documentation: - docs/index.md carried badges/links for zkromerUCD/TimeGAN, a different (predecessor?) repo -- README.md at the repo root already had this right (det-lab/TimeGAN-Static); docs/index.md just never got updated to match. - env.def's help text said "version 0.1.8"; pyproject.toml is at 0.1.13. - embedder()'s docstring pointed at "timegan_static.py's git history" for context on why static features aren't concatenated into the temporal branch -- that file no longer exists after the previous commit. Reworded to explain the reasoning inline instead of pointing at a deleted file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DbnvNbSohNTTvL8MDx5RUU --- Dockerfile | 4 ++-- docs/index.md | 10 +++++----- env.def | 2 +- pyproject.toml | 3 --- test.sh | 4 ++-- timegan/main_timegan.py | 2 +- timegan/timegan.py | 5 ++--- 7 files changed, 13 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 18dfb9e..b0ce60f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM python:3.9-slim-buster +FROM python:3.10-slim ENV POETRY_VERSION=1.4 \ POETRY_VIRTUALENVS_CREATE=false @@ -18,4 +18,4 @@ RUN poetry install --no-interaction --no-ansi --no-root --no-dev # Copy Python code to the Docker image COPY timegan /code/timegan/ -CMD [ "python", "timegan/foo.py"] +CMD [ "python", "timegan/main_timegan.py"] diff --git a/docs/index.md b/docs/index.md index e30a8e2..7c9af53 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,8 @@ -# TimeGAN +# TimeGAN-Static -[![Release](https://img.shields.io/github/v/release/zkromerUCD/TimeGAN)](https://img.shields.io/github/v/release/zkromerUCD/TimeGAN) -[![Build status](https://img.shields.io/github/actions/workflow/status/zkromerUCD/TimeGAN/main.yml?branch=main)](https://github.com/zkromerUCD/TimeGAN/actions/workflows/main.yml?query=branch%3Amain) -[![Commit activity](https://img.shields.io/github/commit-activity/m/zkromerUCD/TimeGAN)](https://img.shields.io/github/commit-activity/m/zkromerUCD/TimeGAN) -[![License](https://img.shields.io/github/license/zkromerUCD/TimeGAN)](https://img.shields.io/github/license/zkromerUCD/TimeGAN) +[![Release](https://img.shields.io/github/v/release/det-lab/TimeGAN-Static)](https://img.shields.io/github/v/release/det-lab/TimeGAN-Static) +[![Build status](https://img.shields.io/github/actions/workflow/status/det-lab/TimeGAN-Static/main.yml?branch=main)](https://github.com/det-lab/TimeGAN-Static/actions/workflows/main.yml?query=branch%3Amain) +[![Commit activity](https://img.shields.io/github/commit-activity/m/det-lab/TimeGAN-Static)](https://img.shields.io/github/commit-activity/m/det-lab/TimeGAN-Static) +[![License](https://img.shields.io/github/license/det-lab/timegan-static)](https://img.shields.io/github/license/det-lab/timegan-static) A fork of https://github.com/jsyoon0823/TimeGAN that implements static features and snapshotting diff --git a/env.def b/env.def index ed69f6e..73ada2e 100644 --- a/env.def +++ b/env.def @@ -7,7 +7,7 @@ From: ubuntu:22.04 %help CDMS Analysis base/development image with ROOT installed at /usr/local - Includes timegan timed training feature version 0.1.8 + Includes timegan timed training feature version 0.1.13 %post export DEBIAN_FRONTEND=noninteractive diff --git a/pyproject.toml b/pyproject.toml index f596557..e81d531 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,6 @@ dependencies = [ Repository = "https://github.com/det-lab/TimeGAN-Static" Documentation = "https://det-lab.github.io/TimeGAN-Static/" -[project.scripts] -test = "tests.tutorial:start" - [tool.poetry.group.dev.dependencies] pytest = "^7.2.0" pytest-cov = "^4.0.0" diff --git a/test.sh b/test.sh index 15b36f5..3fad28e 100644 --- a/test.sh +++ b/test.sh @@ -1,2 +1,2 @@ -#!/bin/sh -python3 tutorial.py +#!/bin/sh +python3 tests/tutorial.py diff --git a/timegan/main_timegan.py b/timegan/main_timegan.py index d7667f0..8304b4b 100644 --- a/timegan/main_timegan.py +++ b/timegan/main_timegan.py @@ -80,7 +80,7 @@ def main(args): parameters["iterations"] = args.iteration parameters["batch_size"] = args.batch_size - generated_data = timegan(ori_data, parameters) + generated_data = timegan.train_timegan(ori_data, parameters) print("Finish Synthetic Data Generation") ## Performance metrics diff --git a/timegan/timegan.py b/timegan/timegan.py index 9584f6b..91f7622 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -76,9 +76,8 @@ def embedder(X, T, param, S=None): - T: input time information - S: optional per-event static features, shape (batch, static_dim). When given, also embeds them (independently of the temporal - branch -- see timegan_static.py's git history, folded into this - file, for why: merging a per-timestep tensor with a per-event - one needs a broadcast/tiling scheme nothing here has designed). + branch: merging a per-timestep tensor with a per-event one + needs a broadcast/tiling scheme nothing here has designed). param["static_dim"] must be set when S is given. Returns: From 70fd4fdd124c2158eec86ca1487e70d714684f54 Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Tue, 15 Sep 2026 10:06:37 -0600 Subject: [PATCH 06/10] Extract _build_timegan_graph: last duplicated block between the two training entry points load_timegan duplicated all of train_timegan_timed's graph-building code (placeholders through the loss/optimizer ops) -- the last remaining big duplication after this branch's earlier consolidation work. It needs those ops built even though it never trains, purely so its Saver's variable list matches what was checkpointed (AdamOptimizer creates slot variables that get saved alongside the trainable ones). That duplication had already drifted: load_timegan's copy hardcoded gamma=1 and the paper's fixed 100x loss weights, while train_timegan_ timed's copy had since gained the configurable gamma/g_loss_s_weight/ g_loss_v_weight. Harmless in practice (load_timegan never executes those loss ops -- only X_hat, for generation), but a real, easy-to- miss inconsistency sitting right next to the configurable version, and exactly the kind of drift this whole branch has been about removing. Extracted the shared logic into _build_timegan_graph(parameters, max_seq_len, dim, static_dim), returning a dict of every tensor/op either caller needs. train_timegan_timed and load_timegan both call it and unpack just what they use; the two-branch (static features or not) reasoning that used to be duplicated in both call sites now lives in exactly one place. Verified via the full test suite (all 5 tests, including the static- feature load/restore round trip -- the riskiest path, since it depends on the checkpoint's graph shape matching exactly) and report_training_comparison.py against both entry points. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DbnvNbSohNTTvL8MDx5RUU --- timegan/timegan.py | 363 ++++++++++++++++++++------------------------- 1 file changed, 161 insertions(+), 202 deletions(-) diff --git a/timegan/timegan.py b/timegan/timegan.py index 91f7622..f7c7858 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -271,6 +271,146 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0, ori_ return info +def _build_timegan_graph(parameters, max_seq_len, dim, static_dim=None): + """Builds the full TimeGAN graph -- placeholders through the loss and + optimizer ops -- shared by train_timegan_timed and load_timegan. + + load_timegan needs the loss/optimizer ops too, even though it never + trains: purely so its Saver's variable list matches what was + checkpointed (AdamOptimizer creates slot variables that get saved + alongside the trainable ones -- a Saver built from a graph missing + them can't restore a checkpoint that has them). Previously this whole + function's worth of code was duplicated between the two callers, + including the loss weights -- load_timegan's copy had drifted to + hardcoded gamma=1/100x weights while train_timegan_timed's own copy + had since gained the configurable gamma/g_loss_s_weight/g_loss_v_weight + (harmless in practice, since load_timegan never executes those loss + ops, but a real, easy-to-miss inconsistency sitting right next to the + configurable version). + + Args: + - parameters: network parameters. gamma/g_loss_s_weight/ + g_loss_v_weight defaults are applied here via setdefault, so + they land back in the caller's own dict the same way + parameters["dim"] does elsewhere. + - max_seq_len, dim: from extract_time(ori_data)/ori_data.shape + - static_dim: static-feature width, or None for no static support + + Returns a dict with everything a caller might need to feed/run: + X, Z, T, S, S_z (placeholders; S/S_z are None if static_dim is + None), X_hat, E0_solver, E_solver, D_solver, G_solver, GS_solver, + E_loss_T0, G_loss_S, G_loss_U, G_loss_V, D_loss. + """ + hidden_dim = parameters["hidden_dim"] + num_layers = parameters["num_layer"] + module_name = parameters["module"] + z_dim = dim + parameters.setdefault("gamma", 1) + parameters.setdefault("g_loss_s_weight", 100.0) + parameters.setdefault("g_loss_v_weight", 100.0) + gamma = parameters["gamma"] + + # Input place holders + X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") + Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") + T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") + + # Embedder, Recovery, Generator, Discriminator -- two branches (rather + # than always passing S=None and letting each function's own S-is-None + # check no-op) because embedder/recovery/generator/discriminator return + # a plain value when S is None and a tuple when it's given: forcing the + # same code to handle both would mean every line here unpacking a tuple + # or not depending on a value only known at runtime. + S = S_z = None + if static_dim is not None: + S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") + S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") + + H, HS = embedder(X, T, parameters, S=S) + X_tilde, XS_tilde = recovery(H, T, parameters, S=HS) + + E_hat, ES = generator(Z, T, parameters, S=S_z) + H_hat = supervisor(E_hat, T, parameters) + H_hat_supervise = supervisor(H, T, parameters) + + X_hat, XS_hat = recovery(H_hat, T, parameters, S=ES) + + Y_fake, YS_fake = discriminator(H_hat, T, parameters, S=ES) + Y_real, YS_real = discriminator(H, T, parameters, S=HS) + Y_fake_e, YS_fake_e = discriminator(E_hat, T, parameters, S=ES) + else: + H = embedder(X, T, parameters) + X_tilde = recovery(H, T, parameters) + + E_hat = generator(Z, T, parameters) + H_hat = supervisor(E_hat, T, parameters) + H_hat_supervise = supervisor(H, T, parameters) + + X_hat = recovery(H_hat, T, parameters) + + Y_fake = discriminator(H_hat, T, parameters) + Y_real = discriminator(H, T, parameters) + Y_fake_e = discriminator(E_hat, T, parameters) + + # Variables + e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] + r_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("recovery")] + g_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("generator")] + s_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("supervisor")] + d_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("discriminator")] + + # Discriminator loss + D_loss_real = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_real), Y_real) + D_loss_fake = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake), Y_fake) + D_loss_fake_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake_e), Y_fake_e) + D_loss = D_loss_real + D_loss_fake + gamma * D_loss_fake_e + + # Generator loss + # 1. Adversarial loss + G_loss_U = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake), Y_fake) + G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) + + # 2. Supervised loss + G_loss_S = tf.compat.v1.losses.mean_squared_error(H[:, 1:, :], H_hat_supervise[:, :-1, :]) + + # 3. Two Momments + G_loss_V1 = tf.reduce_mean( + input_tensor=tf.abs( + tf.sqrt(tf.nn.moments(x=X_hat, axes=[0])[1] + 1e-6) - tf.sqrt(tf.nn.moments(x=X, axes=[0])[1] + 1e-6) + ) + ) + G_loss_V2 = tf.reduce_mean( + input_tensor=tf.abs((tf.nn.moments(x=X_hat, axes=[0])[0]) - (tf.nn.moments(x=X, axes=[0])[0])) + ) + + G_loss_V = G_loss_V1 + G_loss_V2 + + # 4. Summation + G_loss = (G_loss_U + gamma * G_loss_U_e + + parameters["g_loss_s_weight"] * tf.sqrt(G_loss_S + 1e-6) + + parameters["g_loss_v_weight"] * G_loss_V) + + # Embedder network loss + E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) + E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) + E_loss = E_loss0 + 0.1 * G_loss_S + + # optimizer + E0_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss0, var_list=e_vars + r_vars) + E_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss, var_list=e_vars + r_vars) + D_solver = tf.compat.v1.train.AdamOptimizer().minimize(D_loss, var_list=d_vars) + G_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss, var_list=g_vars + s_vars) + GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) + + return dict( + X=X, Z=Z, T=T, S=S, S_z=S_z, X_hat=X_hat, + E0_solver=E0_solver, E_solver=E_solver, D_solver=D_solver, + G_solver=G_solver, GS_solver=GS_solver, + E_loss_T0=E_loss_T0, G_loss_S=G_loss_S, G_loss_U=G_loss_U, + G_loss_V=G_loss_V, D_loss=D_loss, + ) + + def train_timegan_timed( ori_data, parameters, in_filename, out_filename=None, seconds=3600, phase=1, current_iter=0, new=False, version=0, num_generate=None, on_training_complete=None, ori_data_static=None, @@ -398,67 +538,22 @@ def log_history(record): ## Build a RNN networks - # Network Parameters - hidden_dim = parameters["hidden_dim"] - num_layers = parameters["num_layer"] + # Network Parameters -- just what this function's own training loop + # needs directly; _build_timegan_graph reads hidden_dim/num_layer/ + # module/gamma/etc. itself from the same parameters dict. iterations = parameters["iterations"] batch_size = parameters["batch_size"] - module_name = parameters["module"] parameters["dim"] = dim parameters["static_dim"] = static_dim - z_dim = dim - # setdefault (not a plain local default) so the resolved value -- whether - # the caller supplied one or this default -- lands back in the caller's - # own parameters dict, the same way `parameters["dim"] = dim` above does. - # Callers that log/persist their parameters dict (e.g. into a run's - # metadata) get these recorded automatically, with no extra plumbing. - parameters.setdefault("gamma", 1) - parameters.setdefault("g_loss_s_weight", 100.0) - parameters.setdefault("g_loss_v_weight", 100.0) - gamma = parameters["gamma"] - - # Input place holders - X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") - Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") - T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - - # Embedder, Recovery, Generator, Discriminator -- two branches (rather - # than always passing S=None and letting each function's own S-is-None - # check no-op) because embedder/recovery/generator/discriminator return - # a plain value when S is None and a tuple when it's given: forcing the - # same code to handle both would mean every line here unpacking a tuple - # or not depending on a value only known at runtime. This is the one - # place that fork needs to happen, now that it isn't duplicated across - # two entire files. - if ori_data_static is not None: - S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") - S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") - - H, HS = embedder(X, T, parameters, S=S) - X_tilde, XS_tilde = recovery(H, T, parameters, S=HS) - - E_hat, ES = generator(Z, T, parameters, S=S_z) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) - - X_hat, XS_hat = recovery(H_hat, T, parameters, S=ES) - - Y_fake, YS_fake = discriminator(H_hat, T, parameters, S=ES) - Y_real, YS_real = discriminator(H, T, parameters, S=HS) - Y_fake_e, YS_fake_e = discriminator(E_hat, T, parameters, S=ES) - else: - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) - - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) - - X_hat = recovery(H_hat, T, parameters) + z_dim = dim # still used below for random_generator() calls - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) + graph = _build_timegan_graph(parameters, max_seq_len, dim, static_dim) + X, Z, T, S, S_z = graph["X"], graph["Z"], graph["T"], graph["S"], graph["S_z"] + X_hat = graph["X_hat"] + E0_solver, E_solver = graph["E0_solver"], graph["E_solver"] + D_solver, G_solver, GS_solver = graph["D_solver"], graph["G_solver"], graph["GS_solver"] + E_loss_T0, G_loss_S = graph["E_loss_T0"], graph["G_loss_S"] + G_loss_U, G_loss_V, D_loss = graph["G_loss_U"], graph["G_loss_V"], graph["D_loss"] def _draw_batch(): """batch_generator(), plus a static-feature slice (aligned to the @@ -491,56 +586,6 @@ def _static_extras(s_mb=None, sz_mb=None): extras[S_z] = sz_mb return extras - # Variables - e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] - r_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("recovery")] - g_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("generator")] - s_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("supervisor")] - d_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("discriminator")] - - # Discriminator loss - D_loss_real = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_real), Y_real) - D_loss_fake = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake), Y_fake) - D_loss_fake_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake_e), Y_fake_e) - D_loss = D_loss_real + D_loss_fake + gamma * D_loss_fake_e - - # Generator loss - # 1. Adversarial loss - G_loss_U = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake), Y_fake) - G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) - - # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(H[:, 1:, :], H_hat_supervise[:, :-1, :]) - - # 3. Two Momments - G_loss_V1 = tf.reduce_mean( - input_tensor=tf.abs( - tf.sqrt(tf.nn.moments(x=X_hat, axes=[0])[1] + 1e-6) - tf.sqrt(tf.nn.moments(x=X, axes=[0])[1] + 1e-6) - ) - ) - G_loss_V2 = tf.reduce_mean( - input_tensor=tf.abs((tf.nn.moments(x=X_hat, axes=[0])[0]) - (tf.nn.moments(x=X, axes=[0])[0])) - ) - - G_loss_V = G_loss_V1 + G_loss_V2 - - # 4. Summation - G_loss = (G_loss_U + gamma * G_loss_U_e - + parameters["g_loss_s_weight"] * tf.sqrt(G_loss_S + 1e-6) - + parameters["g_loss_v_weight"] * G_loss_V) - - # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) - E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) - E_loss = E_loss0 + 0.1 * G_loss_S - - # optimizer - E0_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss0, var_list=e_vars + r_vars) - E_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss, var_list=e_vars + r_vars) - D_solver = tf.compat.v1.train.AdamOptimizer().minimize(D_loss, var_list=d_vars) - G_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss, var_list=g_vars + s_vars) - GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) - ## TimeGAN training sess = tf.compat.v1.Session() # Initialize if training from scratch @@ -809,104 +854,18 @@ def load_timegan(ori_data, parameters, filename, ori_data_static=None): ori_data, min_val, max_val = MinMaxScaler(ori_data) ## Build a RNN networks - - # Network Parameters - hidden_dim = parameters["hidden_dim"] - num_layers = parameters["num_layer"] - iterations = parameters["iterations"] - batch_size = parameters["batch_size"] - module_name = parameters["module"] parameters["dim"] = dim parameters["static_dim"] = static_dim - z_dim = dim - gamma = 1 - - # Input place holders - X = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, dim], name="myinput_x") - Z = tf.compat.v1.placeholder(tf.float32, [None, max_seq_len, z_dim], name="myinput_z") - T = tf.compat.v1.placeholder(tf.int32, [None], name="myinput_t") - - # Embedder, Recovery, Generator, Discriminator -- same two-branch - # reasoning as train_timegan_timed above: the checkpoint being restored - # must have been built with the exact same graph shape it was saved - # with, so this needs to match whichever branch actually trained it. - if ori_data_static is not None: - S = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_s") - S_z = tf.compat.v1.placeholder(tf.float32, [None, static_dim], name="myinput_sz") - - H, HS = embedder(X, T, parameters, S=S) - X_tilde, XS_tilde = recovery(H, T, parameters, S=HS) - - E_hat, ES = generator(Z, T, parameters, S=S_z) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) - - X_hat, XS_hat = recovery(H_hat, T, parameters, S=ES) - - Y_fake, YS_fake = discriminator(H_hat, T, parameters, S=ES) - Y_real, YS_real = discriminator(H, T, parameters, S=HS) - Y_fake_e, YS_fake_e = discriminator(E_hat, T, parameters, S=ES) - else: - H = embedder(X, T, parameters) - X_tilde = recovery(H, T, parameters) - - E_hat = generator(Z, T, parameters) - H_hat = supervisor(E_hat, T, parameters) - H_hat_supervise = supervisor(H, T, parameters) - - X_hat = recovery(H_hat, T, parameters) - - Y_fake = discriminator(H_hat, T, parameters) - Y_real = discriminator(H, T, parameters) - Y_fake_e = discriminator(E_hat, T, parameters) - - # Variables - e_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("embedder")] - r_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("recovery")] - g_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("generator")] - s_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("supervisor")] - d_vars = [v for v in tf.compat.v1.trainable_variables() if v.name.startswith("discriminator")] - - # Discriminator loss - D_loss_real = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_real), Y_real) - D_loss_fake = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake), Y_fake) - D_loss_fake_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.zeros_like(Y_fake_e), Y_fake_e) - D_loss = D_loss_real + D_loss_fake + gamma * D_loss_fake_e - - # Generator loss - # 1. Adversarial loss - G_loss_U = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake), Y_fake) - G_loss_U_e = tf.compat.v1.losses.sigmoid_cross_entropy(tf.ones_like(Y_fake_e), Y_fake_e) - - # 2. Supervised loss - G_loss_S = tf.compat.v1.losses.mean_squared_error(H[:, 1:, :], H_hat_supervise[:, :-1, :]) - - # 3. Two Momments - G_loss_V1 = tf.reduce_mean( - input_tensor=tf.abs( - tf.sqrt(tf.nn.moments(x=X_hat, axes=[0])[1] + 1e-6) - tf.sqrt(tf.nn.moments(x=X, axes=[0])[1] + 1e-6) - ) - ) - G_loss_V2 = tf.reduce_mean( - input_tensor=tf.abs((tf.nn.moments(x=X_hat, axes=[0])[0]) - (tf.nn.moments(x=X, axes=[0])[0])) - ) - - G_loss_V = G_loss_V1 + G_loss_V2 - - # 4. Summation - G_loss = G_loss_U + gamma * G_loss_U_e + 100 * tf.sqrt(G_loss_S + 1e-6) + 100 * G_loss_V - - # Embedder network loss - E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) - E_loss0 = 10 * tf.sqrt(E_loss_T0 + 1e-6) - E_loss = E_loss0 + 0.1 * G_loss_S - - # optimizer - E0_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss0, var_list=e_vars + r_vars) - E_solver = tf.compat.v1.train.AdamOptimizer().minimize(E_loss, var_list=e_vars + r_vars) - D_solver = tf.compat.v1.train.AdamOptimizer().minimize(D_loss, var_list=d_vars) - G_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss, var_list=g_vars + s_vars) - GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) + z_dim = dim # still used below for random_generator() + + # Same graph-building as train_timegan_timed (shared via + # _build_timegan_graph): the checkpoint being restored must have been + # built with the exact same graph shape it was saved with, so this + # needs to match whichever branch (static features or not) actually + # trained it -- pass the same ori_data_static that run used. + graph = _build_timegan_graph(parameters, max_seq_len, dim, static_dim) + X, Z, T, S, S_z = graph["X"], graph["Z"], graph["T"], graph["S"], graph["S_z"] + X_hat = graph["X_hat"] # Load snapshot sess = tf.compat.v1.Session() From ddc973994705e112fe3f0634244983b6936f66de Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Tue, 15 Sep 2026 10:09:41 -0600 Subject: [PATCH 07/10] Simplify unpack_parameters; move timegan/test.py out of the shipped package unpack_parameters returned iterations, batch_size, and a hardcoded gamma=1 that every one of its 5 callers (embedder/recovery/generator/ supervisor/discriminator) unpacked into a local variable and never used again -- dead bindings in every single call site. The gamma was worse than dead: it was a live lie for anyone who trusted it, since train_timegan_timed's own real, configurable gamma (read straight from parameters["gamma"] in _build_timegan_graph) is what's actually used during training and shadows this one entirely. Trimmed the return to just what's used: hidden_dim, num_layer, module, dim. Moved timegan/test.py to tests/full_demo.py: despite the name, it was never a pytest test (no test functions, just top-level script code that trains a real model and computes discriminative/predictive metrics) -- and it lived inside timegan/, the actual shipped package directory, so it would have been bundled into the installed wheel. It's also a near-duplicate of tests/tutorial.py (same demo, just at production iteration counts with metrics/visualization added) -- the same "two very similar functions" pattern this branch has been removing elsewhere, just never applied here. Named to not match pytest's test_*.py/*_test.py discovery patterns (same reason tutorial.py was already safe), so it won't get accidentally collected and executed as a real test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DbnvNbSohNTTvL8MDx5RUU --- timegan/test.py => tests/full_demo.py | 0 timegan/timegan.py | 28 +++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) rename timegan/test.py => tests/full_demo.py (100%) diff --git a/timegan/test.py b/tests/full_demo.py similarity index 100% rename from timegan/test.py rename to tests/full_demo.py diff --git a/timegan/timegan.py b/timegan/timegan.py index f7c7858..d3a0899 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -53,17 +53,20 @@ def MinMaxScaler(data): return norm_data, min_val, max_val -# Unpack parameters +# Unpack parameters -- just what embedder/recovery/generator/supervisor/ +# discriminator actually use below. Used to also return iterations, +# batch_size, and a hardcoded gamma=1 that every one of those callers +# unpacked into a local variable and never referenced again; worse, that +# hardcoded gamma was a live lie for anyone who might have trusted it -- +# train_timegan_timed's own gamma (configurable, read straight from +# parameters["gamma"], see _build_timegan_graph) is what's actually used +# during training and shadows this one entirely. def unpack_parameters(parameters): - # hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma return ( parameters["hidden_dim"], parameters["num_layer"], - parameters["iterations"], - parameters["batch_size"], parameters["module"], parameters["dim"], - 1, ) @@ -85,8 +88,7 @@ def embedder(X, T, param, S=None): static-feature support -- existing callers are unaffected) - (H, HS): embeddings and static embeddings, if S is given """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - dim = z_dim + hidden_dim, num_layers, module_name, dim = unpack_parameters(param) with tf.compat.v1.variable_scope("embedder", reuse=tf.compat.v1.AUTO_REUSE): e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, X, dtype=tf.float32, sequence_length=T) @@ -117,8 +119,7 @@ def recovery(H, T, param, S=None): - (X_tilde, XS_tilde): recovered temporal and static data, if S is given """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - dim = z_dim + hidden_dim, num_layers, module_name, dim = unpack_parameters(param) with tf.compat.v1.variable_scope("recovery", reuse=tf.compat.v1.AUTO_REUSE): r_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) r_outputs, r_last_states = tf.compat.v1.nn.dynamic_rnn(r_cell, H, dtype=tf.float32, sequence_length=T) @@ -148,8 +149,7 @@ def generator(Z, T, param, S=None): had any static-feature support) - (E, ES): generated temporal and static embeddings, if S is given """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - dim = z_dim + hidden_dim, num_layers, module_name, dim = unpack_parameters(param) with tf.compat.v1.variable_scope("generator", reuse=tf.compat.v1.AUTO_REUSE): e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) e_outputs, e_last_states = tf.compat.v1.nn.dynamic_rnn(e_cell, Z, dtype=tf.float32, sequence_length=T) @@ -173,8 +173,7 @@ def supervisor(H, T, param): Returns: - S: generated sequence based on the latent representations generated by the generator """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - dim = z_dim + hidden_dim, num_layers, module_name, dim = unpack_parameters(param) with tf.compat.v1.variable_scope("supervisor", reuse=tf.compat.v1.AUTO_REUSE): e_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell( [rnn_cell(module_name, hidden_dim) for _ in range(num_layers - 1)] @@ -202,8 +201,7 @@ def discriminator(H, T, param, S=None): - (Y_hat, YS_hat): temporal and static classification results, if S is given """ - hidden_dim, num_layers, iterations, batch_size, module_name, z_dim, gamma = unpack_parameters(param) - dim = z_dim + hidden_dim, num_layers, module_name, dim = unpack_parameters(param) with tf.compat.v1.variable_scope("discriminator", reuse=tf.compat.v1.AUTO_REUSE): d_cell = tf.compat.v1.nn.rnn_cell.MultiRNNCell([rnn_cell(module_name, hidden_dim) for _ in range(num_layers)]) d_outputs, d_last_states = tf.compat.v1.nn.dynamic_rnn(d_cell, H, dtype=tf.float32, sequence_length=T) From bb316eeec3513ed55d87e4f3646575d2c4385a38 Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Tue, 15 Sep 2026 11:55:55 -0600 Subject: [PATCH 08/10] Guard tutorial.py/full_demo.py behind __main__ -- fixes CI hang pytest --doctest-modules tests (what tox.ini's test command, and so CI's tox job, actually runs) imports every .py under tests/ to scan for doctests. Both files ran real TimeGAN training unconditionally at module level, so importing them for doctest-scanning silently kicked off a real (in full_demo.py's case, 10,000-iteration) training run. Confirmed the hang directly with a timeout-wrapped collect-only run before fixing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P5kHya3EZkoBQarkiwdJmL --- tests/full_demo.py | 89 +++++++++++++++++++++++++--------------------- tests/tutorial.py | 58 ++++++++++++++++-------------- 2 files changed, 79 insertions(+), 68 deletions(-) diff --git a/tests/full_demo.py b/tests/full_demo.py index 0dfd50b..0e8ebd3 100644 --- a/tests/full_demo.py +++ b/tests/full_demo.py @@ -1,67 +1,74 @@ # mypy: ignore-errors -# Necessary packages +"""Full demo run -- trains a production-scale TimeGAN model on the bundled +stock dataset (10,000 iterations) and reports discriminative/predictive +scores plus PCA/t-SNE visualizations. Not a pytest test (see +tests/test_training.py for those); run directly with +`python3 tests/full_demo.py`. Slow by design -- this is meant to produce +a real result, not to run in CI. + +Guarded behind `if __name__ == "__main__"` so importing this module (e.g. +pytest's `--doctest-modules` collection, which imports every .py under +`tests/` to scan for doctests) doesn't accidentally kick off this full +10,000-iteration run as a side effect -- confirmed this was happening +before the guard was added: `pytest --doctest-modules tests` hung +importing this file, since its logic used to run unconditionally at +module level. +""" import warnings import numpy as np -# 1. TimeGAN model from timegan import timegan - -# 2. Data loading from timegan.data_loading import real_data_loading, sine_data_generation - -# 3. Metrics from timegan.metrics.discriminative_metrics import discriminative_score_metrics from timegan.metrics.predictive_metrics import predictive_score_metrics from timegan.metrics.visualization_metrics import visualization -warnings.filterwarnings("ignore") -## Data loading -data_name = "stock" -seq_len = 24 +def main(): + warnings.filterwarnings("ignore") -if data_name in ["stock", "energy"]: - ori_data = real_data_loading(data_name, seq_len) -elif data_name == "sine": - # Set number of samples and its dimensions - no, dim = 10000, 5 - ori_data = sine_data_generation(no, seq_len, dim) + data_name = "stock" + seq_len = 24 -print(data_name + " dataset is ready.") -## Newtork parameters -parameters = dict() + if data_name in ["stock", "energy"]: + ori_data = real_data_loading(data_name, seq_len) + elif data_name == "sine": + no, dim = 10000, 5 + ori_data = sine_data_generation(no, seq_len, dim) -parameters["module"] = "gru" -parameters["hidden_dim"] = 24 -parameters["num_layer"] = 3 -parameters["iterations"] = 10000 -parameters["batch_size"] = 128 + print(data_name + " dataset is ready.") + parameters = dict() + parameters["module"] = "gru" + parameters["hidden_dim"] = 24 + parameters["num_layer"] = 3 + parameters["iterations"] = 10000 + parameters["batch_size"] = 128 -# Run TimeGAN -generated_data = timegan.train_timegan(ori_data, parameters) -# timed_training = timegan.train_timegan_timed(ori_data, parameters,filename = 'timegan_save', seconds=60, phase=1, current_iter = 0) + generated_data = timegan.train_timegan(ori_data, parameters) + print("Finish Synthetic Data Generation") + metric_iteration = 5 -print("Finish Synthetic Data Generation") + discriminative_score = list() + for _ in range(metric_iteration): + temp_disc = discriminative_score_metrics(ori_data, generated_data) + discriminative_score.append(temp_disc) -metric_iteration = 5 + print("Discriminative score: " + str(np.round(np.mean(discriminative_score), 4))) -discriminative_score = list() -for _ in range(metric_iteration): - temp_disc = discriminative_score_metrics(ori_data, generated_data) - discriminative_score.append(temp_disc) + predictive_score = list() + for _ in range(metric_iteration): + temp_pred = predictive_score_metrics(ori_data, generated_data) + predictive_score.append(temp_pred) -print("Discriminative score: " + str(np.round(np.mean(discriminative_score), 4))) + print("Predictive score: " + str(np.round(np.mean(predictive_score), 4))) -predictive_score = list() -for tt in range(metric_iteration): - temp_pred = predictive_score_metrics(ori_data, generated_data) - predictive_score.append(temp_pred) + visualization(ori_data, generated_data, "pca") + visualization(ori_data, generated_data, "tsne") -print("Predictive score: " + str(np.round(np.mean(predictive_score), 4))) -visualization(ori_data, generated_data, "pca") -visualization(ori_data, generated_data, "tsne") +if __name__ == "__main__": + main() diff --git a/tests/tutorial.py b/tests/tutorial.py index 7623cdf..b5f9d5c 100644 --- a/tests/tutorial.py +++ b/tests/tutorial.py @@ -1,41 +1,45 @@ -## Necessary packages +"""Fast demo run -- trains a real (small) TimeGAN model on the bundled +stock dataset. Not a pytest test (see tests/test_training.py for those); +run directly with `python3 tests/tutorial.py`. + +Guarded behind `if __name__ == "__main__"` so importing this module (e.g. +pytest's `--doctest-modules` collection, which imports every .py under +`tests/` to scan for doctests) doesn't accidentally kick off a real +training run as a side effect -- confirmed this was happening before the +guard was added: `pytest --doctest-modules tests` hung importing this +file, since its logic used to run unconditionally at module level. +""" import warnings -# 1. TimeGAN model from timegan import timegan - -# 2. Data loading from timegan.data_loading import real_data_loading, sine_data_generation -# 3. Metrics -warnings.filterwarnings("ignore") -## Data loading -data_name = "stock" -seq_len = 24 +def main(): + warnings.filterwarnings("ignore") -if data_name in ["stock", "energy"]: - ori_data = real_data_loading(data_name, seq_len) -elif data_name == "sine": - # Set number of samples and its dimensions - no, dim = 100, 5 - ori_data = sine_data_generation(no, seq_len, dim) + data_name = "stock" + seq_len = 24 -print(data_name + " dataset is ready.") -## Newtork parameters -parameters = dict() + if data_name in ["stock", "energy"]: + ori_data = real_data_loading(data_name, seq_len) + elif data_name == "sine": + no, dim = 100, 5 + ori_data = sine_data_generation(no, seq_len, dim) -parameters["module"] = "gru" -parameters["hidden_dim"] = 24 -parameters["num_layer"] = 3 -parameters["iterations"] = 100 -parameters["batch_size"] = 128 + print(data_name + " dataset is ready.") + parameters = dict() + parameters["module"] = "gru" + parameters["hidden_dim"] = 24 + parameters["num_layer"] = 3 + parameters["iterations"] = 100 + parameters["batch_size"] = 128 -# Run TimeGAN -generated_data = timegan.train_timegan(ori_data, parameters) -# timed_training = timegan.train_timegan_timed(ori_data, parameters, filename="timegan_save", seconds=60, phase=1, current_iter=0) + timegan.train_timegan(ori_data, parameters) + print("Finish Synthetic Data Generation") -print("Finish Synthetic Data Generation") +if __name__ == "__main__": + main() From 6a122aaad78c7357b7c20532d0cce01e3f290f68 Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Tue, 15 Sep 2026 11:56:02 -0600 Subject: [PATCH 09/10] Fix ruff findings: S108 tmp paths, S603/S607 subprocess, TRY003 - tests/test_training.py: hardcoded /tmp/test_* checkpoint paths -> pytest's tmp_path fixture. - report_training_comparison.py: hardcoded /tmp/report_cmp_* paths -> tempfile.gettempdir(); noqa the git-rev-parse subprocess call (fixed argv list, no shell involved -- not the injectable pattern S603/S607 are meant to catch). - timegan/timegan.py: noqa the two long-message RuntimeErrors in train_timegan rather than introducing dedicated exception classes for two internal error paths. Also includes ruff-format's own reformatting of these files (one-arg- per-line call style) from running the formatter as part of this pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P5kHya3EZkoBQarkiwdJmL --- report_training_comparison.py | 75 ++++++++++++++------ tests/test_training.py | 52 +++++++++----- timegan/timegan.py | 125 ++++++++++++++++++++++------------ 3 files changed, 171 insertions(+), 81 deletions(-) diff --git a/report_training_comparison.py b/report_training_comparison.py index 7a9a2a8..30960d1 100644 --- a/report_training_comparison.py +++ b/report_training_comparison.py @@ -30,18 +30,19 @@ thing train_timegan_timed and train_timegan (with or without ori_data_static) all return in common. """ + import argparse import json import os import subprocess +import tempfile import time import numpy as np import tensorflow as tf from timegan.data_loading import real_data_loading -from timegan.timegan import train_timegan -from timegan.timegan import train_timegan_timed +from timegan.timegan import train_timegan, train_timegan_timed OUT_DIR = ".training_comparison" TINY_PARAMS = dict(hidden_dim=4, num_layer=2, batch_size=8, module="gru", iterations=2) @@ -49,8 +50,12 @@ def _git_commit(): try: + # Fixed argv list, no shell involved -- not the injectable pattern + # S603/S607 are meant to catch. return subprocess.check_output( - ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL, + ["git", "rev-parse", "HEAD"], # noqa: S603, S607 + text=True, + stderr=subprocess.DEVNULL, ).strip() except Exception: return None @@ -59,38 +64,53 @@ def _git_commit(): def _summary_stats(generated): flat = np.concatenate([np.asarray(seq).ravel() for seq in generated]) return dict( - mean=float(flat.mean()), std=float(flat.std()), - min=float(flat.min()), max=float(flat.max()), + mean=float(flat.mean()), + std=float(flat.std()), + min=float(flat.min()), + max=float(flat.max()), ) def _run_once(fn_name, seed): np.random.seed(seed) tf.compat.v1.set_random_seed(seed) + # tempfile.gettempdir() rather than a literal "/tmp/..." -- same + # location in practice, but respects TMPDIR and isn't a hardcoded + # world-writable-directory guess (ruff S108). + checkpoint_stem = os.path.join(tempfile.gettempdir(), f"report_cmp_{fn_name}_{seed}") if fn_name in ("timed", "base"): ori_data = np.asarray(real_data_loading("stock", seq_len=8)[:40], dtype=np.float32) if fn_name == "timed": phase, generated = train_timegan_timed( - ori_data, dict(TINY_PARAMS), in_filename=f"/tmp/report_cmp_{fn_name}_{seed}", - seconds=120, phase=1, new=True, num_generate=10, + ori_data, + dict(TINY_PARAMS), + in_filename=checkpoint_stem, + seconds=120, + phase=1, + new=True, + num_generate=10, ) if phase != 4: - raise RuntimeError(f"train_timegan_timed didn't finish in one call (phase={phase})") + raise RuntimeError(f"train_timegan_timed didn't finish in one call (phase={phase})") # noqa: TRY003 else: generated = train_timegan( - ori_data, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", + ori_data, + dict(TINY_PARAMS), + filename=checkpoint_stem, ) elif fn_name == "static": no, seq_len, dim = 32, 6, 3 ori_data = np.random.uniform(0, 1, [no, seq_len, dim]).astype(np.float32) ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) generated = train_timegan( - ori_data, dict(TINY_PARAMS), filename=f"/tmp/report_cmp_{fn_name}_{seed}", + ori_data, + dict(TINY_PARAMS), + filename=checkpoint_stem, ori_data_static=ori_data_static, ) else: - raise ValueError(f"unknown fn: {fn_name}") + raise ValueError(f"unknown fn: {fn_name}") # noqa: TRY003 return _summary_stats(generated) @@ -106,8 +126,12 @@ def capture(label, fn_name, runs): run_stats.append(stats) record = dict( - label=label, fn=fn_name, runs=runs, git_commit=_git_commit(), - timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), params=TINY_PARAMS, + label=label, + fn=fn_name, + runs=runs, + git_commit=_git_commit(), + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + params=TINY_PARAMS, run_stats=run_stats, ) out_path = os.path.join(OUT_DIR, f"{label}.json") @@ -123,8 +147,10 @@ def compare(label_a, label_b): rec_b = json.load(f) if rec_a["fn"] != rec_b["fn"]: - print(f"WARNING: comparing different functions ({rec_a['fn']} vs {rec_b['fn']}) -- " - f"not a meaningful before/after comparison") + print( + f"WARNING: comparing different functions ({rec_a['fn']} vs {rec_b['fn']}) -- " + f"not a meaningful before/after comparison" + ) print(f"{label_a}: fn={rec_a['fn']} commit={rec_a.get('git_commit')} runs={rec_a['runs']}") print(f"{label_b}: fn={rec_b['fn']} commit={rec_b.get('git_commit')} runs={rec_b['runs']}") @@ -141,17 +167,22 @@ def compare(label_a, label_b): flag = "" if overlap else " <-- NO OVERLAP, worth a closer look" if not overlap: any_no_overlap = True - print(f"{metric:5s} {label_a} range=[{a_lo:.4f}, {a_hi:.4f}] " - f"{label_b} range=[{b_lo:.4f}, {b_hi:.4f}]{flag}") + print( + f"{metric:5s} {label_a} range=[{a_lo:.4f}, {a_hi:.4f}] " f"{label_b} range=[{b_lo:.4f}, {b_hi:.4f}]{flag}" + ) print() if any_no_overlap: - print("At least one metric's ranges don't overlap across runs -- run more repeats on " - "each side before concluding this is a real behavior change rather than the " - "handful-of-runs sample just missing each other by chance.") + print( + "At least one metric's ranges don't overlap across runs -- run more repeats on " + "each side before concluding this is a real behavior change rather than the " + "handful-of-runs sample just missing each other by chance." + ) else: - print("All tracked metrics' ranges overlap -- no evidence of a behavior change beyond " - "ordinary run-to-run noise, at this sample size.") + print( + "All tracked metrics' ranges overlap -- no evidence of a behavior change beyond " + "ordinary run-to-run noise, at this sample size." + ) if __name__ == "__main__": diff --git a/tests/test_training.py b/tests/test_training.py index e603dbc..bc67551 100644 --- a/tests/test_training.py +++ b/tests/test_training.py @@ -27,14 +27,12 @@ of events. These are meant to run in a couple seconds each, not to produce a meaningful trained model. """ + import numpy as np -import pytest import tensorflow as tf from timegan.data_loading import real_data_loading -from timegan.timegan import load_timegan -from timegan.timegan import train_timegan -from timegan.timegan import train_timegan_timed +from timegan.timegan import load_timegan, train_timegan, train_timegan_timed TINY_PARAMS = dict(hidden_dim=4, num_layer=2, batch_size=8, module="gru") # supervisor() builds num_layers - 1 RNN cells -- num_layer=1 would pass an @@ -63,7 +61,7 @@ def _assert_sane_generated_output(generated, expected_shape): assert generated.max() <= 1.05 -def test_train_timegan_timed_on_stock(): +def test_train_timegan_timed_on_stock(tmp_path): """The function Traces_GAN (and presumably every other real caller) actually uses.""" np.random.seed(0) @@ -71,15 +69,20 @@ def test_train_timegan_timed_on_stock(): ori_data = _subsample("stock", seq_len=8, n_events=40) phase, info = train_timegan_timed( - ori_data, dict(TINY_PARAMS, iterations=2), in_filename="/tmp/test_timed_stock", - seconds=120, phase=1, new=True, num_generate=5, + ori_data, + dict(TINY_PARAMS, iterations=2), + in_filename=str(tmp_path / "test_timed_stock"), + seconds=120, + phase=1, + new=True, + num_generate=5, ) assert phase == 4, f"expected all phases to finish within the time budget, got phase {phase}" _assert_sane_generated_output(info, expected_shape=(5, 8, ori_data.shape[-1])) -def test_train_timegan_timed_on_energy_smoke(): +def test_train_timegan_timed_on_energy_smoke(tmp_path): """Different dataset/dimensionality -- lighter check, just confirms the shape/no-NaN properties hold beyond the one dataset above.""" np.random.seed(1) @@ -87,15 +90,20 @@ def test_train_timegan_timed_on_energy_smoke(): ori_data = _subsample("energy", seq_len=8, n_events=40) phase, info = train_timegan_timed( - ori_data, dict(TINY_PARAMS, iterations=2), in_filename="/tmp/test_timed_energy", - seconds=120, phase=1, new=True, num_generate=5, + ori_data, + dict(TINY_PARAMS, iterations=2), + in_filename=str(tmp_path / "test_timed_energy"), + seconds=120, + phase=1, + new=True, + num_generate=5, ) assert phase == 4 _assert_sane_generated_output(info, expected_shape=(5, 8, ori_data.shape[-1])) -def test_train_timegan_base_smoke(): +def test_train_timegan_base_smoke(tmp_path): """timegan.py's train_timegan -- a thin wrapper around train_timegan_timed (was previously a full standalone copy of its graph-building/training-loop code -- the same "same fix needs applying @@ -108,13 +116,15 @@ def test_train_timegan_base_smoke(): ori_data = _subsample("stock", seq_len=8, n_events=24) generated = train_timegan( - ori_data, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_base_stock", + ori_data, + dict(TINY_PARAMS, iterations=2), + filename=str(tmp_path / "test_base_stock"), ) _assert_sane_generated_output(generated, expected_shape=(24, 8, ori_data.shape[-1])) -def test_train_timegan_with_static_features_on_synthetic(): +def test_train_timegan_with_static_features_on_synthetic(tmp_path): """Static-feature support, folded into timegan.py's own train_timegan (via the optional ori_data_static param) from what used to be a second, entirely separate file (timegan_static.py) that could not @@ -140,14 +150,16 @@ def test_train_timegan_with_static_features_on_synthetic(): ori_data_static = np.random.randint(0, 2, size=(no, 1)).astype(np.float32) generated = train_timegan( - ori_data, dict(TINY_PARAMS, iterations=2), filename="/tmp/test_static_synth", + ori_data, + dict(TINY_PARAMS, iterations=2), + filename=str(tmp_path / "test_static_synth"), ori_data_static=ori_data_static, ) _assert_sane_generated_output(generated, expected_shape=(no, seq_len, dim)) -def test_load_timegan_with_static_features_round_trip(): +def test_load_timegan_with_static_features_round_trip(tmp_path): """train then immediately load the same checkpoint, with static features -- confirms load_timegan's identical treatment (same optional ori_data_static param, same two-branch graph wiring) also @@ -160,10 +172,16 @@ def test_load_timegan_with_static_features_round_trip(): params = dict(TINY_PARAMS, iterations=2) train_timegan( - ori_data, dict(params), filename="/tmp/test_roundtrip", ori_data_static=ori_data_static, + ori_data, + dict(params), + filename=str(tmp_path / "test_roundtrip"), + ori_data_static=ori_data_static, ) generated = load_timegan( - ori_data, dict(params), filename="/tmp/test_roundtrip-0", ori_data_static=ori_data_static, + ori_data, + dict(params), + filename=str(tmp_path / "test_roundtrip-0"), + ori_data_static=ori_data_static, ) _assert_sane_generated_output(generated, expected_shape=(no, seq_len, dim)) diff --git a/timegan/timegan.py b/timegan/timegan.py index d3a0899..ede8935 100644 --- a/timegan/timegan.py +++ b/timegan/timegan.py @@ -255,17 +255,26 @@ def train_timegan(ori_data, parameters, filename="timegan_save", version=0, ori_ filenames, which is why train_timegan_timed stopped using it). """ phase, info = train_timegan_timed( - ori_data, parameters, in_filename=filename, out_filename=filename, - seconds=float("inf"), phase=1, current_iter=0, new=True, version=version, + ori_data, + parameters, + in_filename=filename, + out_filename=filename, + seconds=float("inf"), + phase=1, + current_iter=0, + new=True, + version=version, ori_data_static=ori_data_static, ) if phase == -1: - raise RuntimeError(f"train_timegan_timed failed: {info}") + raise RuntimeError(f"train_timegan_timed failed: {info}") # noqa: TRY003 if phase != 4: # Unreachable with seconds=inf (the time-budget check can never # trip), but fail loudly rather than silently returning partial # training if that assumption is ever wrong. - raise RuntimeError(f"train_timegan_timed did not reach phase 4 (got phase={phase}) despite seconds=inf") + raise RuntimeError( # noqa: TRY003 + f"train_timegan_timed did not reach phase 4 (got phase={phase}) despite seconds=inf" + ) return info @@ -384,9 +393,12 @@ def _build_timegan_graph(parameters, max_seq_len, dim, static_dim=None): G_loss_V = G_loss_V1 + G_loss_V2 # 4. Summation - G_loss = (G_loss_U + gamma * G_loss_U_e - + parameters["g_loss_s_weight"] * tf.sqrt(G_loss_S + 1e-6) - + parameters["g_loss_v_weight"] * G_loss_V) + G_loss = ( + G_loss_U + + gamma * G_loss_U_e + + parameters["g_loss_s_weight"] * tf.sqrt(G_loss_S + 1e-6) + + parameters["g_loss_v_weight"] * G_loss_V + ) # Embedder network loss E_loss_T0 = tf.compat.v1.losses.mean_squared_error(X, X_tilde) @@ -401,17 +413,38 @@ def _build_timegan_graph(parameters, max_seq_len, dim, static_dim=None): GS_solver = tf.compat.v1.train.AdamOptimizer().minimize(G_loss_S, var_list=g_vars + s_vars) return dict( - X=X, Z=Z, T=T, S=S, S_z=S_z, X_hat=X_hat, - E0_solver=E0_solver, E_solver=E_solver, D_solver=D_solver, - G_solver=G_solver, GS_solver=GS_solver, - E_loss_T0=E_loss_T0, G_loss_S=G_loss_S, G_loss_U=G_loss_U, - G_loss_V=G_loss_V, D_loss=D_loss, + X=X, + Z=Z, + T=T, + S=S, + S_z=S_z, + X_hat=X_hat, + E0_solver=E0_solver, + E_solver=E_solver, + D_solver=D_solver, + G_solver=G_solver, + GS_solver=GS_solver, + E_loss_T0=E_loss_T0, + G_loss_S=G_loss_S, + G_loss_U=G_loss_U, + G_loss_V=G_loss_V, + D_loss=D_loss, ) def train_timegan_timed( - ori_data, parameters, in_filename, out_filename=None, seconds=3600, phase=1, current_iter=0, new=False, version=0, - num_generate=None, on_training_complete=None, ori_data_static=None, + ori_data, + parameters, + in_filename, + out_filename=None, + seconds=3600, + phase=1, + current_iter=0, + new=False, + version=0, + num_generate=None, + on_training_complete=None, + ori_data_static=None, ): """Trains a TimeGAN model for a specific number of seconds, then stops and saves the session. @@ -610,14 +643,13 @@ def _static_extras(s_mb=None, sz_mb=None): # Set mini-batch X_mb, T_mb, S_mb = _draw_batch() # Train embedder - _, step_e_loss = sess.run( - [E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb, **_static_extras(S_mb)} - ) + _, step_e_loss = sess.run([E0_solver, E_loss_T0], feed_dict={X: X_mb, T: T_mb, **_static_extras(S_mb)}) if itt % 50 == 0: elapsed = (time_ns() - start_time) / 1e9 - print(f"phase 1 iter {itt}/{iterations} e_loss={step_e_loss:.4f} " - f"elapsed={elapsed:.0f}s", flush=True) + print( + f"phase 1 iter {itt}/{iterations} e_loss={step_e_loss:.4f} " f"elapsed={elapsed:.0f}s", flush=True + ) log_history({"phase": 1, "iter": itt, "elapsed": elapsed, "e_loss": float(step_e_loss)}) # End/suspend training if time is over max @@ -648,8 +680,10 @@ def _static_extras(s_mb=None, sz_mb=None): if itt % 50 == 0: elapsed = (time_ns() - start_time) / 1e9 - print(f"phase 2 iter {itt}/{iterations} g_loss_s={step_g_loss_s:.4f} " - f"elapsed={elapsed:.0f}s", flush=True) + print( + f"phase 2 iter {itt}/{iterations} g_loss_s={step_g_loss_s:.4f} " f"elapsed={elapsed:.0f}s", + flush=True, + ) log_history({"phase": 2, "iter": itt, "elapsed": elapsed, "g_loss_s": float(step_g_loss_s)}) # End/suspend training if time is over max @@ -692,9 +726,7 @@ def _static_extras(s_mb=None, sz_mb=None): Z_mb = random_generator(batch_size, z_dim, T_mb, max_seq_len) Sz_mb = _draw_static_noise(batch_size) # Check discriminator loss before updating - check_d_loss = sess.run( - D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb, **_static_extras(S_mb, Sz_mb)} - ) + check_d_loss = sess.run(D_loss, feed_dict={X: X_mb, T: T_mb, Z: Z_mb, **_static_extras(S_mb, Sz_mb)}) # Train discriminator (only when the discriminator does not work well) if check_d_loss > 0.15: _, step_d_loss = sess.run( @@ -704,9 +736,12 @@ def _static_extras(s_mb=None, sz_mb=None): if itt % 50 == 0: elapsed = (time_ns() - start_time) / 1e9 - print(f"phase 3 iter {itt}/{iterations} g_loss_u={step_g_loss_u:.4f} " - f"g_loss_s={step_g_loss_s:.4f} g_loss_v={step_g_loss_v:.4f} " - f"d_loss={check_d_loss:.4f} elapsed={elapsed:.0f}s", flush=True) + print( + f"phase 3 iter {itt}/{iterations} g_loss_u={step_g_loss_u:.4f} " + f"g_loss_s={step_g_loss_s:.4f} g_loss_v={step_g_loss_v:.4f} " + f"d_loss={check_d_loss:.4f} elapsed={elapsed:.0f}s", + flush=True, + ) # Per-timestep variance check on the same minibatch used for # the discriminator loss above: catches a generator @@ -716,24 +751,30 @@ def _static_extras(s_mb=None, sz_mb=None): # -- a stable-looking d_loss/g_loss_u is consistent with # both a healthy adversarial game AND a generator that's # settled for reproducing the dominant mode. - gen_batch = sess.run( - X_hat, feed_dict={Z: Z_mb, X: X_mb, T: T_mb, **_static_extras(S_mb, Sz_mb)} - ) + gen_batch = sess.run(X_hat, feed_dict={Z: Z_mb, X: X_mb, T: T_mb, **_static_extras(S_mb, Sz_mb)}) real_std = np.std(X_mb, axis=0) gen_std = np.std(gen_batch, axis=0) ratio = gen_std / (real_std + 1e-8) - print(f"phase 3 iter {itt}/{iterations} variance_check " - f"(generated_std/real_std, per timestep): " - f"mean={ratio.mean():.3f} min={ratio.min():.3f} max={ratio.max():.3f}", - flush=True) - log_history({ - "phase": 3, "iter": itt, "elapsed": elapsed, - "g_loss_u": float(step_g_loss_u), "g_loss_s": float(step_g_loss_s), - "g_loss_v": float(step_g_loss_v), "d_loss": float(check_d_loss), - "variance_ratio_mean": float(ratio.mean()), - "variance_ratio_min": float(ratio.min()), - "variance_ratio_max": float(ratio.max()), - }) + print( + f"phase 3 iter {itt}/{iterations} variance_check " + f"(generated_std/real_std, per timestep): " + f"mean={ratio.mean():.3f} min={ratio.min():.3f} max={ratio.max():.3f}", + flush=True, + ) + log_history( + { + "phase": 3, + "iter": itt, + "elapsed": elapsed, + "g_loss_u": float(step_g_loss_u), + "g_loss_s": float(step_g_loss_s), + "g_loss_v": float(step_g_loss_v), + "d_loss": float(check_d_loss), + "variance_ratio_mean": float(ratio.mean()), + "variance_ratio_min": float(ratio.min()), + "variance_ratio_max": float(ratio.max()), + } + ) # End/suspend training if time is over max now = time_ns() From 236ad22150aebc4b2fe87bcdcca63d625a4f240c Mon Sep 17 00:00:00 2001 From: Amy Roberts Date: Tue, 15 Sep 2026 11:56:09 -0600 Subject: [PATCH 10/10] Remove unused "utils" dependency Confirmed via deptry (DEP002) and grep: nothing in the codebase does `import utils`/`from utils import ...` (everywhere correctly uses timegan.utils). This spurious PyPI package -- added by mistake during the poetry [tool.poetry] -> PEP 621 [project] migration -- is what was shadowing timegan_static.py's intended local import before that file was fixed and later folded into timegan.py. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P5kHya3EZkoBQarkiwdJmL --- poetry.lock | 13 +------------ pyproject.toml | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index b2e6797..17b279d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2327,17 +2327,6 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] -[[package]] -name = "utils" -version = "1.0.2" -description = "A grab-bag of utility functions and objects" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "utils-1.0.2.tar.gz", hash = "sha256:f4d5157e27e9d434006b5b52a1ec951a34e53e7ecaa145d43a153ec452eb5d9e"}, -] - [[package]] name = "virtualenv" version = "20.26.2" @@ -2520,4 +2509,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.11" -content-hash = "cf1e4c030b7638328fc02d45358ccbfb185204113f7b5cb464400d32108321ac" +content-hash = "80eb24555fb0eeb69336d3a1be74252f1c469b47ebbfd6eddda4611cf04f4fb8" diff --git a/pyproject.toml b/pyproject.toml index e81d531..7eb1a8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ authors = [ dependencies = [ "tensorflow==2.11.0", "numpy==1.26.4", - "utils", "scikit-learn>=1.4.2,<2.0.0", "matplotlib>=3.1.1,<4.0.0", "tf-slim",