From 341b34d324cf8054f2f59f092b3afcdd031bc828 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 2 Aug 2022 17:05:25 +0200 Subject: [PATCH] Add black to linting (#138) --- .github/workflows/test.yml | 4 +- deepdow/__init__.py | 2 +- deepdow/benchmarks.py | 87 +++-- deepdow/callbacks.py | 357 ++++++++++++------ deepdow/data/__init__.py | 35 +- deepdow/data/augment.py | 47 ++- deepdow/data/load.py | 262 +++++++++---- deepdow/experiments.py | 239 ++++++++---- deepdow/explain.py | 33 +- deepdow/layers/__init__.py | 69 ++-- deepdow/layers/allocate.py | 207 ++++++++--- deepdow/layers/collapse.py | 20 +- deepdow/layers/misc.py | 163 +++++--- deepdow/layers/transform.py | 144 ++++--- deepdow/losses.py | 443 ++++++++++++++-------- deepdow/nn.py | 198 +++++++--- deepdow/utils.py | 102 +++-- deepdow/visualize.py | 220 +++++++---- setup.cfg | 3 + tests/conftest.py | 136 ++++--- tests/test_benchmarks.py | 97 +++-- tests/test_callbacks.py | 211 +++++++---- tests/test_data/test_augment.py | 81 ++-- tests/test_data/test_load.py | 234 +++++++----- tests/test_data/test_synthetic.py | 11 +- tests/test_experiments.py | 88 +++-- tests/test_explain.py | 71 +++- tests/test_layers.py | 598 ++++++++++++++++++++---------- tests/test_losses.py | 426 ++++++++++++++------- tests/test_nn.py | 166 ++++++--- tests/test_utils.py | 130 ++++--- tests/test_visualize.py | 160 +++++--- 32 files changed, 3462 insertions(+), 1582 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bfd4c9b..e41053d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,9 @@ jobs: - name: Lint run: | - pip install 'flake8==5.0.3' 'pydocstyle==6.1.1' + pip install 'black==22.6.0' 'flake8==5.0.3' 'pydocstyle==6.1.1' + + black -l 79 --check deepdow/ tests flake8 deepdow tests pydocstyle deepdow diff --git a/deepdow/__init__.py b/deepdow/__init__.py index 610cf3c..b675f42 100644 --- a/deepdow/__init__.py +++ b/deepdow/__init__.py @@ -5,4 +5,4 @@ X.Y.Z for bug fixes """ -__version__ = '0.2.2' +__version__ = "0.2.2" diff --git a/deepdow/benchmarks.py b/deepdow/benchmarks.py index c502639..6d484a3 100644 --- a/deepdow/benchmarks.py +++ b/deepdow/benchmarks.py @@ -68,8 +68,10 @@ def __call__(self, x): @property def hparams(self): """Hyperparamters relevant to construction of the model.""" - return {'use_std': self.use_std, - 'returns_channel': self.returns_channel} + return { + "use_std": self.use_std, + "returns_channel": self.returns_channel, + } class MaximumReturn(Benchmark): @@ -103,7 +105,11 @@ def __init__(self, max_weight=1, n_assets=None, returns_channel=0): self.n_assets = n_assets self.returns_channel = returns_channel - self.optlayer = self._construct_problem(n_assets, max_weight) if self.n_assets is not None else None + self.optlayer = ( + self._construct_problem(n_assets, max_weight) + if self.n_assets is not None + else None + ) @staticmethod def _construct_problem(n_assets, max_weight): @@ -112,9 +118,9 @@ def _construct_problem(n_assets, max_weight): w = cp.Variable(n_assets) ret = rets @ w - prob = cp.Problem(cp.Maximize(ret), [cp.sum(w) == 1, - w >= 0, - w <= max_weight]) + prob = cp.Problem( + cp.Maximize(ret), [cp.sum(w) == 1, w >= 0, w <= max_weight] + ) return CvxpyLayer(prob, parameters=[rets], variables=[w]) @@ -137,22 +143,30 @@ def __call__(self, x): # Problem setup if self.optlayer is not None: if self.n_assets != n_assets: - raise ValueError('Incorrect number of assets: {}, expected: {}'.format(n_assets, self.n_assets)) + raise ValueError( + "Incorrect number of assets: {}, expected: {}".format( + n_assets, self.n_assets + ) + ) optlayer = self.optlayer else: optlayer = self._construct_problem(n_assets, self.max_weight) - rets_estimate = x[:, self.returns_channel, :, :].mean(dim=1) # (n_samples, n_assets) + rets_estimate = x[:, self.returns_channel, :, :].mean( + dim=1 + ) # (n_samples, n_assets) return optlayer(rets_estimate)[0] @property def hparams(self): """Hyperparamters relevant to construction of the model.""" - return {'max_weight': self.max_weight, - 'returns_channel': self.returns_channel, - 'n_assets': self.n_assets} + return { + "max_weight": self.max_weight, + "returns_channel": self.returns_channel, + "n_assets": self.n_assets, + } class MinimumVariance(Benchmark): @@ -186,7 +200,11 @@ def __init__(self, max_weight=1, returns_channel=0, n_assets=None): self.returns_channel = returns_channel self.max_weight = max_weight - self.optlayer = self._construct_problem(n_assets, max_weight) if self.n_assets is not None else None + self.optlayer = ( + self._construct_problem(n_assets, max_weight) + if self.n_assets is not None + else None + ) @staticmethod def _construct_problem(n_assets, max_weight): @@ -195,9 +213,9 @@ def _construct_problem(n_assets, max_weight): w = cp.Variable(n_assets) risk = cp.sum_squares(covmat_sqrt @ w) - prob = cp.Problem(cp.Minimize(risk), [cp.sum(w) == 1, - w >= 0, - w <= max_weight]) + prob = cp.Problem( + cp.Minimize(risk), [cp.sum(w) == 1, w >= 0, w <= max_weight] + ) return CvxpyLayer(prob, parameters=[covmat_sqrt], variables=[w]) @@ -220,23 +238,31 @@ def __call__(self, x): # Problem setup if self.optlayer is not None: if self.n_assets != n_assets: - raise ValueError('Incorrect number of assets: {}, expected: {}'.format(n_assets, self.n_assets)) + raise ValueError( + "Incorrect number of assets: {}, expected: {}".format( + n_assets, self.n_assets + ) + ) optlayer = self.optlayer else: optlayer = self._construct_problem(n_assets, self.max_weight) # problem solver - covmat_sqrt_estimates = CovarianceMatrix(sqrt=True)(x[:, self.returns_channel, :, :]) + covmat_sqrt_estimates = CovarianceMatrix(sqrt=True)( + x[:, self.returns_channel, :, :] + ) return optlayer(covmat_sqrt_estimates)[0] @property def hparams(self): """Hyperparamters relevant to construction of the model.""" - return {'max_weight': self.max_weight, - 'returns_channel': self.returns_channel, - 'n_assets': self.n_assets} + return { + "max_weight": self.max_weight, + "returns_channel": self.returns_channel, + "n_assets": self.n_assets, + } class OneOverN(Benchmark): @@ -258,7 +284,10 @@ def __call__(self, x): """ n_samples, n_channels, lookback, n_assets = x.shape - return torch.ones((n_samples, n_assets), dtype=x.dtype, device=x.device) / n_assets + return ( + torch.ones((n_samples, n_assets), dtype=x.dtype, device=x.device) + / n_assets + ) class Random(Benchmark): @@ -280,8 +309,12 @@ def __call__(self, x): """ n_samples, n_channels, lookback, n_assets = x.shape - weights_unscaled = torch.rand((n_samples, n_assets), dtype=x.dtype, device=x.device) - weights_sums = weights_unscaled.sum(dim=1, keepdim=True).repeat(1, n_assets) + weights_unscaled = torch.rand( + (n_samples, n_assets), dtype=x.dtype, device=x.device + ) + weights_sums = weights_unscaled.sum(dim=1, keepdim=True).repeat( + 1, n_assets + ) return weights_unscaled / weights_sums @@ -316,9 +349,11 @@ def __call__(self, x): n_samples, n_channels, lookback, n_assets = x.shape if self.asset_ix not in set(range(n_assets)): - raise IndexError('The selected asset index is out of range.') + raise IndexError("The selected asset index is out of range.") - weights = torch.zeros((n_samples, n_assets), dtype=x.dtype, device=x.device) + weights = torch.zeros( + (n_samples, n_assets), dtype=x.dtype, device=x.device + ) weights[:, self.asset_ix] = 1 return weights @@ -326,4 +361,4 @@ def __call__(self, x): @property def hparams(self): """Hyperparamters relevant to construction of the model.""" - return {'asset_ix': self.asset_ix} + return {"asset_ix": self.asset_ix} diff --git a/deepdow/callbacks.py b/deepdow/callbacks.py index 5d6f13a..8f134ac 100644 --- a/deepdow/callbacks.py +++ b/deepdow/callbacks.py @@ -135,11 +135,20 @@ def on_train_begin(self, metadata): with torch.no_grad(): for dl_name, dl in self.run.val_dataloaders.items(): for bm_name, bm in self.run.models.items(): - if bm_name == 'main': + if bm_name == "main": continue - for batch, (X_batch, y_batch, timestamps_batch, _) in enumerate(dl): - X_batch = X_batch.to(self.run.device).to(self.run.dtype) - y_batch = y_batch.to(self.run.device).to(self.run.dtype) + for batch, ( + X_batch, + y_batch, + timestamps_batch, + _, + ) in enumerate(dl): + X_batch = X_batch.to(self.run.device).to( + self.run.dtype + ) + y_batch = y_batch.to(self.run.device).to( + self.run.dtype + ) lookbacks = [] if self.lookbacks is None: @@ -151,18 +160,30 @@ def on_train_begin(self, metadata): weights = bm(X_batch[:, :, -lb:, :]) - for metric_name, metric in self.run.metrics.items(): - metric_per_s = metric(weights, y_batch).detach().cpu().numpy() - - for metric_value, ts in zip(metric_per_s, timestamps_batch): - self.run.history.add_entry(timestamp=ts, - epoch=-1, - model=bm_name, - batch=batch, - lookback=lb, - dataloader=dl_name, - metric=metric_name, - value=metric_value) + for ( + metric_name, + metric, + ) in self.run.metrics.items(): + metric_per_s = ( + metric(weights, y_batch) + .detach() + .cpu() + .numpy() + ) + + for metric_value, ts in zip( + metric_per_s, timestamps_batch + ): + self.run.history.add_entry( + timestamp=ts, + epoch=-1, + model=bm_name, + batch=batch, + lookback=lb, + dataloader=dl_name, + metric=metric_name, + value=metric_value, + ) if len(self.run.models) > 1: self.run.history.pretty_print(-1) @@ -207,21 +228,34 @@ def __init__(self, dataloader_name, metric_name, patience=5): def on_train_begin(self, metadata): """Check if dataloader name and metric name even exist.""" if self.dataloader_name not in self.run.val_dataloaders: - raise ValueError('Did not find the dataloader {}'.format(self.dataloader_name)) + raise ValueError( + "Did not find the dataloader {}".format(self.dataloader_name) + ) if self.metric_name not in self.run.metrics: - raise ValueError('Did not find the metric {}'.format(self.metric_name)) + raise ValueError( + "Did not find the metric {}".format(self.metric_name) + ) def on_epoch_end(self, metadata): """Extract statistics and if necessary stop training.""" - epoch = metadata['epoch'] + epoch = metadata["epoch"] stats = self.run.history.metrics_per_epoch(epoch) - if not (len(stats['lookback'].unique()) == 1 and len(stats['model'].unique()) == 1): - raise ValueError('EarlyStoppingCallback needs to have a single lookback and model') # pragma: no cover - - stats_formatted = stats.groupby(['dataloader', 'metric'])['value'].mean().unstack(-1) - current_metric = stats_formatted.loc[self.dataloader_name, self.metric_name] + if not ( + len(stats["lookback"].unique()) == 1 + and len(stats["model"].unique()) == 1 + ): + raise ValueError( + "EarlyStoppingCallback needs to have a single lookback and model" + ) # pragma: no cover + + stats_formatted = ( + stats.groupby(["dataloader", "metric"])["value"].mean().unstack(-1) + ) + current_metric = stats_formatted.loc[ + self.dataloader_name, self.metric_name + ] if current_metric < self.min: self.min = current_metric @@ -234,11 +268,12 @@ def on_epoch_end(self, metadata): def on_train_interrupt(self, metadata): """Handle ``EarlyStoppingException``.""" - ex = metadata['exception'] + ex = metadata["exception"] if isinstance(ex, EarlyStoppingException): - msg = 'Training stopped early because there was no improvement in {}_{} for {} epochs'.format( - self.dataloader_name, self.metric_name, self.patience) + msg = "Training stopped early because there was no improvement in {}_{} for {} epochs".format( + self.dataloader_name, self.metric_name, self.patience + ) print(msg) @@ -272,13 +307,20 @@ class MLFlowCallback(Callback): """ - def __init__(self, run_name=None, mlflow_path=None, experiment_name=None, run_id=None, log_benchmarks=False): + def __init__( + self, + run_name=None, + mlflow_path=None, + experiment_name=None, + run_id=None, + log_benchmarks=False, + ): self.run_name = run_name self.mlflow_path = mlflow_path self.experiment_name = experiment_name if run_name is not None and run_id is not None: - raise ValueError('Cannot provide both run_id and run_name') + raise ValueError("Cannot provide both run_id and run_name") self.run_id = run_id self.log_benchmarks = log_benchmarks @@ -300,9 +342,9 @@ def on_train_begin(self, metadata): # if run_id is not None then run_name is ignored self._run_id = mlflow.active_run().info.run_id params = { - 'device': self.run.device, - 'dtype': self.run.dtype, - 'train_dataloader': self.run.train_dataloader.__class__.__name__ + "device": self.run.device, + "dtype": self.run.dtype, + "train_dataloader": self.run.train_dataloader.__class__.__name__, } params.update(self.run.train_dataloader.hparams) params.update(self.run.network.hparams) @@ -311,12 +353,20 @@ def on_train_begin(self, metadata): if self.log_benchmarks: try: - df = self.run.history.metrics_per_epoch(-1) # only benchmarks - for bm_name in df['model'].unique(): + df = self.run.history.metrics_per_epoch( + -1 + ) # only benchmarks + for bm_name in df["model"].unique(): with mlflow.start_run(run_name=bm_name): - temp_df = df[df['model'] == bm_name] - metrics = {'_'.join(list(map(lambda x: str(x), k))): v for k, v in - temp_df.groupby(['dataloader', 'metric', 'lookback'])['value'].mean().items()} + temp_df = df[df["model"] == bm_name] + metrics = { + "_".join(list(map(lambda x: str(x), k))): v + for k, v in temp_df.groupby( + ["dataloader", "metric", "lookback"] + )["value"] + .mean() + .items() + } mlflow.log_metrics(metrics, step=0) mlflow.log_metrics(metrics, step=10) @@ -325,7 +375,7 @@ def on_train_begin(self, metadata): def on_epoch_end(self, metadata): """Read relevant results and log into MLflow.""" - epoch = metadata.get('epoch') + epoch = metadata.get("epoch") with ChangeWorkingDirectory(self.mlflow_path): # log some metadata @@ -336,8 +386,14 @@ def on_epoch_end(self, metadata): try: df = self.run.history.metrics_per_epoch(epoch) - metrics = {'_'.join(list(map(lambda x: str(x), k))): v for k, v in - df.groupby(['dataloader', 'metric', 'lookback'])['value'].mean().items()} + metrics = { + "_".join(list(map(lambda x: str(x), k))): v + for k, v in df.groupby( + ["dataloader", "metric", "lookback"] + )["value"] + .mean() + .items() + } mlflow.log_metrics(metrics, step=epoch) except KeyError: @@ -370,10 +426,14 @@ class ModelCheckpointCallback(Callback): Running minimum of the metric. """ - def __init__(self, folder_path, dataloader_name, metric_name, verbose=False): + def __init__( + self, folder_path, dataloader_name, metric_name, verbose=False + ): self.folder_path = pathlib.Path(folder_path) if self.folder_path.is_file(): - raise NotADirectoryError('The checkpointing path needs to be a folder.') + raise NotADirectoryError( + "The checkpointing path needs to be a folder." + ) self.dataloader_name = dataloader_name self.metric_name = metric_name @@ -387,30 +447,46 @@ def on_train_begin(self, metadata): self.folder_path.mkdir(parents=True, exist_ok=True) if self.dataloader_name not in self.run.val_dataloaders: - raise ValueError('Did not find the dataloader {}'.format(self.dataloader_name)) + raise ValueError( + "Did not find the dataloader {}".format(self.dataloader_name) + ) if self.metric_name not in self.run.metrics: - raise ValueError('Did not find the metric {}'.format(self.metric_name)) + raise ValueError( + "Did not find the metric {}".format(self.metric_name) + ) def on_epoch_end(self, metadata): """Store checkpoint if metric is in its all time low.""" - epoch = metadata['epoch'] + epoch = metadata["epoch"] stats = self.run.history.metrics_per_epoch(epoch) - if not (len(stats['lookback'].unique()) == 1 and len(stats['model'].unique()) == 1): - raise ValueError('ModelCheckpointCallback needs to have a single lookback and model') # pragma: no cover - - stats_formatted = stats.groupby(['dataloader', 'metric'])['value'].mean().unstack(-1) - current_metric = stats_formatted.loc[self.dataloader_name, self.metric_name] + if not ( + len(stats["lookback"].unique()) == 1 + and len(stats["model"].unique()) == 1 + ): + raise ValueError( + "ModelCheckpointCallback needs to have a single lookback and model" + ) # pragma: no cover + + stats_formatted = ( + stats.groupby(["dataloader", "metric"])["value"].mean().unstack(-1) + ) + current_metric = stats_formatted.loc[ + self.dataloader_name, self.metric_name + ] if current_metric < self.min: self.min = current_metric - checkpoint_path = self.folder_path / 'model_{:02d}__{:.4f}.pth'.format(epoch, current_metric) + checkpoint_path = ( + self.folder_path + / "model_{:02d}__{:.4f}.pth".format(epoch, current_metric) + ) torch.save(self.run.network, str(checkpoint_path)) if self.verbose: - print('Checkpointed {}'.format(checkpoint_path)) + print("Checkpointed {}".format(checkpoint_path)) class ProgressBarCallback(Callback): @@ -433,36 +509,42 @@ class ProgressBarCallback(Callback): Where to output the progress bar. """ - def __init__(self, output='stderr', n_decimals=3): + def __init__(self, output="stderr", n_decimals=3): self.bar = None self.metrics = {} self.n_decimals = n_decimals - if output == 'stderr': + if output == "stderr": self.output = sys.stderr - elif output == 'stdout': + elif output == "stdout": self.output = sys.stdout else: - raise ValueError('Unrecognized output {}'.format(output)) + raise ValueError("Unrecognized output {}".format(output)) self.run = None def on_epoch_begin(self, metadata): """Initialize tqdm bar and metric lists.""" - self.bar = tqdm.tqdm(total=len(self.run.train_dataloader), - leave=True, - desc='Epoch {}'.format(metadata['epoch']), - file=self.output) + self.bar = tqdm.tqdm( + total=len(self.run.train_dataloader), + leave=True, + desc="Epoch {}".format(metadata["epoch"]), + file=self.output, + ) self.metrics = {metric: [] for metric in self.run.metrics.keys()} def on_epoch_end(self, metadata): """Update finished progress bar with latest epoch metrics.""" - epoch = metadata.get('epoch') + epoch = metadata.get("epoch") try: df = self.run.history.metrics_per_epoch(epoch) - additional_metrics = {'_'.join(list(map(lambda x: str(x), k))): v for k, v in - df.groupby(['dataloader', 'metric'])['value'].mean().items()} + additional_metrics = { + "_".join(list(map(lambda x: str(x), k))): v + for k, v in df.groupby(["dataloader", "metric"])["value"] + .mean() + .items() + } except KeyError: # no val_dataloaders @@ -480,8 +562,8 @@ def on_epoch_end(self, metadata): def on_batch_end(self, metadata): """Update progress bar with batch metrics.""" - weights = metadata.get('weights') - y_batch = metadata.get('y_batch') + weights = metadata.get("weights") + y_batch = metadata.get("y_batch") for metric, cal in self.run.metrics.items(): self.metrics[metric].append(cal(weights, y_batch).mean().item()) @@ -544,7 +626,11 @@ class TensorBoardCallback(Callback): """ def __init__(self, log_dir=None, ts=None, log_benchmarks=False): - self.log_dir = pathlib.Path(log_dir) if log_dir is not None else pathlib.Path.cwd() + self.log_dir = ( + pathlib.Path(log_dir) + if log_dir is not None + else pathlib.Path.cwd() + ) self.writer = SummaryWriter(self.log_dir) self.counter = 0 self.ts = ts @@ -558,28 +644,38 @@ def __init__(self, log_dir=None, ts=None, log_benchmarks=False): def on_train_begin(self, metadata): """Log benchmarks performance.""" - n_epochs = metadata.get('n_epochs') + n_epochs = metadata.get("n_epochs") if self.log_benchmarks: try: df = self.run.history.metrics_per_epoch(-1) # only benchmarks - for bm_name in df['model'].unique(): - temp_df = df[df['model'] == bm_name] - metrics = {'/'.join(list(map(lambda x: str(x), k))): v for k, v in - temp_df.groupby(['dataloader', 'metric', 'lookback'])['value'].mean().items()} + for bm_name in df["model"].unique(): + temp_df = df[df["model"] == bm_name] + metrics = { + "/".join(list(map(lambda x: str(x), k))): v + for k, v in temp_df.groupby( + ["dataloader", "metric", "lookback"] + )["value"] + .mean() + .items() + } bm_writer = SummaryWriter(self.log_dir / bm_name) for metric_name, metric_value in metrics.items(): for global_step in range(n_epochs): - bm_writer.add_scalar(metric_name, metric_value, global_step=global_step) + bm_writer.add_scalar( + metric_name, + metric_value, + global_step=global_step, + ) except KeyError: return def on_batch_begin(self, metadata): """Set up forward hooks.""" - timestamps = metadata.get('timestamps') + timestamps = metadata.get("timestamps") if self.ts is not None and self.ts not in timestamps: return @@ -592,61 +688,87 @@ def hook(model, inp, out): def on_batch_end(self, metadata): """Log activations.""" - timestamps = metadata.get('timestamps') - weights = metadata.get('weights') + timestamps = metadata.get("timestamps") + weights = metadata.get("weights") # cache weights - self.weights.append(pd.DataFrame(weights.detach().cpu().numpy(), index=timestamps)) + self.weights.append( + pd.DataFrame(weights.detach().cpu().numpy(), index=timestamps) + ) # add activations self._add_activations(metadata) def on_epoch_end(self, metadata): """Log images, metrics and hyperparamters.""" - epoch = metadata.get('epoch') + epoch = metadata.get("epoch") # create weight image master_df = pd.concat(self.weights).sort_index() - self.writer.add_image('weights', master_df.values[np.newaxis, ...], global_step=metadata['epoch']) + self.writer.add_image( + "weights", + master_df.values[np.newaxis, ...], + global_step=metadata["epoch"], + ) self.weights = [] # log scalars try: df = self.run.history.metrics_per_epoch(epoch) - metrics = {'/'.join(list(map(lambda x: str(x), k))): v for k, v in - df.groupby(['dataloader', 'metric', 'lookback'])['value'].mean().items()} + metrics = { + "/".join(list(map(lambda x: str(x), k))): v + for k, v in df.groupby(["dataloader", "metric", "lookback"])[ + "value" + ] + .mean() + .items() + } for metric_name, metric_value in metrics.items(): - self.writer.add_scalar(metric_name, metric_value, global_step=epoch) + self.writer.add_scalar( + metric_name, metric_value, global_step=epoch + ) except KeyError: pass def _add_activations(self, metadata): """Add activations.""" - X_batch = metadata.get('X_batch') - timestamps = metadata.get('timestamps') + X_batch = metadata.get("X_batch") + timestamps = metadata.get("timestamps") if self.ts is not None and self.ts not in timestamps: return - ix = timestamps.index(self.ts) if self.ts is not None else list(range(len(X_batch))) - self.writer.add_histogram(tag='inputs', values=X_batch[ix], global_step=self.counter) + ix = ( + timestamps.index(self.ts) + if self.ts is not None + else list(range(len(X_batch))) + ) + self.writer.add_histogram( + tag="inputs", values=X_batch[ix], global_step=self.counter + ) for s, io in self.activations.items(): for i, x in enumerate(io): if torch.is_tensor(x): - self.writer.add_histogram(s.__class__.__name__ + "_{}".format('inp' if i == 0 else 'out'), - x[ix], - global_step=self.counter) + self.writer.add_histogram( + s.__class__.__name__ + + "_{}".format("inp" if i == 0 else "out"), + x[ix], + global_step=self.counter, + ) else: for j, y in enumerate(x): if y is None: continue # pragma: no cover - self.writer.add_histogram(s.__class__.__name__ + "_{}_{}".format('inp' if i == 0 else 'out', j), - y[ix], - global_step=self.counter) + self.writer.add_histogram( + s.__class__.__name__ + + "_{}_{}".format("inp" if i == 0 else "out", j), + y[ix], + global_step=self.counter, + ) for handle in self.handles: handle.remove() @@ -684,16 +806,25 @@ def __init__(self, freq=1, lookbacks=None): def on_epoch_end(self, metadata): """Compute metrics and log them into the history object.""" - epoch = metadata.get('epoch') + epoch = metadata.get("epoch") model = self.run.network model.eval() if epoch % self.freq == 0: with torch.no_grad(): for dl_name, dl in self.run.val_dataloaders.items(): - for batch, (X_batch, y_batch, timestamps_batch, _) in enumerate(dl): - X_batch = X_batch.to(self.run.device).to(self.run.dtype) - y_batch = y_batch.to(self.run.device).to(self.run.dtype) + for batch, ( + X_batch, + y_batch, + timestamps_batch, + _, + ) in enumerate(dl): + X_batch = X_batch.to(self.run.device).to( + self.run.dtype + ) + y_batch = y_batch.to(self.run.device).to( + self.run.dtype + ) lookbacks = [] if self.lookbacks is None: @@ -704,15 +835,27 @@ def on_epoch_end(self, metadata): for lb in lookbacks: weights = model(X_batch[:, :, -lb:, :]) - for metric_name, metric in self.run.metrics.items(): - metric_per_s = metric(weights, y_batch).detach().cpu().numpy() - - for metric_value, ts in zip(metric_per_s, timestamps_batch): - self.run.history.add_entry(timestamp=ts, - model='network', - epoch=epoch, - batch=batch, - lookback=lb, - dataloader=dl_name, - metric=metric_name, - value=metric_value) + for ( + metric_name, + metric, + ) in self.run.metrics.items(): + metric_per_s = ( + metric(weights, y_batch) + .detach() + .cpu() + .numpy() + ) + + for metric_value, ts in zip( + metric_per_s, timestamps_batch + ): + self.run.history.add_entry( + timestamp=ts, + model="network", + epoch=epoch, + batch=batch, + lookback=lb, + dataloader=dl_name, + metric=metric_name, + value=metric_value, + ) diff --git a/deepdow/data/__init__.py b/deepdow/data/__init__.py index bea0f5e..256a193 100644 --- a/deepdow/data/__init__.py +++ b/deepdow/data/__init__.py @@ -1,16 +1,25 @@ """Module dealing with data.""" -from .augment import (Compose, Dropout, Multiply, Noise, Scale, prepare_robust_scaler, - prepare_standard_scaler) -from .load import (FlexibleDataLoader, InRAMDataset, RigidDataLoader) +from .augment import ( + Compose, + Dropout, + Multiply, + Noise, + Scale, + prepare_robust_scaler, + prepare_standard_scaler, +) +from .load import FlexibleDataLoader, InRAMDataset, RigidDataLoader -__all__ = ['Compose', - 'Dropout', - 'FlexibleDataLoader', - 'InRAMDataset', - 'Multiply', - 'Noise', - 'RigidDataLoader', - 'Scale', - 'prepare_robust_scaler', - 'prepare_standard_scaler'] +__all__ = [ + "Compose", + "Dropout", + "FlexibleDataLoader", + "InRAMDataset", + "Multiply", + "Noise", + "RigidDataLoader", + "Scale", + "prepare_robust_scaler", + "prepare_standard_scaler", +] diff --git a/deepdow/data/augment.py b/deepdow/data/augment.py index b6f81fc..2bf6005 100644 --- a/deepdow/data/augment.py +++ b/deepdow/data/augment.py @@ -38,7 +38,9 @@ def prepare_standard_scaler(X, overlap=False, indices=None): return means, stds -def prepare_robust_scaler(X, overlap=False, indices=None, percentile_range=(25, 75)): +def prepare_robust_scaler( + X, overlap=False, indices=None, percentile_range=(25, 75) +): """Compute median and percentile range for each channel. Parameters @@ -67,13 +69,17 @@ def prepare_robust_scaler(X, overlap=False, indices=None, percentile_range=(25, """ if not 0 <= percentile_range[0] < percentile_range[1] <= 100: - raise ValueError('The percentile range needs to be in [0, 100] and left < right') + raise ValueError( + "The percentile range needs to be in [0, 100] and left < right" + ) indices = indices if indices is not None else list(range(len(X))) considered_values = X[indices, ...] if overlap else X[indices, :, -1:, :] medians = np.median(considered_values, axis=(0, 2, 3)) - percentiles = np.percentile(considered_values, percentile_range, axis=(0, 2, 3)) # (2, n_channels) + percentiles = np.percentile( + considered_values, percentile_range, axis=(0, 2, 3) + ) # (2, n_channels) ranges = percentiles[1] - percentiles[0] @@ -125,7 +131,9 @@ def __call__(self, X_sample, y_sample, timestamps_sample, asset_names): Transformed version of `asset_names`. """ for t in self.transforms: - X_sample, y_sample, timestamps_sample, asset_names = t(X_sample, y_sample, timestamps_sample, asset_names) + X_sample, y_sample, timestamps_sample, asset_names = t( + X_sample, y_sample, timestamps_sample, asset_names + ) return X_sample, y_sample, timestamps_sample, asset_names @@ -178,7 +186,9 @@ def __call__(self, X_sample, y_sample, timestamps_sample, asset_names): asset_names Same as input. """ - X_sample_new = torch.nn.functional.dropout(X_sample, p=self.p, training=self.training) + X_sample_new = torch.nn.functional.dropout( + X_sample, p=self.p, training=self.training + ) return X_sample_new, y_sample, timestamps_sample, asset_names @@ -266,7 +276,12 @@ def __call__(self, X_sample, y_sample, timestamps_sample, asset_names): asset_names Same as input. """ - X_sample_new = self.frac * X_sample.std([1, 2], keepdim=True) * torch.randn_like(X_sample) + X_sample + X_sample_new = ( + self.frac + * X_sample.std([1, 2], keepdim=True) + * torch.randn_like(X_sample) + + X_sample + ) return X_sample_new, y_sample, timestamps_sample, asset_names @@ -295,10 +310,12 @@ class Scale: def __init__(self, center, scale): if len(center) != len(scale): - raise ValueError('The center and scale need to have the same size.') + raise ValueError( + "The center and scale need to have the same size." + ) if np.any(scale <= 0): - raise ValueError('The scale parameters need to be positive.') + raise ValueError("The scale parameters need to be positive.") self.center = center self.scale = scale @@ -337,13 +354,21 @@ def __call__(self, X_sample, y_sample, timestamps_sample, asset_names): """ n_channels = X_sample.shape[0] if n_channels != self.n_channels: - raise ValueError('Expected {} channels in X, got {}'.format(self.n_channels, n_channels)) + raise ValueError( + "Expected {} channels in X, got {}".format( + self.n_channels, n_channels + ) + ) X_sample_new = X_sample.clone() dtype, device = X_sample_new.dtype, X_sample_new.device - center = torch.as_tensor(self.center, dtype=dtype, device=device)[:, None, None] - scale = torch.as_tensor(self.scale, dtype=dtype, device=device)[:, None, None] + center = torch.as_tensor(self.center, dtype=dtype, device=device)[ + :, None, None + ] + scale = torch.as_tensor(self.scale, dtype=dtype, device=device)[ + :, None, None + ] X_sample_new.sub_(center).div_(scale) diff --git a/deepdow/data/load.py b/deepdow/data/load.py index 3405755..1aa5bd7 100644 --- a/deepdow/data/load.py +++ b/deepdow/data/load.py @@ -25,22 +25,34 @@ class InRAMDataset(torch.utils.data.Dataset): If provided, then a callable that transforms a single sample. """ - def __init__(self, X, y, timestamps=None, asset_names=None, transform=None): + def __init__( + self, X, y, timestamps=None, asset_names=None, transform=None + ): """Construct.""" # checks if len(X) != len(y): - raise ValueError('X and y need to have the same number of samples.') + raise ValueError( + "X and y need to have the same number of samples." + ) if X.shape[1] != y.shape[1]: - raise ValueError('X and y need to have the same number of input channels.') + raise ValueError( + "X and y need to have the same number of input channels." + ) if X.shape[-1] != y.shape[-1]: - raise ValueError('X and y need to have the same number of assets.') + raise ValueError("X and y need to have the same number of assets.") self.X = X self.y = y - self.timestamps = list(range(len(X))) if timestamps is None else timestamps - self.asset_names = ['a_{}'.format(i) for i in range(X.shape[-1])] if asset_names is None else asset_names + self.timestamps = ( + list(range(len(X))) if timestamps is None else timestamps + ) + self.asset_names = ( + ["a_{}".format(i) for i in range(X.shape[-1])] + if asset_names is None + else asset_names + ) self.transform = transform # utility @@ -59,16 +71,26 @@ def __getitem__(self, ix): asset_names = self.asset_names if self.transform: - X_sample, y_sample, timestamps_sample, asset_names = self.transform(X_sample, - y_sample, - timestamps_sample, - asset_names) + ( + X_sample, + y_sample, + timestamps_sample, + asset_names, + ) = self.transform( + X_sample, y_sample, timestamps_sample, asset_names + ) return X_sample, y_sample, timestamps_sample, asset_names -def collate_uniform(batch, n_assets_range=(5, 10), lookback_range=(2, 20), horizon_range=(3, 15), asset_ixs=None, - random_state=None): +def collate_uniform( + batch, + n_assets_range=(5, 10), + lookback_range=(2, 20), + horizon_range=(3, 15), + asset_ixs=None, + random_state=None, +): """Create batch of samples. Randomly (from uniform distribution) selects assets, lookback and horizon. If `assets` are specified then assets @@ -113,13 +135,13 @@ def collate_uniform(batch, n_assets_range=(5, 10), lookback_range=(2, 20), horiz """ # checks if asset_ixs is None and not n_assets_range[1] > n_assets_range[0] >= 1: - raise ValueError('Incorrect number of assets range.') + raise ValueError("Incorrect number of assets range.") if not lookback_range[1] > lookback_range[0] >= 2: - raise ValueError('Incorrect lookback range.') + raise ValueError("Incorrect lookback range.") if not horizon_range[1] > horizon_range[0] >= 1: - raise ValueError('Incorrect horizon range.') + raise ValueError("Incorrect horizon range.") if random_state is not None: torch.manual_seed(random_state) @@ -129,18 +151,34 @@ def collate_uniform(batch, n_assets_range=(5, 10), lookback_range=(2, 20), horiz # sample assets if asset_ixs is None: - n_assets = torch.randint(low=n_assets_range[0], high=min(n_assets_max + 1, n_assets_range[1]), size=(1,))[0] - asset_ixs = torch.multinomial(torch.ones(n_assets_max), n_assets.item(), replacement=False) + n_assets = torch.randint( + low=n_assets_range[0], + high=min(n_assets_max + 1, n_assets_range[1]), + size=(1,), + )[0] + asset_ixs = torch.multinomial( + torch.ones(n_assets_max), n_assets.item(), replacement=False + ) else: pass # sample lookback - lookback = torch.randint(low=lookback_range[0], high=min(lookback_max + 1, lookback_range[1]), size=(1,))[0] + lookback = torch.randint( + low=lookback_range[0], + high=min(lookback_max + 1, lookback_range[1]), + size=(1,), + )[0] # sample horizon - horizon = torch.randint(low=horizon_range[0], high=min(horizon_max + 1, horizon_range[1]), size=(1,))[0] - - X_batch = torch.stack([b[0][:, -lookback:, asset_ixs] for b in batch], dim=0) + horizon = torch.randint( + low=horizon_range[0], + high=min(horizon_max + 1, horizon_range[1]), + size=(1,), + )[0] + + X_batch = torch.stack( + [b[0][:, -lookback:, asset_ixs] for b in batch], dim=0 + ) y_batch = torch.stack([b[1][:, :horizon, asset_ixs] for b in batch], dim=0) timestamps_batch = [b[2] for b in batch] asset_names_batch = [batch[0][3][ix] for ix in asset_ixs] @@ -189,56 +227,93 @@ class FlexibleDataLoader(torch.utils.data.DataLoader): """ - def __init__(self, dataset, indices=None, n_assets_range=None, lookback_range=None, horizon_range=None, - asset_ixs=None, batch_size=1, drop_last=False, **kwargs): + def __init__( + self, + dataset, + indices=None, + n_assets_range=None, + lookback_range=None, + horizon_range=None, + asset_ixs=None, + batch_size=1, + drop_last=False, + **kwargs + ): if n_assets_range is not None and asset_ixs is not None: - raise ValueError('One cannot specify both n_assets_range and asset_ixs') + raise ValueError( + "One cannot specify both n_assets_range and asset_ixs" + ) # checks - if n_assets_range is not None and not (2 <= n_assets_range[0] <= n_assets_range[1] <= dataset.n_assets + 1): - raise ValueError('Invalid n_assets_range.') - - if lookback_range is not None and not (2 <= lookback_range[0] <= lookback_range[1] <= dataset.lookback + 1): - raise ValueError('Invalid lookback_range.') - - if horizon_range is not None and not (1 <= horizon_range[0] <= horizon_range[1] <= dataset.horizon + 1): - raise ValueError('Invalid horizon_range.') - - if indices is not None and not (0 <= min(indices) <= max(indices) <= len(dataset) - 1): - raise ValueError('The indices our outside of the range of the dataset.') + if n_assets_range is not None and not ( + 2 <= n_assets_range[0] <= n_assets_range[1] <= dataset.n_assets + 1 + ): + raise ValueError("Invalid n_assets_range.") + + if lookback_range is not None and not ( + 2 <= lookback_range[0] <= lookback_range[1] <= dataset.lookback + 1 + ): + raise ValueError("Invalid lookback_range.") + + if horizon_range is not None and not ( + 1 <= horizon_range[0] <= horizon_range[1] <= dataset.horizon + 1 + ): + raise ValueError("Invalid horizon_range.") + + if indices is not None and not ( + 0 <= min(indices) <= max(indices) <= len(dataset) - 1 + ): + raise ValueError( + "The indices our outside of the range of the dataset." + ) self.dataset = dataset - self.indices = indices if indices is not None else list(range(len(dataset))) + self.indices = ( + indices if indices is not None else list(range(len(dataset))) + ) self.n_assets_range = n_assets_range - self.lookback_range = lookback_range if lookback_range is not None else (2, dataset.lookback + 1) - self.horizon_range = horizon_range if horizon_range is not None else (2, dataset.horizon + 1) + self.lookback_range = ( + lookback_range + if lookback_range is not None + else (2, dataset.lookback + 1) + ) + self.horizon_range = ( + horizon_range + if horizon_range is not None + else (2, dataset.horizon + 1) + ) if n_assets_range is None and asset_ixs is None: self.asset_ixs = list(range(len(dataset.asset_names))) else: self.asset_ixs = asset_ixs - super().__init__(dataset, - collate_fn=partial(collate_uniform, - n_assets_range=self.n_assets_range, - lookback_range=self.lookback_range, - horizon_range=self.horizon_range, - asset_ixs=self.asset_ixs), - sampler=torch.utils.data.SubsetRandomSampler(self.indices), - batch_sampler=None, - shuffle=False, - drop_last=drop_last, - batch_size=batch_size, - **kwargs) + super().__init__( + dataset, + collate_fn=partial( + collate_uniform, + n_assets_range=self.n_assets_range, + lookback_range=self.lookback_range, + horizon_range=self.horizon_range, + asset_ixs=self.asset_ixs, + ), + sampler=torch.utils.data.SubsetRandomSampler(self.indices), + batch_sampler=None, + shuffle=False, + drop_last=drop_last, + batch_size=batch_size, + **kwargs + ) @property def hparams(self): """Generate dictionary of relevant parameters.""" return { - 'lookback_range': str(self.lookback_range), - 'horizon_range': str(self.horizon_range), - 'batch_size': self.batch_size} + "lookback_range": str(self.lookback_range), + "horizon_range": str(self.horizon_range), + "batch_size": self.batch_size, + } class RigidDataLoader(torch.utils.data.DataLoader): @@ -273,43 +348,70 @@ class RigidDataLoader(torch.utils.data.DataLoader): """ - def __init__(self, dataset, asset_ixs=None, indices=None, lookback=None, horizon=None, - drop_last=False, batch_size=1, **kwargs): - - if asset_ixs is not None and not (0 <= min(asset_ixs) <= max(asset_ixs) <= dataset.n_assets - 1): - raise ValueError('Invalid asset_ixs.') + def __init__( + self, + dataset, + asset_ixs=None, + indices=None, + lookback=None, + horizon=None, + drop_last=False, + batch_size=1, + **kwargs + ): + + if asset_ixs is not None and not ( + 0 <= min(asset_ixs) <= max(asset_ixs) <= dataset.n_assets - 1 + ): + raise ValueError("Invalid asset_ixs.") if lookback is not None and not (2 <= lookback <= dataset.lookback): - raise ValueError('Invalid lookback_range.') + raise ValueError("Invalid lookback_range.") if horizon is not None and not (1 <= horizon <= dataset.horizon): - raise ValueError('Invalid horizon_range.') + raise ValueError("Invalid horizon_range.") - if indices is not None and not (0 <= min(indices) <= max(indices) <= len(dataset) - 1): - raise ValueError('The indices our outside of the range of the dataset.') + if indices is not None and not ( + 0 <= min(indices) <= max(indices) <= len(dataset) - 1 + ): + raise ValueError( + "The indices our outside of the range of the dataset." + ) self.dataset = dataset - self.indices = indices if indices is not None else list(range(len(dataset))) + self.indices = ( + indices if indices is not None else list(range(len(dataset))) + ) self.lookback = lookback if lookback is not None else dataset.lookback self.horizon = horizon if horizon is not None else dataset.horizon - self.asset_ixs = asset_ixs if asset_ixs is not None else list(range(len(dataset.asset_names))) - - super().__init__(self.dataset, - collate_fn=partial(collate_uniform, - n_assets_range=None, - lookback_range=(self.lookback, self.lookback + 1), - horizon_range=(self.horizon, self.horizon + 1), - asset_ixs=self.asset_ixs), - sampler=torch.utils.data.SubsetRandomSampler(self.indices), - batch_sampler=None, - shuffle=False, - drop_last=drop_last, - batch_size=batch_size, - **kwargs) + self.asset_ixs = ( + asset_ixs + if asset_ixs is not None + else list(range(len(dataset.asset_names))) + ) + + super().__init__( + self.dataset, + collate_fn=partial( + collate_uniform, + n_assets_range=None, + lookback_range=(self.lookback, self.lookback + 1), + horizon_range=(self.horizon, self.horizon + 1), + asset_ixs=self.asset_ixs, + ), + sampler=torch.utils.data.SubsetRandomSampler(self.indices), + batch_sampler=None, + shuffle=False, + drop_last=drop_last, + batch_size=batch_size, + **kwargs + ) @property def hparams(self): """Generate dictionary of relevant parameters.""" - return {'lookback': self.lookback, - 'horizon': self.horizon, - 'batch_size': self.batch_size} + return { + "lookback": self.lookback, + "horizon": self.horizon, + "batch_size": self.batch_size, + } diff --git a/deepdow/experiments.py b/deepdow/experiments.py index bd6eea3..642867b 100644 --- a/deepdow/experiments.py +++ b/deepdow/experiments.py @@ -9,7 +9,12 @@ import torch from .benchmarks import Benchmark -from .callbacks import BenchmarkCallback, EarlyStoppingException, ProgressBarCallback, ValidationCallback +from .callbacks import ( + BenchmarkCallback, + EarlyStoppingException, + ProgressBarCallback, + ValidationCallback, +) from .data import FlexibleDataLoader, RigidDataLoader from .losses import Loss @@ -54,21 +59,34 @@ def metrics_per_epoch(self, epoch): """ return pd.DataFrame(self.database[epoch]) - def add_entry(self, model=None, metric=None, batch=None, epoch=None, dataloader=None, - lookback=None, timestamp=None, value=np.nan): + def add_entry( + self, + model=None, + metric=None, + batch=None, + epoch=None, + dataloader=None, + lookback=None, + timestamp=None, + value=np.nan, + ): """Add entry to the internal database.""" if epoch not in self.database: self.database[epoch] = [] - self.database[epoch].append({'model': model, - 'metric': metric, - 'value': value, - 'batch': batch, - 'epoch': epoch, - 'dataloader': dataloader, - 'lookback': lookback, - 'timestamp': timestamp, - 'current_time': datetime.datetime.now()}) + self.database[epoch].append( + { + "model": model, + "metric": metric, + "value": value, + "batch": batch, + "epoch": epoch, + "dataloader": dataloader, + "lookback": lookback, + "timestamp": timestamp, + "current_time": datetime.datetime.now(), + } + ) def pretty_print(self, epoch=None): """Print nicely the internal database. @@ -84,8 +102,12 @@ def pretty_print(self, epoch=None): else: df = self.metrics_per_epoch(epoch) - pd.options.display.float_format = '{:,.3f}'.format - print(df.groupby(['model', 'metric', 'epoch', 'dataloader'])['value'].mean().to_string()) + pd.options.display.float_format = "{:,.3f}".format + print( + df.groupby(["model", "metric", "epoch", "dataloader"])["value"] + .mean() + .to_string() + ) class Run: @@ -148,40 +170,63 @@ class Run: """ - def __init__(self, network, loss, train_dataloader, val_dataloaders=None, metrics=None, - benchmarks=None, device=None, dtype=None, optimizer=None, callbacks=None): + def __init__( + self, + network, + loss, + train_dataloader, + val_dataloaders=None, + metrics=None, + benchmarks=None, + device=None, + dtype=None, + optimizer=None, + callbacks=None, + ): # checks - if not isinstance(train_dataloader, (FlexibleDataLoader, RigidDataLoader)): - raise TypeError('The train_dataloader needs to be an instance of RigidDataLoader or FlexibleDataLoadeer.') + if not isinstance( + train_dataloader, (FlexibleDataLoader, RigidDataLoader) + ): + raise TypeError( + "The train_dataloader needs to be an instance of RigidDataLoader or FlexibleDataLoadeer." + ) if not isinstance(loss, Loss): - raise TypeError('The loss needs to be an instance of Loss.') + raise TypeError("The loss needs to be an instance of Loss.") - if not (isinstance(network, torch.nn.Module) and isinstance(network, Benchmark)): - raise TypeError('The network needs to be a torch.nn.Module and Benchmark. ') + if not ( + isinstance(network, torch.nn.Module) + and isinstance(network, Benchmark) + ): + raise TypeError( + "The network needs to be a torch.nn.Module and Benchmark. " + ) self.network = network self.loss = loss self.train_dataloader = train_dataloader # metrics - self.metrics = { - 'loss': loss} + self.metrics = {"loss": loss} if metrics is None: pass elif isinstance(metrics, dict): if not all([isinstance(x, Loss) for x in metrics.values()]): - raise TypeError('All values of metrics need to be Loss.') + raise TypeError("All values of metrics need to be Loss.") - if 'loss' in metrics: - raise ValueError("Cannot name a metric 'loss' - restricted for the actual loss.") + if "loss" in metrics: + raise ValueError( + "Cannot name a metric 'loss' - restricted for the actual loss." + ) self.metrics.update(metrics) else: - raise TypeError('Invalid type of metrics: {}'.format(type(metrics))) + raise TypeError( + "Invalid type of metrics: {}".format(type(metrics)) + ) # metrics_dataloaders self.val_dataloaders = {} @@ -190,32 +235,55 @@ def __init__(self, network, loss, train_dataloader, val_dataloaders=None, metric pass elif isinstance(val_dataloaders, dict): - if not all([isinstance(x, RigidDataLoader) for x in val_dataloaders.values()]): - raise TypeError('All values of val_dataloaders need to be RigidDataLoader.') + if not all( + [ + isinstance(x, RigidDataLoader) + for x in val_dataloaders.values() + ] + ): + raise TypeError( + "All values of val_dataloaders need to be RigidDataLoader." + ) self.val_dataloaders.update(val_dataloaders) else: - raise TypeError('Invalid type of val_dataloaders: {}'.format(type(val_dataloaders))) + raise TypeError( + "Invalid type of val_dataloaders: {}".format( + type(val_dataloaders) + ) + ) # benchmarks - self.models = {'main': network} + self.models = {"main": network} if benchmarks is None: pass elif isinstance(benchmarks, dict): - if not all([isinstance(x, Benchmark) for x in benchmarks.values()]): - raise TypeError('All values of benchmarks need to be a Benchmark.') - - if 'main' in benchmarks: - raise ValueError("Cannot name a benchmark 'main' - restricted for the main network.") + if not all( + [isinstance(x, Benchmark) for x in benchmarks.values()] + ): + raise TypeError( + "All values of benchmarks need to be a Benchmark." + ) + + if "main" in benchmarks: + raise ValueError( + "Cannot name a benchmark 'main' - restricted for the main network." + ) self.models.update(benchmarks) else: - raise TypeError('Invalid type of benchmarks: {}'.format(type(benchmarks))) - - self.callbacks = [BenchmarkCallback(), ValidationCallback(), ProgressBarCallback()] + (callbacks or []) + raise TypeError( + "Invalid type of benchmarks: {}".format(type(benchmarks)) + ) + + self.callbacks = [ + BenchmarkCallback(), + ValidationCallback(), + ProgressBarCallback(), + ] + (callbacks or []) # Inject self into callbacks for cb in self.callbacks: cb.run = self @@ -223,9 +291,13 @@ def __init__(self, network, loss, train_dataloader, val_dataloaders=None, metric self.history = History() self.loss = loss - self.device = device or torch.device('cpu') + self.device = device or torch.device("cpu") self.dtype = dtype or torch.float - self.optimizer = torch.optim.Adam(self.network.parameters(), lr=1e-2) if optimizer is None else optimizer + self.optimizer = ( + torch.optim.Adam(self.network.parameters(), lr=1e-2) + if optimizer is None + else optimizer + ) self.current_epoch = -1 def launch(self, n_epochs=1): @@ -240,24 +312,35 @@ def launch(self, n_epochs=1): self.network.to(device=self.device, dtype=self.dtype) # Train begin if self.current_epoch == -1: - self.on_train_begin(metadata={'n_epochs': n_epochs}) + self.on_train_begin(metadata={"n_epochs": n_epochs}) for _ in range(n_epochs): self.current_epoch += 1 # Epoch begin - self.on_epoch_begin(metadata={'epoch': self.current_epoch}) - - for batch_ix, (X_batch, y_batch, timestamps, asset_names) in enumerate(self.train_dataloader): + self.on_epoch_begin(metadata={"epoch": self.current_epoch}) + + for batch_ix, ( + X_batch, + y_batch, + timestamps, + asset_names, + ) in enumerate(self.train_dataloader): # Batch begin - self.on_batch_begin(metadata={'asset_names': asset_names, - 'batch': batch_ix, - 'epoch': self.current_epoch, - 'timestamps': timestamps, - 'X_batch': X_batch, - 'y_batch': y_batch}) + self.on_batch_begin( + metadata={ + "asset_names": asset_names, + "batch": batch_ix, + "epoch": self.current_epoch, + "timestamps": timestamps, + "X_batch": X_batch, + "y_batch": y_batch, + } + ) # Get batch - X_batch, y_batch = X_batch.to(self.device).to(self.dtype), y_batch.to(self.device).to(self.dtype) + X_batch, y_batch = X_batch.to(self.device).to( + self.dtype + ), y_batch.to(self.device).to(self.dtype) # Make sure network on the right device and train mode self.network.train() @@ -274,28 +357,37 @@ def launch(self, n_epochs=1): self.network.eval() # Batch end - self.on_batch_end(metadata={'asset_names': asset_names, - 'batch': batch_ix, - 'batch_loss': loss.item(), - 'epoch': self.current_epoch, - 'timestamps': timestamps, - 'weights': weights, - 'X_batch': X_batch, - 'y_batch': y_batch}) + self.on_batch_end( + metadata={ + "asset_names": asset_names, + "batch": batch_ix, + "batch_loss": loss.item(), + "epoch": self.current_epoch, + "timestamps": timestamps, + "weights": weights, + "X_batch": X_batch, + "y_batch": y_batch, + } + ) # Epoch end - self.on_epoch_end(metadata={'epoch': self.current_epoch, - 'n_epochs': n_epochs}) + self.on_epoch_end( + metadata={ + "epoch": self.current_epoch, + "n_epochs": n_epochs, + } + ) # Train end self.on_train_end() except (EarlyStoppingException, KeyboardInterrupt, SolverError) as ex: - print('Training interrupted') + print("Training interrupted") time.sleep(1) - self.on_train_interrupt(metadata={'exception': ex, - 'locals': locals()}) + self.on_train_interrupt( + metadata={"exception": ex, "locals": locals()} + ) return self.history @@ -340,11 +432,16 @@ def hparams(self): res = {} res.update(self.network.hparams) res.update(self.train_dataloader.hparams) - res.update({'device': str(self.device), - 'dtype': str(self.dtype), - 'loss': str(self.loss), - 'weight_decay': self.optimizer.defaults.get('weight_decay', ''), - 'lr': self.optimizer.defaults.get('lr', '') - }) + res.update( + { + "device": str(self.device), + "dtype": str(self.dtype), + "loss": str(self.loss), + "weight_decay": self.optimizer.defaults.get( + "weight_decay", "" + ), + "lr": self.optimizer.defaults.get("lr", ""), + } + ) return res diff --git a/deepdow/explain.py b/deepdow/explain.py index 28e5663..fa5b42a 100644 --- a/deepdow/explain.py +++ b/deepdow/explain.py @@ -3,8 +3,17 @@ import torch -def gradient_wrt_input(model, target_weights, initial_guess, n_iter=100, mask=None, lr=1e-1, verbose=True, device=None, - dtype=None): +def gradient_wrt_input( + model, + target_weights, + initial_guess, + n_iter=100, + mask=None, + lr=1e-1, + verbose=True, + device=None, + dtype=None, +): """Find input tensor such that the model produces an allocation close to the target one. Parameters @@ -48,7 +57,7 @@ def gradient_wrt_input(model, target_weights, initial_guess, n_iter=100, mask=No hist : list List of losses per iteration. """ - device = device or torch.device('cpu') + device = device or torch.device("cpu") dtype = dtype or torch.float32 x = initial_guess.clone().to(device=device, dtype=dtype) @@ -59,9 +68,11 @@ def gradient_wrt_input(model, target_weights, initial_guess, n_iter=100, mask=No elif torch.is_tensor(mask): if mask.shape != x.shape: - raise ValueError('Inconsistent shape of the mask.') + raise ValueError("Inconsistent shape of the mask.") else: - raise TypeError('Incorrect type of the mask, either None or torch.Tensor.') + raise TypeError( + "Incorrect type of the mask, either None or torch.Tensor." + ) # casting mask = mask.to(dtype=torch.bool, device=device) @@ -74,10 +85,16 @@ def gradient_wrt_input(model, target_weights, initial_guess, n_iter=100, mask=No hist = [] for i in range(n_iter): if i % 50 == 0 and verbose: - msg = '{}-th iteration, loss: {:.4f}'.format(i, hist[-1]) if i != 0 else 'Starting optimization' + msg = ( + "{}-th iteration, loss: {:.4f}".format(i, hist[-1]) + if i != 0 + else "Starting optimization" + ) print(msg) - loss_per_asset = (model((x * mask)[None, ...])[0] - target_weights) ** 2 + loss_per_asset = ( + model((x * mask)[None, ...])[0] - target_weights + ) ** 2 loss = loss_per_asset.mean() hist.append(loss.item()) @@ -86,6 +103,6 @@ def gradient_wrt_input(model, target_weights, initial_guess, n_iter=100, mask=No optimizer.step() if verbose: - print('Optimization done, final loss: {:.4f}'.format(hist[-1])) + print("Optimization done, final loss: {:.4f}".format(hist[-1])) return x, hist diff --git a/deepdow/layers/__init__.py b/deepdow/layers/__init__.py index 9049e54..2fd2c7a 100644 --- a/deepdow/layers/__init__.py +++ b/deepdow/layers/__init__.py @@ -1,32 +1,47 @@ """Collection of layers.""" -from .collapse import (AttentionCollapse, AverageCollapse, ElementCollapse, ExponentialCollapse, - MaxCollapse, SumCollapse) -from .allocate import (AnalyticalMarkowitz, NCO, NumericalMarkowitz, - NumericalRiskBudgeting, Resample, SoftmaxAllocator, - SparsemaxAllocator, WeightNorm) +from .collapse import ( + AttentionCollapse, + AverageCollapse, + ElementCollapse, + ExponentialCollapse, + MaxCollapse, + SumCollapse, +) +from .allocate import ( + AnalyticalMarkowitz, + NCO, + NumericalMarkowitz, + NumericalRiskBudgeting, + Resample, + SoftmaxAllocator, + SparsemaxAllocator, + WeightNorm, +) from .misc import Cov2Corr, CovarianceMatrix, KMeans, MultiplyByConstant from .transform import Conv, RNN, Warp, Zoom -__all__ = ['AnalyticalMarkowitz', - 'AttentionCollapse', - 'AverageCollapse', - 'Conv', - 'Cov2Corr', - 'CovarianceMatrix', - 'ElementCollapse', - 'ExponentialCollapse', - 'KMeans', - 'MaxCollapse', - 'MultiplyByConstant', - 'NCO', - 'NumericalMarkowitz', - 'NumericalRiskBudgeting', - 'Resample', - 'RNN', - 'SoftmaxAllocator', - 'SparsemaxAllocator', - 'SumCollapse', - 'Warp', - 'WeightNorm', - 'Zoom'] +__all__ = [ + "AnalyticalMarkowitz", + "AttentionCollapse", + "AverageCollapse", + "Conv", + "Cov2Corr", + "CovarianceMatrix", + "ElementCollapse", + "ExponentialCollapse", + "KMeans", + "MaxCollapse", + "MultiplyByConstant", + "NCO", + "NumericalMarkowitz", + "NumericalRiskBudgeting", + "Resample", + "RNN", + "SoftmaxAllocator", + "SparsemaxAllocator", + "SumCollapse", + "Warp", + "WeightNorm", + "Zoom", +] diff --git a/deepdow/layers/allocate.py b/deepdow/layers/allocate.py index 0a38c8f..d4aaee0 100644 --- a/deepdow/layers/allocate.py +++ b/deepdow/layers/allocate.py @@ -41,7 +41,9 @@ def forward(self, covmat, rets=None): device = covmat.device dtype = covmat.dtype - ones = torch.ones(n_samples, n_assets, 1).to(device=device, dtype=dtype) + ones = torch.ones(n_samples, n_assets, 1).to( + device=device, dtype=dtype + ) if rets is not None: expected_returns = rets.view(n_samples, n_assets, 1) else: @@ -91,7 +93,9 @@ class NCO(nn.Module): """ - def __init__(self, n_clusters, n_init=10, init='random', random_state=None): + def __init__( + self, n_clusters, n_init=10, init="random", random_state=None + ): super().__init__() self.n_clusters = n_clusters self.n_init = n_init @@ -99,10 +103,12 @@ def __init__(self, n_clusters, n_init=10, init='random', random_state=None): self.random_state = random_state self.cov2corr_layer = Cov2Corr() - self.kmeans_layer = KMeans(n_clusters=self.n_clusters, - n_init=self.n_init, - init=self.init, - random_state=self.random_state) + self.kmeans_layer = KMeans( + n_clusters=self.n_clusters, + n_init=self.n_init, + init=self.init, + random_state=self.random_state, + ) self.analytical_markowitz_layer = AnalyticalMarkowitz() @@ -135,24 +141,52 @@ def forward(self, covmat, rets=None): corrmat = Cov2Corr()(covmat) - w_l = [] # we need to iterate over the sample dimension (currently no speedup) + w_l = ( + [] + ) # we need to iterate over the sample dimension (currently no speedup) for i in range(n_samples): cluster_ixs, cluster_centers = self.kmeans_layer(corrmat[i]) - w_intra_clusters = torch.zeros((n_assets, self.n_clusters), dtype=dtype, device=device) + w_intra_clusters = torch.zeros( + (n_assets, self.n_clusters), dtype=dtype, device=device + ) for c in range(self.n_clusters): - in_cluster = torch.where(cluster_ixs == c)[0] # indices from the same cluster - intra_covmat = covmat[[i]].index_select(1, in_cluster).index_select(2, in_cluster) # (1, ?, ?) - intra_rets = None if rets is None else rets[[i]].index_select(1, in_cluster) # (1, ?) - w_intra_clusters[in_cluster, c] = self.analytical_markowitz_layer(intra_covmat, intra_rets)[0] - - inter_covmat = w_intra_clusters.T @ (covmat[i] @ w_intra_clusters) # (n_clusters, n_clusters) - inter_rets = None if rets is None else (w_intra_clusters.T @ rets[i]).view(1, -1) # (1, n_clusters) - w_inter_clusters = self.analytical_markowitz_layer(inter_covmat.view(1, self.n_clusters, self.n_clusters), - inter_rets) # (1, n_clusters) - w_final = (w_intra_clusters * w_inter_clusters).sum(dim=1) # (n_assets,) + in_cluster = torch.where(cluster_ixs == c)[ + 0 + ] # indices from the same cluster + intra_covmat = ( + covmat[[i]] + .index_select(1, in_cluster) + .index_select(2, in_cluster) + ) # (1, ?, ?) + intra_rets = ( + None + if rets is None + else rets[[i]].index_select(1, in_cluster) + ) # (1, ?) + w_intra_clusters[ + in_cluster, c + ] = self.analytical_markowitz_layer(intra_covmat, intra_rets)[ + 0 + ] + + inter_covmat = w_intra_clusters.T @ ( + covmat[i] @ w_intra_clusters + ) # (n_clusters, n_clusters) + inter_rets = ( + None + if rets is None + else (w_intra_clusters.T @ rets[i]).view(1, -1) + ) # (1, n_clusters) + w_inter_clusters = self.analytical_markowitz_layer( + inter_covmat.view(1, self.n_clusters, self.n_clusters), + inter_rets, + ) # (1, n_clusters) + w_final = (w_intra_clusters * w_inter_clusters).sum( + dim=1 + ) # (n_assets,) w_l.append(w_final) @@ -192,15 +226,16 @@ def __init__(self, n_assets, max_weight=1): risk = cp.sum_squares(covmat_sqrt @ w) reg = alpha * (cp.norm(w) ** 2) - prob = cp.Problem(cp.Maximize(ret - risk - reg), - [cp.sum(w) == 1, - w >= 0, - w <= max_weight - ]) + prob = cp.Problem( + cp.Maximize(ret - risk - reg), + [cp.sum(w) == 1, w >= 0, w <= max_weight], + ) assert prob.is_dpp() - self.cvxpylayer = CvxpyLayer(prob, parameters=[rets, covmat_sqrt, alpha], variables=[w]) + self.cvxpylayer = CvxpyLayer( + prob, parameters=[rets, covmat_sqrt, alpha], variables=[w] + ) def forward(self, rets, covmat_sqrt, gamma_sqrt, alpha): """Perform forward pass. @@ -230,7 +265,9 @@ def forward(self, rets, covmat_sqrt, gamma_sqrt, alpha): """ n_samples, n_assets = rets.shape - gamma_sqrt_ = gamma_sqrt.repeat((1, n_assets * n_assets)).view(n_samples, n_assets, n_assets) + gamma_sqrt_ = gamma_sqrt.repeat((1, n_assets * n_assets)).view( + n_samples, n_assets, n_assets + ) alpha_abs = torch.abs(alpha) # it needs to be nonnegative return self.cvxpylayer(rets, gamma_sqrt_ * covmat_sqrt, alpha_abs)[0] @@ -273,11 +310,22 @@ class Resample(nn.Module): Available at SSRN 2658657 (2007) """ - def __init__(self, allocator, n_draws=None, n_portfolios=5, sqrt=False, random_state=None): + def __init__( + self, + allocator, + n_draws=None, + n_portfolios=5, + sqrt=False, + random_state=None, + ): super().__init__() - if not isinstance(allocator, (AnalyticalMarkowitz, NCO, NumericalMarkowitz)): - raise TypeError('Unsupported type of allocator: {}'.format(type(allocator))) + if not isinstance( + allocator, (AnalyticalMarkowitz, NCO, NumericalMarkowitz) + ): + raise TypeError( + "Unsupported type of allocator: {}".format(type(allocator)) + ) self.allocator = allocator self.sqrt = sqrt @@ -285,9 +333,11 @@ def __init__(self, allocator, n_draws=None, n_portfolios=5, sqrt=False, random_s self.n_portfolios = n_portfolios self.random_state = random_state - mapper = {'AnalyticalMarkowitz': False, - 'NCO': True, - 'NumericalMarkowitz': True} + mapper = { + "AnalyticalMarkowitz": False, + "NCO": True, + "NumericalMarkowitz": True, + } self.uses_sqrt = mapper[allocator.__class__.__name__] @@ -320,10 +370,16 @@ def forward(self, matrix, rets=None, **kwargs): n_samples, n_assets, _ = matrix.shape dtype, device = matrix.dtype, matrix.device - n_draws = self.n_draws or n_assets # make sure that if None then we have the same N=M + n_draws = ( + self.n_draws or n_assets + ) # make sure that if None then we have the same N=M covmat = matrix @ matrix if self.sqrt else matrix - dist_rets = torch.zeros(n_samples, n_assets, dtype=dtype, device=device) if rets is None else rets + dist_rets = ( + torch.zeros(n_samples, n_assets, dtype=dtype, device=device) + if rets is None + else rets + ) dist = MultivariateNormal(loc=dist_rets, covariance_matrix=covmat) @@ -331,20 +387,26 @@ def forward(self, matrix, rets=None, **kwargs): for _ in range(self.n_portfolios): draws = dist.rsample((n_draws,)) # (n_draws, n_samples, n_assets) - rets_ = draws.mean(dim=0) if rets is not None else None # (n_samples, n_assets) - covmat_ = CovarianceMatrix(sqrt=self.uses_sqrt)(draws.permute(1, 0, 2)) # (n_samples, n_assets, ...) + rets_ = ( + draws.mean(dim=0) if rets is not None else None + ) # (n_samples, n_assets) + covmat_ = CovarianceMatrix(sqrt=self.uses_sqrt)( + draws.permute(1, 0, 2) + ) # (n_samples, n_assets, ...) if isinstance(self.allocator, (AnalyticalMarkowitz, NCO)): portfolio = self.allocator(covmat=covmat_, rets=rets_) elif isinstance(self.allocator, NumericalMarkowitz): - gamma = kwargs['gamma'] - alpha = kwargs['alpha'] + gamma = kwargs["gamma"] + alpha = kwargs["alpha"] portfolio = self.allocator(rets_, covmat_, gamma, alpha) portfolios.append(portfolio) - portfolios_t = torch.stack(portfolios, dim=0) # (n_portfolios, n_samples, n_assets) + portfolios_t = torch.stack( + portfolios, dim=0 + ) # (n_portfolios, n_samples, n_assets) return portfolios_t.mean(dim=0) @@ -371,33 +433,44 @@ class SoftmaxAllocator(torch.nn.Module): """ - def __init__(self, temperature=1, formulation='analytical', n_assets=None, max_weight=1): + def __init__( + self, + temperature=1, + formulation="analytical", + n_assets=None, + max_weight=1, + ): super().__init__() self.temperature = temperature - if formulation not in {'analytical', 'variational'}: - raise ValueError('Unrecognized formulation {}'.format(formulation)) + if formulation not in {"analytical", "variational"}: + raise ValueError("Unrecognized formulation {}".format(formulation)) - if formulation == 'variational' and n_assets is None: - raise ValueError('One needs to provide n_assets for the variational formulation.') + if formulation == "variational" and n_assets is None: + raise ValueError( + "One needs to provide n_assets for the variational formulation." + ) - if formulation == 'analytical' and max_weight != 1: - raise ValueError('Cannot constraint weights via max_weight for analytical formulation') + if formulation == "analytical" and max_weight != 1: + raise ValueError( + "Cannot constraint weights via max_weight for analytical formulation" + ) - if formulation == 'variational' and n_assets * max_weight < 1: - raise ValueError('One cannot create fully invested portfolio with the given max_weight') + if formulation == "variational" and n_assets * max_weight < 1: + raise ValueError( + "One cannot create fully invested portfolio with the given max_weight" + ) self.formulation = formulation - if formulation == 'analytical': + if formulation == "analytical": self.layer = torch.nn.Softmax(dim=1) else: x = cp.Parameter(n_assets) w = cp.Variable(n_assets) obj = -x @ w - cp.sum(cp.entr(w)) - cons = [cp.sum(w) == 1., - w <= max_weight] + cons = [cp.sum(w) == 1.0, w <= max_weight] prob = cp.Problem(cp.Minimize(obj), cons) self.layer = CvxpyLayer(prob, [x], [w]) @@ -423,16 +496,22 @@ def forward(self, x, temperature=None): device, dtype = x.device, x.dtype if not ((temperature is None) ^ (self.temperature is None)): - raise ValueError('Not clear which temperature to use') + raise ValueError("Not clear which temperature to use") if temperature is not None: temperature_ = temperature # (n_samples,) else: - temperature_ = float(self.temperature) * torch.ones(n_samples, dtype=dtype, device=device) + temperature_ = float(self.temperature) * torch.ones( + n_samples, dtype=dtype, device=device + ) inp = x / temperature_[..., None] - return self.layer(inp) if self.formulation == 'analytical' else self.layer(inp)[0] + return ( + self.layer(inp) + if self.formulation == "analytical" + else self.layer(inp)[0] + ) class SparsemaxAllocator(torch.nn.Module): @@ -464,7 +543,9 @@ def __init__(self, n_assets, temperature=1, max_weight=1): super().__init__() if n_assets * max_weight < 1: - raise ValueError('One cannot create fully invested portfolio with the given max_weight') + raise ValueError( + "One cannot create fully invested portfolio with the given max_weight" + ) self.n_assets = n_assets self.temperature = temperature @@ -473,9 +554,7 @@ def __init__(self, n_assets, temperature=1, max_weight=1): x = cp.Parameter(n_assets) w = cp.Variable(n_assets) obj = cp.sum_squares(x - w) - cons = [cp.sum(w) == 1, - 0. <= w, - w <= max_weight] + cons = [cp.sum(w) == 1, 0.0 <= w, w <= max_weight] prob = cp.Problem(cp.Minimize(obj), cons) self.layer = CvxpyLayer(prob, parameters=[x], variables=[w]) @@ -502,12 +581,14 @@ def forward(self, x, temperature=None): device, dtype = x.device, x.dtype if not ((temperature is None) ^ (self.temperature is None)): - raise ValueError('Not clear which temperature to use') + raise ValueError("Not clear which temperature to use") if temperature is not None: temperature_ = temperature # (n_samples,) else: - temperature_ = float(self.temperature) * torch.ones(n_samples, dtype=dtype, device=device) + temperature_ = float(self.temperature) * torch.ones( + n_samples, dtype=dtype, device=device + ) inp = x / temperature_[..., None] @@ -522,7 +603,9 @@ class WeightNorm(torch.nn.Module): def __init__(self, n_assets): super().__init__() - self.asset_weights = torch.nn.Parameter(torch.ones(n_assets), requires_grad=True) + self.asset_weights = torch.nn.Parameter( + torch.ones(n_assets), requires_grad=True + ) def forward(self, x): """Perform forward pass. @@ -583,7 +666,9 @@ def __init__(self, n_assets, max_weight=1): assert prob.is_dpp() - self.cvxpylayer = CvxpyLayer(prob, parameters=[covmat_sqrt, b], variables=[w]) + self.cvxpylayer = CvxpyLayer( + prob, parameters=[covmat_sqrt, b], variables=[w] + ) def forward(self, covmat_sqrt, b): """Perform forward pass. diff --git a/deepdow/layers/collapse.py b/deepdow/layers/collapse.py index 45e446a..6dff92e 100644 --- a/deepdow/layers/collapse.py +++ b/deepdow/layers/collapse.py @@ -45,11 +45,17 @@ def forward(self, x): res_list = [] for i in range(n_samples): - inp_single = x[i].permute(2, 1, 0) # n_assets, lookback, n_channels + inp_single = x[i].permute( + 2, 1, 0 + ) # n_assets, lookback, n_channels tformed = self.affine(inp_single) # n_assets, lookback, n_channels w = self.context_vector(tformed) # n_assets, lookback, 1 - scaled_w = torch.nn.functional.softmax(w, dim=1) # n_assets, lookback, 1 - weighted_sum = (inp_single * scaled_w).mean(dim=1) # n_assets, n_channels + scaled_w = torch.nn.functional.softmax( + w, dim=1 + ) # n_assets, lookback, 1 + weighted_sum = (inp_single * scaled_w).mean( + dim=1 + ) # n_assets, n_channels res_list.append(weighted_sum.permute(1, 0)) # n_channels, n_assets return torch.stack(res_list, dim=0) @@ -124,7 +130,9 @@ class ExponentialCollapse(nn.Module): def __init__(self, collapse_dim=2, forgetting_factor=None): super().__init__() self.collapse_dim = collapse_dim - self.forgetting_factor = forgetting_factor or torch.nn.Parameter(torch.Tensor([0.5]), requires_grad=True) + self.forgetting_factor = forgetting_factor or torch.nn.Parameter( + torch.Tensor([0.5]), requires_grad=True + ) def forward(self, x): """Perform forward pass. @@ -148,7 +156,9 @@ def forward(self, x): for _ in range(1, n_steps): w_unscaled.append(self.forgetting_factor * w_unscaled[-1] + 1) - w_unscaled = torch.Tensor(w_unscaled).to(dtype=x.dtype, device=x.device) + w_unscaled = torch.Tensor(w_unscaled).to( + dtype=x.dtype, device=x.device + ) w = w_unscaled / w_unscaled.sum() return (x * w.view(*view)).sum(dim=self.collapse_dim) diff --git a/deepdow/layers/misc.py b/deepdow/layers/misc.py index e39939a..1869363 100644 --- a/deepdow/layers/misc.py +++ b/deepdow/layers/misc.py @@ -47,15 +47,25 @@ class CovarianceMatrix(nn.Module): If None then needs to be provided dynamically when performing forward pass. """ - def __init__(self, sqrt=True, shrinkage_strategy='diagonal', shrinkage_coef=0.5): + def __init__( + self, sqrt=True, shrinkage_strategy="diagonal", shrinkage_coef=0.5 + ): """Construct.""" super().__init__() self.sqrt = sqrt if shrinkage_strategy is not None: - if shrinkage_strategy not in {'diagonal', 'identity', 'scaled_identity'}: - raise ValueError('Unrecognized shrinkage strategy {}'.format(shrinkage_strategy)) + if shrinkage_strategy not in { + "diagonal", + "identity", + "scaled_identity", + }: + raise ValueError( + "Unrecognized shrinkage strategy {}".format( + shrinkage_strategy + ) + ) self.shrinkage_strategy = shrinkage_strategy self.shrinkage_coef = shrinkage_coef @@ -83,19 +93,30 @@ def forward(self, x, shrinkage_coef=None): dtype, device = x.dtype, x.device if not ((shrinkage_coef is None) ^ (self.shrinkage_coef is None)): - raise ValueError('Not clear which shrinkage coefficient to use') + raise ValueError("Not clear which shrinkage coefficient to use") if shrinkage_coef is not None: shrinkage_coef_ = shrinkage_coef # (n_samples,) else: - shrinkage_coef_ = self.shrinkage_coef * torch.ones(n_samples, dtype=dtype, device=device) + shrinkage_coef_ = self.shrinkage_coef * torch.ones( + n_samples, dtype=dtype, device=device + ) wrapper = self.compute_sqrt if self.sqrt else lambda h: h - return torch.stack([wrapper(self.compute_covariance(x[i].T.clone(), - shrinkage_strategy=self.shrinkage_strategy, - shrinkage_coef=shrinkage_coef_[i])) - for i in range(n_samples)], dim=0) + return torch.stack( + [ + wrapper( + self.compute_covariance( + x[i].T.clone(), + shrinkage_strategy=self.shrinkage_strategy, + shrinkage_coef=shrinkage_coef_[i], + ) + ) + for i in range(n_samples) + ], + dim=0, + ) @staticmethod def compute_covariance(m, shrinkage_strategy=None, shrinkage_coef=0.5): @@ -128,18 +149,18 @@ def compute_covariance(m, shrinkage_strategy=None, shrinkage_coef=0.5): if shrinkage_strategy is None: return s - elif shrinkage_strategy == 'identity': + elif shrinkage_strategy == "identity": identity = torch.eye(len(s), device=s.device, dtype=s.dtype) return shrinkage_coef * s + (1 - shrinkage_coef) * identity - elif shrinkage_strategy == 'scaled_identity': + elif shrinkage_strategy == "scaled_identity": identity = torch.eye(len(s), device=s.device, dtype=s.dtype) scaled_identity = identity * torch.diag(s).mean() return shrinkage_coef * s + (1 - shrinkage_coef) * scaled_identity - elif shrinkage_strategy == 'diagonal': + elif shrinkage_strategy == "diagonal": diagonal = torch.diag(torch.diag(s)) return shrinkage_coef * s + (1 - shrinkage_coef) * diagonal @@ -161,7 +182,9 @@ def compute_sqrt(m): """ _, s, v = m.svd() - good = s > s.max(-1, True).values * s.size(-1) * torch.finfo(s.dtype).eps + good = ( + s > s.max(-1, True).values * s.size(-1) * torch.finfo(s.dtype).eps + ) components = good.sum(-1) common = components.max() unbalanced = common != components.min() @@ -171,7 +194,9 @@ def compute_sqrt(m): if unbalanced: # pragma: no cover good = good[..., :common] # pragma: no cover if unbalanced: - s = s.where(good, torch.zeros((), device=s.device, dtype=s.dtype)) # pragma: no cover + s = s.where( + good, torch.zeros((), device=s.device, dtype=s.dtype) + ) # pragma: no cover return (v * s.sqrt().unsqueeze(-2)) @ v.transpose(-2, -1) @@ -205,7 +230,16 @@ class KMeans(torch.nn.Module): Control level of verbosity. """ - def __init__(self, n_clusters=5, init='random', n_init=1, max_iter=30, tol=1e-5, random_state=None, verbose=False): + def __init__( + self, + n_clusters=5, + init="random", + n_init=1, + max_iter=30, + tol=1e-5, + random_state=None, + verbose=False, + ): super().__init__() self.n_clusters = n_clusters self.init = init @@ -215,8 +249,10 @@ def __init__(self, n_clusters=5, init='random', n_init=1, max_iter=30, tol=1e-5, self.random_state = random_state self.verbose = verbose - if self.init not in {'manual', 'random', 'k-means++'}: - raise ValueError('Unrecognized initialization {}'.format(self.init)) + if self.init not in {"manual", "random", "k-means++"}: + raise ValueError( + "Unrecognized initialization {}".format(self.init) + ) def initialize(self, x, manual_init=None): """Initialize the k-means algorithm. @@ -240,19 +276,23 @@ def initialize(self, x, manual_init=None): device, dtype = x.device, x.dtype # Note that normalization to probablities is done automatically within torch.multinomial - if self.init == 'random': + if self.init == "random": p = torch.ones(n_samples, dtype=dtype, device=device) # centroid_samples = torch.randperm(n_samples).to(device=device)[:self.n_clusters] - centroid_samples = torch.multinomial(p, num_samples=self.n_clusters, replacement=False) + centroid_samples = torch.multinomial( + p, num_samples=self.n_clusters, replacement=False + ) cluster_centers = x[centroid_samples] - elif self.init == 'k-means++': + elif self.init == "k-means++": p = torch.ones(n_samples, dtype=dtype, device=device) cluster_centers_l = [] centroid_samples_l = [] while len(cluster_centers_l) < self.n_clusters: - centroid_sample = torch.multinomial(p, num_samples=1, replacement=False) + centroid_sample = torch.multinomial( + p, num_samples=1, replacement=False + ) if centroid_sample in centroid_samples_l: continue # pragma: no cover @@ -264,15 +304,19 @@ def initialize(self, x, manual_init=None): cluster_centers = torch.cat(cluster_centers_l, dim=0) - elif self.init == 'manual': + elif self.init == "manual": if not torch.is_tensor(manual_init): - raise TypeError('The manual_init needs to be a torch.Tensor') + raise TypeError("The manual_init needs to be a torch.Tensor") if manual_init.shape[0] != self.n_clusters: - raise ValueError('The number of manually provided cluster centers is different from n_clusters') + raise ValueError( + "The number of manually provided cluster centers is different from n_clusters" + ) if manual_init.shape[1] != x.shape[1]: - raise ValueError('The feature size of manually provided cluster centers is different from the input') + raise ValueError( + "The feature size of manually provided cluster centers is different from the input" + ) cluster_centers = manual_init.to(dtype=dtype, device=device) @@ -301,37 +345,56 @@ def forward(self, x, manual_init=None): """ n_samples, n_features = x.shape if n_samples < self.n_clusters: - raise ValueError('The number of samples is lower than the number of clusters.') + raise ValueError( + "The number of samples is lower than the number of clusters." + ) if self.random_state is not None: torch.manual_seed(self.random_state) - lowest_potential = float('inf') + lowest_potential = float("inf") lowest_potential_cluster_ixs = None lowest_potential_cluster_centers = None for run in range(self.n_init): cluster_centers = self.initialize(x, manual_init=manual_init) - previous_potential = float('inf') + previous_potential = float("inf") for it in range(self.max_iter): - distances = self.compute_distances(x, cluster_centers) # (n_samples, n_clusters) + distances = self.compute_distances( + x, cluster_centers + ) # (n_samples, n_clusters) # E step cluster_ixs = torch.argmin(distances, dim=1) # (n_samples,) # M step - cluster_centers = torch.stack([x[cluster_ixs == i].mean(dim=0) for i in range(self.n_clusters)], dim=0) + cluster_centers = torch.stack( + [ + x[cluster_ixs == i].mean(dim=0) + for i in range(self.n_clusters) + ], + dim=0, + ) # stats - current_potential = distances.gather(1, cluster_ixs.view(-1, 1)).sum() - - if abs(current_potential - previous_potential) < self.tol or it == self.max_iter - 1: + current_potential = distances.gather( + 1, cluster_ixs.view(-1, 1) + ).sum() + + if ( + abs(current_potential - previous_potential) < self.tol + or it == self.max_iter - 1 + ): if self.verbose: - print('Run: {}, n_iters: {}, stop_early: {}, potential: {:.3f}'.format(run, - it, - it != self.max_iter - 1, - current_potential)) + print( + "Run: {}, n_iters: {}, stop_early: {}, potential: {:.3f}".format( + run, + it, + it != self.max_iter - 1, + current_potential, + ) + ) break previous_potential = current_potential @@ -342,7 +405,7 @@ def forward(self, x, manual_init=None): lowest_potential_cluster_centers = cluster_centers.clone() if self.verbose: - print('Lowest potential: {}'.format(lowest_potential)) + print("Lowest potential: {}".format(lowest_potential)) return lowest_potential_cluster_ixs, lowest_potential_cluster_centers @@ -365,10 +428,12 @@ def compute_distances(x, cluster_centers): to a given cluster center (column). """ - x_n = (x ** 2).sum(dim=1).view(-1, 1) # (n_samples, 1) - c_n = (cluster_centers ** 2).sum(dim=1).view(1, -1) # (1, n_clusters) + x_n = (x**2).sum(dim=1).view(-1, 1) # (n_samples, 1) + c_n = (cluster_centers**2).sum(dim=1).view(1, -1) # (1, n_clusters) - distances = x_n + c_n - 2 * torch.mm(x, cluster_centers.permute(1, 0)) # (n_samples, n_clusters) + distances = ( + x_n + c_n - 2 * torch.mm(x, cluster_centers.permute(1, 0)) + ) # (n_samples, n_clusters) return torch.clamp(distances, min=0) @@ -390,7 +455,9 @@ def __init__(self, dim_size=1, dim_ix=1): self.dim_size = dim_size self.dim_ix = dim_ix - self.constant = torch.nn.Parameter(torch.ones(self.dim_size), requires_grad=True) + self.constant = torch.nn.Parameter( + torch.ones(self.dim_size), requires_grad=True + ) def forward(self, x): """Perform forward pass. @@ -407,8 +474,12 @@ def forward(self, x): """ if self.dim_size != x.shape[self.dim_ix]: - raise ValueError('The size of dimension {} is {} which is different than {}'.format(self.dim_ix, - x.shape[self.dim_ix], - self.dim_size)) - view = [self.dim_size if i == self.dim_ix else 1 for i in range(x.ndim)] + raise ValueError( + "The size of dimension {} is {} which is different than {}".format( + self.dim_ix, x.shape[self.dim_ix], self.dim_size + ) + ) + view = [ + self.dim_size if i == self.dim_ix else 1 for i in range(x.ndim) + ] return x * self.constant.view(view) diff --git a/deepdow/layers/transform.py b/deepdow/layers/transform.py index 9ac54e4..6e4879b 100644 --- a/deepdow/layers/transform.py +++ b/deepdow/layers/transform.py @@ -22,23 +22,31 @@ class Conv(nn.Module): What type of convolution is used in the background. """ - def __init__(self, n_input_channels, n_output_channels, kernel_size=3, method='2D'): + def __init__( + self, n_input_channels, n_output_channels, kernel_size=3, method="2D" + ): super().__init__() self.method = method - if method == '2D': - self.conv = nn.Conv2d(n_input_channels, - n_output_channels, - kernel_size=kernel_size, - padding=(kernel_size - 1) // 2) - elif method == '1D': - self.conv = nn.Conv1d(n_input_channels, - n_output_channels, - kernel_size=kernel_size, - padding=(kernel_size - 1) // 2) + if method == "2D": + self.conv = nn.Conv2d( + n_input_channels, + n_output_channels, + kernel_size=kernel_size, + padding=(kernel_size - 1) // 2, + ) + elif method == "1D": + self.conv = nn.Conv1d( + n_input_channels, + n_output_channels, + kernel_size=kernel_size, + padding=(kernel_size - 1) // 2, + ) else: - raise ValueError("Invalid method {}, only supports '1D' or '2D'.".format(method)) + raise ValueError( + "Invalid method {}, only supports '1D' or '2D'.".format(method) + ) def forward(self, x): """Perform forward pass. @@ -81,25 +89,44 @@ class RNN(nn.Module): """ - def __init__(self, n_channels, hidden_size, cell_type='LSTM', bidirectional=True, n_layers=1): + def __init__( + self, + n_channels, + hidden_size, + cell_type="LSTM", + bidirectional=True, + n_layers=1, + ): """Construct.""" super().__init__() if hidden_size % 2 != 0 and bidirectional: - raise ValueError('Hidden size needs to be divisible by two for bidirectional RNNs.') - - hidden_size_one_direction = int(hidden_size // (1 + int(bidirectional))) # only will work out for - - if cell_type == 'RNN': - self.cell = torch.nn.RNN(n_channels, hidden_size_one_direction, bidirectional=bidirectional, - num_layers=n_layers) - - elif cell_type == 'LSTM': - self.cell = torch.nn.LSTM(n_channels, hidden_size_one_direction, bidirectional=bidirectional, - num_layers=n_layers) + raise ValueError( + "Hidden size needs to be divisible by two for bidirectional RNNs." + ) + + hidden_size_one_direction = int( + hidden_size // (1 + int(bidirectional)) + ) # only will work out for + + if cell_type == "RNN": + self.cell = torch.nn.RNN( + n_channels, + hidden_size_one_direction, + bidirectional=bidirectional, + num_layers=n_layers, + ) + + elif cell_type == "LSTM": + self.cell = torch.nn.LSTM( + n_channels, + hidden_size_one_direction, + bidirectional=bidirectional, + num_layers=n_layers, + ) else: - raise ValueError('Unsupported cell_type {}'.format(cell_type)) + raise ValueError("Unsupported cell_type {}".format(cell_type)) def forward(self, x): """Perform forward pass. @@ -116,12 +143,18 @@ def forward(self, x): """ n_samples, n_channels, lookback, n_assets = x.shape - x_swapped = x.permute(0, 2, 3, 1) # n_samples, lookback, n_assets, n_channels + x_swapped = x.permute( + 0, 2, 3, 1 + ) # n_samples, lookback, n_assets, n_channels res = [] for i in range(n_samples): - all_hidden_ = self.cell(x_swapped[i])[0] # lookback, n_assets, hidden_size - res.append(all_hidden_.permute(2, 0, 1)) # hidden_size, lookback, n_assets + all_hidden_ = self.cell(x_swapped[i])[ + 0 + ] # lookback, n_assets, hidden_size + res.append( + all_hidden_.permute(2, 0, 1) + ) # hidden_size, lookback, n_assets return torch.stack(res) @@ -129,7 +162,7 @@ def forward(self, x): class Warp(torch.nn.Module): """Custom warping layer.""" - def __init__(self, mode='bilinear', padding_mode='reflection'): + def __init__(self, mode="bilinear", padding_mode="reflection"): super().__init__() self.mode = mode self.padding_mode = padding_mode @@ -162,21 +195,30 @@ def forward(self, x, tform): if tform.ndim == 3: ty = tform elif tform.ndim == 2: - ty = torch.stack(n_assets * [tform], dim=-1) # (n_samples, lookback, n_assets) + ty = torch.stack( + n_assets * [tform], dim=-1 + ) # (n_samples, lookback, n_assets) else: - raise ValueError('The tform tensor needs to be either 2 or 3 dimensional.') + raise ValueError( + "The tform tensor needs to be either 2 or 3 dimensional." + ) - tx = torch.ones(n_samples, lookback, n_assets, dtype=dtype, device=device) - tx *= torch.linspace(-1, 1, steps=n_assets, device=device, dtype=dtype)[None, None, :] + tx = torch.ones( + n_samples, lookback, n_assets, dtype=dtype, device=device + ) + tx *= torch.linspace( + -1, 1, steps=n_assets, device=device, dtype=dtype + )[None, None, :] grid = torch.stack([tx, ty], dim=-1) - x_warped = nn.functional.grid_sample(x, - grid, - mode=self.mode, - padding_mode=self.padding_mode, - align_corners=True, - ) + x_warped = nn.functional.grid_sample( + x, + grid, + mode=self.mode, + padding_mode=self.padding_mode, + align_corners=True, + ) return x_warped @@ -203,7 +245,7 @@ class Zoom(torch.nn.Module): """ - def __init__(self, mode='bilinear', padding_mode='reflection'): + def __init__(self, mode="bilinear", padding_mode="reflection"): super().__init__() self.mode = mode self.padding_mode = padding_mode @@ -229,16 +271,22 @@ def forward(self, x, scale): """ translate = 1 - scale - theta = torch.stack([torch.tensor([[1, 0, 0], - [0, s, t]]) for s, t in zip(scale, translate)], dim=0) + theta = torch.stack( + [ + torch.tensor([[1, 0, 0], [0, s, t]]) + for s, t in zip(scale, translate) + ], + dim=0, + ) theta = theta.to(device=x.device, dtype=x.dtype) grid = nn.functional.affine_grid(theta, x.shape, align_corners=True) - x_zoomed = nn.functional.grid_sample(x, - grid, - mode=self.mode, - padding_mode=self.padding_mode, - align_corners=True, - ) + x_zoomed = nn.functional.grid_sample( + x, + grid, + mode=self.mode, + padding_mode=self.padding_mode, + align_corners=True, + ) return x_zoomed diff --git a/deepdow/losses.py b/deepdow/losses.py index 7fd7ca5..d16d2c0 100644 --- a/deepdow/losses.py +++ b/deepdow/losses.py @@ -73,7 +73,9 @@ def simple2log(x): return torch.log(x + 1) -def portfolio_returns(weights, y, input_type='log', output_type='simple', rebalance=False): +def portfolio_returns( + weights, y, input_type="log", output_type="simple", rebalance=False +): """Compute portfolio returns. Parameters @@ -100,36 +102,44 @@ def portfolio_returns(weights, y, input_type='log', output_type='simple', rebala Of shape (n_samples, horizon) representing per timestep portfolio returns. """ - if input_type == 'log': + if input_type == "log": simple_returns = log2simple(y) - elif input_type == 'simple': + elif input_type == "simple": simple_returns = y else: - raise ValueError('Unsupported input type: {}'.format(input_type)) + raise ValueError("Unsupported input type: {}".format(input_type)) n_samples, horizon, n_assets = simple_returns.shape - weights_ = weights.view(n_samples, 1, n_assets).repeat(1, horizon, 1) # (n_samples, horizon, n_assets) + weights_ = weights.view(n_samples, 1, n_assets).repeat( + 1, horizon, 1 + ) # (n_samples, horizon, n_assets) if not rebalance: - weights_unscaled = (1 + simple_returns).cumprod(1)[:, :-1, :] * weights_[:, 1:, :] - weights_[:, 1:, :] = weights_unscaled / weights_unscaled.sum(2, keepdim=True) + weights_unscaled = (1 + simple_returns).cumprod(1)[ + :, :-1, : + ] * weights_[:, 1:, :] + weights_[:, 1:, :] = weights_unscaled / weights_unscaled.sum( + 2, keepdim=True + ) out = (simple_returns * weights_).sum(-1) - if output_type == 'log': + if output_type == "log": return simple2log(out) - elif output_type == 'simple': + elif output_type == "simple": return out else: - raise ValueError('Unsupported output type: {}'.format(output_type)) + raise ValueError("Unsupported output type: {}".format(output_type)) -def portfolio_cumulative_returns(weights, y, input_type='log', output_type='simple', rebalance=False): +def portfolio_cumulative_returns( + weights, y, input_type="log", output_type="simple", rebalance=False +): """Compute cumulative portfolio returns. Parameters @@ -157,17 +167,25 @@ def portfolio_cumulative_returns(weights, y, input_type='log', output_type='simp Tensor of shape `(n_samples, horizon)`. """ - prets = portfolio_returns(weights, y, input_type=input_type, output_type='log', rebalance=rebalance) - log_prets = torch.cumsum(prets, dim=1) # we can aggregate log returns over time by sum - - if output_type == 'log': + prets = portfolio_returns( + weights, + y, + input_type=input_type, + output_type="log", + rebalance=rebalance, + ) + log_prets = torch.cumsum( + prets, dim=1 + ) # we can aggregate log returns over time by sum + + if output_type == "log": return log_prets - elif output_type == 'simple': + elif output_type == "simple": return log2simple(log_prets) else: - raise ValueError('Unsupported output type: {}'.format(output_type)) + raise ValueError("Unsupported output type: {}".format(output_type)) class Loss: @@ -226,21 +244,32 @@ def __add__(self, other): """ if isinstance(other, Loss): new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) + other(weights, y), new_instance) - new_instance._repr = MethodType(lambda inst: '{} + {}'.format(self.__repr__(), other.__repr__()), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) + other(weights, y), + new_instance, + ) + new_instance._repr = MethodType( + lambda inst: "{} + {}".format( + self.__repr__(), other.__repr__() + ), + new_instance, + ) return new_instance elif isinstance(other, (int, float)): new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) + other, new_instance) - new_instance._repr = MethodType(lambda inst: '{} + {}'.format(self.__repr__(), other), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) + other, new_instance + ) + new_instance._repr = MethodType( + lambda inst: "{} + {}".format(self.__repr__(), other), + new_instance, + ) return new_instance else: - raise TypeError('Unsupported type: {}'.format(type(other))) + raise TypeError("Unsupported type: {}".format(type(other))) def __radd__(self, other): """Add two losses together. @@ -274,21 +303,32 @@ def __mul__(self, other): """ if isinstance(other, Loss): new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) * other(weights, y), new_instance) - new_instance._repr = MethodType(lambda inst: '{} * {}'.format(self.__repr__(), other.__repr__()), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) * other(weights, y), + new_instance, + ) + new_instance._repr = MethodType( + lambda inst: "{} * {}".format( + self.__repr__(), other.__repr__() + ), + new_instance, + ) return new_instance elif isinstance(other, (int, float)): new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) * other, new_instance) - new_instance._repr = MethodType(lambda inst: '{} * {}'.format(self.__repr__(), other), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) * other, new_instance + ) + new_instance._repr = MethodType( + lambda inst: "{} * {}".format(self.__repr__(), other), + new_instance, + ) return new_instance else: - raise TypeError('Unsupported type: {}'.format(type(other))) + raise TypeError("Unsupported type: {}".format(type(other))) def __rmul__(self, other): """Multiply two losses together. @@ -322,9 +362,16 @@ def __truediv__(self, other): """ if isinstance(other, Loss): new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) / other(weights, y), new_instance) - new_instance._repr = MethodType(lambda inst: '{} / {}'.format(self.__repr__(), other.__repr__()), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) / other(weights, y), + new_instance, + ) + new_instance._repr = MethodType( + lambda inst: "{} / {}".format( + self.__repr__(), other.__repr__() + ), + new_instance, + ) return new_instance @@ -333,13 +380,17 @@ def __truediv__(self, other): raise ZeroDivisionError() new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) / other, new_instance) - new_instance._repr = MethodType(lambda inst: '{} / {}'.format(self.__repr__(), other), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) / other, new_instance + ) + new_instance._repr = MethodType( + lambda inst: "{} / {}".format(self.__repr__(), other), + new_instance, + ) return new_instance else: - raise TypeError('Unsupported type: {}'.format(type(other))) + raise TypeError("Unsupported type: {}".format(type(other))) def __pow__(self, power): """Put a loss to a power. @@ -356,13 +407,18 @@ def __pow__(self, power): """ if isinstance(power, (int, float)): new_instance = Loss() - new_instance._call = MethodType(lambda inst, weights, y: self(weights, y) ** power, new_instance) - new_instance._repr = MethodType(lambda inst: '({}) ** {}'.format(self.__repr__(), power), - new_instance) + new_instance._call = MethodType( + lambda inst, weights, y: self(weights, y) ** power, + new_instance, + ) + new_instance._repr = MethodType( + lambda inst: "({}) ** {}".format(self.__repr__(), power), + new_instance, + ) return new_instance else: - raise TypeError('Unsupported type: {}'.format(type(power))) + raise TypeError("Unsupported type: {}".format(type(power))) class Alpha(Loss): @@ -382,7 +438,9 @@ class Alpha(Loss): """ - def __init__(self, benchmark_weights=None, returns_channel=0, input_type='log'): + def __init__( + self, benchmark_weights=None, returns_channel=0, input_type="log" + ): self.benchmark_weights = benchmark_weights self.returns_channel = returns_channel self.input_type = input_type @@ -407,20 +465,31 @@ def __call__(self, weights, y): """ n_samples, n_assets = weights.shape device, dtype = weights.device, weights.dtype - portfolio_rets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type='simple') # (n_samples, horizon) + portfolio_rets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type="simple", + ) # (n_samples, horizon) if self.benchmark_weights is None: - benchmark_weights = torch.ones(n_samples, n_assets, dtype=dtype, device=device) / n_assets + benchmark_weights = ( + torch.ones(n_samples, n_assets, dtype=dtype, device=device) + / n_assets + ) else: - benchmark_weights = self.benchmark_weights[None, :].repeat(n_samples, 1).to(device=device, dtype=dtype) + benchmark_weights = ( + self.benchmark_weights[None, :] + .repeat(n_samples, 1) + .to(device=device, dtype=dtype) + ) - benchmark_rets = portfolio_returns(benchmark_weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type='simple') # (n_samples, horizon) + benchmark_rets = portfolio_returns( + benchmark_weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type="simple", + ) # (n_samples, horizon) cov = covariance(benchmark_rets, portfolio_rets) beta = cov / benchmark_rets.var(dim=1) @@ -430,10 +499,12 @@ def __call__(self, weights, y): def __repr__(self): """Generate representation string.""" - return "{}(benchmark_weights={},returns_channel={}, input_type='{}')".format(self.__class__.__name__, - self.benchmark_weights, - self.returns_channel, - self.input_type) + return "{}(benchmark_weights={},returns_channel={}, input_type='{}')".format( + self.__class__.__name__, + self.benchmark_weights, + self.returns_channel, + self.input_type, + ) class CumulativeReturn(Loss): @@ -448,7 +519,7 @@ class CumulativeReturn(Loss): What type of returns are we dealing with in `y`. """ - def __init__(self, returns_channel=0, input_type='log'): + def __init__(self, returns_channel=0, input_type="log"): self.returns_channel = returns_channel self.input_type = input_type @@ -470,18 +541,20 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample negative simple cumulative returns. """ - crets = portfolio_cumulative_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type='simple') + crets = portfolio_cumulative_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type="simple", + ) return -crets[:, -1] def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={}, input_type='{}')".format(self.__class__.__name__, - self.returns_channel, - self.input_type) + return "{}(returns_channel={}, input_type='{}')".format( + self.__class__.__name__, self.returns_channel, self.input_type + ) class LargestWeight(Loss): @@ -521,7 +594,7 @@ def __repr__(self): class MaximumDrawdown(Loss): """Negative of the maximum drawdown.""" - def __init__(self, returns_channel=0, input_type='log'): + def __init__(self, returns_channel=0, input_type="log"): self.returns_channel = returns_channel self.input_type = input_type @@ -543,10 +616,12 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample maximum drawdown. """ - cumrets = 1 + portfolio_cumulative_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type='simple') + cumrets = 1 + portfolio_cumulative_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type="simple", + ) cummax = torch.cummax(cumrets, 1)[0] # (n_samples, n_timesteps) @@ -559,15 +634,17 @@ def __call__(self, weights, y): def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={}, input_type='{}')".format(self.__class__.__name__, - self.returns_channel, - self.input_type) + return "{}(returns_channel={}, input_type='{}')".format( + self.__class__.__name__, self.returns_channel, self.input_type + ) class MeanReturns(Loss): """Negative mean returns.""" - def __init__(self, returns_channel=0, input_type='log', output_type='simple'): + def __init__( + self, returns_channel=0, input_type="log", output_type="simple" + ): self.returns_channel = returns_channel self.input_type = input_type self.output_type = output_type @@ -590,19 +667,25 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample negative mean returns. """ - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) return -prets.mean(dim=1) def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={}, input_type='{}', output_type='{}')".format(self.__class__.__name__, - self.returns_channel, - self.input_type, - self.output_type) + return ( + "{}(returns_channel={}, input_type='{}', output_type='{}')".format( + self.__class__.__name__, + self.returns_channel, + self.input_type, + self.output_type, + ) + ) class Quantile(Loss): @@ -637,8 +720,9 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample negative quantile. """ - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...]) # (n_samples, horizon) + prets = portfolio_returns( + weights, y[:, self.returns_channel, ...] + ) # (n_samples, horizon) _, horizon = prets.shape @@ -647,7 +731,9 @@ def __call__(self, weights, y): def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={})".format(self.__class__.__name__, self.returns_channel) + return "{}(returns_channel={})".format( + self.__class__.__name__, self.returns_channel + ) class SharpeRatio(Loss): @@ -671,7 +757,14 @@ class SharpeRatio(Loss): Additional constant added to the denominator to avoid division by zero. """ - def __init__(self, rf=0, returns_channel=0, input_type='log', output_type='simple', eps=1e-4): + def __init__( + self, + rf=0, + returns_channel=0, + input_type="log", + output_type="simple", + eps=1e-4, + ): self.rf = rf self.returns_channel = returns_channel self.input_type = input_type @@ -696,10 +789,12 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample negative sharpe ratio. """ - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) return -(prets.mean(dim=1) - self.rf) / (prets.std(dim=1) + self.eps) @@ -711,7 +806,8 @@ def __repr__(self): self.returns_channel, self.input_type, self.output_type, - self.eps) + self.eps, + ) class Softmax(Loss): @@ -744,7 +840,9 @@ def __call__(self, weights, y): def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={})".format(self.__class__.__name__, self.returns_channel) + return "{}(returns_channel={})".format( + self.__class__.__name__, self.returns_channel + ) class SortinoRatio(Loss): @@ -768,7 +866,14 @@ class SortinoRatio(Loss): Additional constant added to the denominator to avoid division by zero. """ - def __init__(self, rf=0, returns_channel=0, input_type='log', output_type='simple', eps=1e-4): + def __init__( + self, + rf=0, + returns_channel=0, + input_type="log", + output_type="simple", + eps=1e-4, + ): self.rf = rf self.returns_channel = returns_channel self.input_type = input_type @@ -793,12 +898,16 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample negative worst return over the horizon. """ - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) - return -(prets.mean(dim=1) - self.rf) / (torch.sqrt(torch.mean(torch.relu(-prets) ** 2, dim=1)) + self.eps) + return -(prets.mean(dim=1) - self.rf) / ( + torch.sqrt(torch.mean(torch.relu(-prets) ** 2, dim=1)) + self.eps + ) def __repr__(self): """Generate representation string.""" @@ -808,7 +917,8 @@ def __repr__(self): self.returns_channel, self.input_type, self.output_type, - self.eps) + self.eps, + ) class SquaredWeights(Loss): @@ -842,7 +952,7 @@ def __call__(self, weights, *args): If single asset then equal to `1`. If equally weighted portfolio then `1/N`. """ - return (weights ** 2).sum(dim=1) + return (weights**2).sum(dim=1) def __repr__(self): """Generate representation string.""" @@ -852,7 +962,9 @@ def __repr__(self): class StandardDeviation(Loss): """Standard deviation.""" - def __init__(self, returns_channel=0, input_type='log', output_type='simple'): + def __init__( + self, returns_channel=0, input_type="log", output_type="simple" + ): self.returns_channel = returns_channel self.input_type = input_type self.output_type = output_type @@ -875,19 +987,25 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample standard deviation. """ - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) return prets.std(dim=1) def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={}, input_type='{}', output_type='{}')".format(self.__class__.__name__, - self.returns_channel, - self.input_type, - self.output_type) + return ( + "{}(returns_channel={}, input_type='{}', output_type='{}')".format( + self.__class__.__name__, + self.returns_channel, + self.input_type, + self.output_type, + ) + ) class TargetMeanReturn(Loss): @@ -896,7 +1014,14 @@ class TargetMeanReturn(Loss): Difference between some desired mean return and the realized one. """ - def __init__(self, target=0.01, p=2, returns_channel=0, input_type='log', output_type='simple'): + def __init__( + self, + target=0.01, + p=2, + returns_channel=0, + input_type="log", + output_type="simple", + ): self.p = p self.target = target self.returns_channel = returns_channel @@ -925,10 +1050,12 @@ def __call__(self, weights, y): def mapping(x): return abs(x - self.target) ** self.p - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) return mapping(prets.mean(dim=1)) # (n_shapes,) @@ -940,7 +1067,8 @@ def __repr__(self): self.p, self.returns_channel, self.input_type, - self.output_type) + self.output_type, + ) class TargetStandardDeviation(Loss): @@ -949,7 +1077,14 @@ class TargetStandardDeviation(Loss): Difference between some desired standard deviation and the realized one. """ - def __init__(self, target=0.01, p=2, returns_channel=0, input_type='log', output_type='simple'): + def __init__( + self, + target=0.01, + p=2, + returns_channel=0, + input_type="log", + output_type="simple", + ): self.p = p self.target = target self.returns_channel = returns_channel @@ -978,10 +1113,12 @@ def __call__(self, weights, y): def mapping(x): return abs(x - self.target) ** self.p - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) return mapping(prets.std(dim=1)) # (n_shapes,) @@ -993,7 +1130,8 @@ def __repr__(self): self.p, self.returns_channel, self.input_type, - self.output_type) + self.output_type, + ) class WorstReturn(Loss): @@ -1002,7 +1140,9 @@ class WorstReturn(Loss): This loss is designed to discourage outliers - extremely low returns. """ - def __init__(self, returns_channel=0, input_type='log', output_type='simple'): + def __init__( + self, returns_channel=0, input_type="log", output_type="simple" + ): self.returns_channel = returns_channel self.input_type = input_type self.output_type = output_type @@ -1026,19 +1166,25 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample negative worst return over the horizon. """ - prets = portfolio_returns(weights, - y[:, self.returns_channel, ...], - input_type=self.input_type, - output_type=self.output_type) + prets = portfolio_returns( + weights, + y[:, self.returns_channel, ...], + input_type=self.input_type, + output_type=self.output_type, + ) return -prets.topk(1, dim=1, largest=False)[0].view(-1) def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={}, input_type='{}', output_type='{}')".format(self.__class__.__name__, - self.returns_channel, - self.input_type, - self.output_type) + return ( + "{}(returns_channel={}, input_type='{}', output_type='{}')".format( + self.__class__.__name__, + self.returns_channel, + self.input_type, + self.output_type, + ) + ) class RiskParity(Loss): @@ -1082,24 +1228,29 @@ def __call__(self, weights, y): Tensor of shape `(n_samples,)` representing the per sample risk parity. """ n_assets = weights.shape[-1] - covar = self.covariance_layer(y[:, self.returns_channel, ...]) # (n_samples, n_assets, n_assets) + covar = self.covariance_layer( + y[:, self.returns_channel, ...] + ) # (n_samples, n_assets, n_assets) weights = weights.unsqueeze(dim=1) - volatility = torch.sqrt(torch.matmul(weights, - torch.matmul(covar, - weights.permute((0, 2, 1))))) # (n_samples, 1, 1) + volatility = torch.sqrt( + torch.matmul( + weights, torch.matmul(covar, weights.permute((0, 2, 1))) + ) + ) # (n_samples, 1, 1) c = (covar * weights) / volatility # (n_samples, n_assets, n_assets) risk = volatility / n_assets # (n_samples, 1, 1) budget = torch.matmul(weights, c) # (n_samples, n_assets, n_assets) - rp = torch.sum((risk - budget)**2, dim=-1).view(-1) # (n_samples,) + rp = torch.sum((risk - budget) ** 2, dim=-1).view(-1) # (n_samples,) return rp def __repr__(self): """Generate representation string.""" - return "{}(returns_channel={})".format(self.__class__.__name__, - self.returns_channel) + return "{}(returns_channel={})".format( + self.__class__.__name__, self.returns_channel + ) class DownsideRisk(Loss): @@ -1140,18 +1291,18 @@ def __call__(self, weights, y): return torch.sqrt( torch.mean( - torch.relu(-prets.sub(prets.mean(dim=1)[:, None])) ** self.beta, dim=1 + torch.relu(-prets.sub(prets.mean(dim=1)[:, None])) + ** self.beta, + dim=1, ) ) def __repr__(self): """Generate representation string.""" - return ( - "{}(beta={}, returns_channel={}, input_type='{}', output_type='{}')".format( - self.__class__.__name__, - self.beta, - self.returns_channel, - self.input_type, - self.output_type, - ) + return "{}(beta={}, returns_channel={}, input_type='{}', output_type='{}')".format( + self.__class__.__name__, + self.beta, + self.returns_channel, + self.input_type, + self.output_type, ) diff --git a/deepdow/nn.py b/deepdow/nn.py index 54c5521..a0c7880 100644 --- a/deepdow/nn.py +++ b/deepdow/nn.py @@ -2,8 +2,17 @@ import torch from .benchmarks import Benchmark -from .layers import (AttentionCollapse, AverageCollapse, CovarianceMatrix, Conv, NumericalMarkowitz, MultiplyByConstant, - RNN, SoftmaxAllocator, WeightNorm) +from .layers import ( + AttentionCollapse, + AverageCollapse, + CovarianceMatrix, + Conv, + NumericalMarkowitz, + MultiplyByConstant, + RNN, + SoftmaxAllocator, + WeightNorm, +) class DummyNet(torch.nn.Module, Benchmark): @@ -44,7 +53,11 @@ def forward(self, x): @property def hparams(self): """Hyperparamters relevant to construction of the model.""" - return {k: v if isinstance(v, (int, float, str)) else str(v) for k, v in self._hparams.items() if k != 'self'} + return { + k: v if isinstance(v, (int, float, str)) else str(v) + for k, v in self._hparams.items() + if k != "self" + } class BachelierNet(torch.nn.Module, Benchmark): @@ -109,16 +122,30 @@ class BachelierNet(torch.nn.Module, Benchmark): """ - def __init__(self, n_input_channels, n_assets, hidden_size=32, max_weight=1, shrinkage_strategy='diagonal', p=0.5): + def __init__( + self, + n_input_channels, + n_assets, + hidden_size=32, + max_weight=1, + shrinkage_strategy="diagonal", + p=0.5, + ): self._hparams = locals().copy() super().__init__() - self.norm_layer = torch.nn.InstanceNorm2d(n_input_channels, affine=True) + self.norm_layer = torch.nn.InstanceNorm2d( + n_input_channels, affine=True + ) self.transform_layer = RNN(n_input_channels, hidden_size=hidden_size) self.dropout_layer = torch.nn.Dropout(p=p) self.time_collapse_layer = AttentionCollapse(n_channels=hidden_size) - self.covariance_layer = CovarianceMatrix(sqrt=False, shrinkage_strategy=shrinkage_strategy) + self.covariance_layer = CovarianceMatrix( + sqrt=False, shrinkage_strategy=shrinkage_strategy + ) self.channel_collapse_layer = AverageCollapse(collapse_dim=1) - self.portfolio_opt_layer = NumericalMarkowitz(n_assets, max_weight=max_weight) + self.portfolio_opt_layer = NumericalMarkowitz( + n_assets, max_weight=max_weight + ) self.gamma_sqrt = torch.nn.Parameter(torch.ones(1), requires_grad=True) self.alpha = torch.nn.Parameter(torch.ones(1), requires_grad=True) @@ -150,18 +177,29 @@ def forward(self, x): exp_rets = self.channel_collapse_layer(x) # gamma - gamma_sqrt_all = torch.ones(len(x)).to(device=x.device, dtype=x.dtype) * self.gamma_sqrt - alpha_all = torch.ones(len(x)).to(device=x.device, dtype=x.dtype) * self.alpha + gamma_sqrt_all = ( + torch.ones(len(x)).to(device=x.device, dtype=x.dtype) + * self.gamma_sqrt + ) + alpha_all = ( + torch.ones(len(x)).to(device=x.device, dtype=x.dtype) * self.alpha + ) # weights - weights = self.portfolio_opt_layer(exp_rets, covmat, gamma_sqrt_all, alpha_all) + weights = self.portfolio_opt_layer( + exp_rets, covmat, gamma_sqrt_all, alpha_all + ) return weights @property def hparams(self): """Hyperparamters relevant to construction of the model.""" - return {k: v if isinstance(v, (int, float, str)) else str(v) for k, v in self._hparams.items() if k != 'self'} + return { + k: v if isinstance(v, (int, float, str)) else str(v) + for k, v in self._hparams.items() + if k != "self" + } class KeynesNet(torch.nn.Module, Benchmark): @@ -205,29 +243,53 @@ class KeynesNet(torch.nn.Module, Benchmark): Portfolio allocation layer. Uses learned `temperature`. """ - def __init__(self, n_input_channels, hidden_size=32, transform_type='RNN', n_groups=4): + def __init__( + self, + n_input_channels, + hidden_size=32, + transform_type="RNN", + n_groups=4, + ): self._hparams = locals().copy() super().__init__() self.transform_type = transform_type - if self.transform_type == 'RNN': - self.transform_layer = RNN(n_input_channels, hidden_size=hidden_size, bidirectional=False, - cell_type='LSTM') - - elif self.transform_type == 'Conv': - self.transform_layer = Conv(n_input_channels, n_output_channels=hidden_size, method='1D', - kernel_size=3) + if self.transform_type == "RNN": + self.transform_layer = RNN( + n_input_channels, + hidden_size=hidden_size, + bidirectional=False, + cell_type="LSTM", + ) + + elif self.transform_type == "Conv": + self.transform_layer = Conv( + n_input_channels, + n_output_channels=hidden_size, + method="1D", + kernel_size=3, + ) else: - raise ValueError('Unsupported transform_type: {}'.format(transform_type)) + raise ValueError( + "Unsupported transform_type: {}".format(transform_type) + ) if hidden_size % n_groups != 0: - raise ValueError('The hidden_size needs to be divisible by the n_groups.') - - self.norm_layer_1 = torch.nn.InstanceNorm2d(n_input_channels, affine=True) - self.temperature = torch.nn.Parameter(torch.ones(1), requires_grad=True) - self.norm_layer_2 = torch.nn.GroupNorm(n_groups, hidden_size, affine=True) + raise ValueError( + "The hidden_size needs to be divisible by the n_groups." + ) + + self.norm_layer_1 = torch.nn.InstanceNorm2d( + n_input_channels, affine=True + ) + self.temperature = torch.nn.Parameter( + torch.ones(1), requires_grad=True + ) + self.norm_layer_2 = torch.nn.GroupNorm( + n_groups, hidden_size, affine=True + ) self.time_collapse_layer = AverageCollapse(collapse_dim=2) self.channel_collapse_layer = AverageCollapse(collapse_dim=1) @@ -250,17 +312,23 @@ def __call__(self, x): n_samples, n_channels, lookback, n_assets = x.shape x = self.norm_layer_1(x) - if self.transform_type == 'RNN': + if self.transform_type == "RNN": x = self.transform_layer(x) else: - x = torch.stack([self.transform_layer(x[..., i]) for i in range(n_assets)], dim=-1) + x = torch.stack( + [self.transform_layer(x[..., i]) for i in range(n_assets)], + dim=-1, + ) x = self.norm_layer_2(x) x = torch.nn.functional.relu(x) x = self.time_collapse_layer(x) x = self.channel_collapse_layer(x) - temperatures = torch.ones(n_samples).to(device=x.device, dtype=x.dtype) * self.temperature + temperatures = ( + torch.ones(n_samples).to(device=x.device, dtype=x.dtype) + * self.temperature + ) weights = self.portfolio_opt_layer(x, temperatures) @@ -269,7 +337,11 @@ def __call__(self, x): @property def hparams(self): """Hyperparameters relevant to construction of the model.""" - return {k: v if isinstance(v, (int, float, str)) else str(v) for k, v in self._hparams.items() if k != 'self'} + return { + k: v if isinstance(v, (int, float, str)) else str(v) + for k, v in self._hparams.items() + if k != "self" + } class LinearNet(torch.nn.Module, Benchmark): @@ -322,7 +394,9 @@ def __init__(self, n_channels, lookback, n_assets, p=0.5): self.dropout_layer = torch.nn.Dropout(p=p) self.linear = torch.nn.Linear(n_features, n_assets, bias=True) - self.temperature = torch.nn.Parameter(torch.ones(1), requires_grad=True) + self.temperature = torch.nn.Parameter( + torch.ones(1), requires_grad=True + ) self.allocate_layer = SoftmaxAllocator(temperature=None) def forward(self, x): @@ -341,7 +415,7 @@ def forward(self, x): """ if x.shape[1:] != (self.n_channels, self.lookback, self.n_assets): - raise ValueError('Input x has incorrect shape {}'.format(x.shape)) + raise ValueError("Input x has incorrect shape {}".format(x.shape)) n_samples, _, _, _ = x.shape @@ -351,7 +425,10 @@ def forward(self, x): x = self.dropout_layer(x) x = self.linear(x) - temperatures = torch.ones(n_samples).to(device=x.device, dtype=x.dtype) * self.temperature + temperatures = ( + torch.ones(n_samples).to(device=x.device, dtype=x.dtype) + * self.temperature + ) weights = self.allocate_layer(x, temperatures) return weights @@ -359,7 +436,11 @@ def forward(self, x): @property def hparams(self): """Hyperparameters relevant to construction of the model.""" - return {k: v if isinstance(v, (int, float, str)) else str(v) for k, v in self._hparams.items() if k != 'self'} + return { + k: v if isinstance(v, (int, float, str)) else str(v) + for k, v in self._hparams.items() + if k != "self" + } class MinimalNet(torch.nn.Module, Benchmark): @@ -401,7 +482,7 @@ def forward(self, x): @property def hparams(self): """Hyperparameters relevant to construction of the model.""" - return {'n_assets': self.n_assets} + return {"n_assets": self.n_assets} class ThorpNet(torch.nn.Module, Benchmark): @@ -444,12 +525,18 @@ def __init__(self, n_assets, max_weight=1, force_symmetric=True): super().__init__() self.force_symmetric = force_symmetric - self.matrix = torch.nn.Parameter(torch.eye(n_assets), requires_grad=True) - self.exp_returns = torch.nn.Parameter(torch.zeros(n_assets), requires_grad=True) + self.matrix = torch.nn.Parameter( + torch.eye(n_assets), requires_grad=True + ) + self.exp_returns = torch.nn.Parameter( + torch.zeros(n_assets), requires_grad=True + ) self.gamma_sqrt = torch.nn.Parameter(torch.ones(1), requires_grad=True) self.alpha = torch.nn.Parameter(torch.ones(1), requires_grad=True) - self.portfolio_opt_layer = NumericalMarkowitz(n_assets, max_weight=max_weight) + self.portfolio_opt_layer = NumericalMarkowitz( + n_assets, max_weight=max_weight + ) def forward(self, x): """Perform forward pass. @@ -467,18 +554,37 @@ def forward(self, x): """ n = len(x) - covariance = torch.mm(self.matrix, torch.t(self.matrix)) if self.force_symmetric else self.matrix - - exp_returns_all = torch.repeat_interleave(self.exp_returns[None, ...], repeats=n, dim=0) - covariance_all = torch.repeat_interleave(covariance[None, ...], repeats=n, dim=0) - gamma_all = torch.ones(len(x)).to(device=x.device, dtype=x.dtype) * self.gamma_sqrt - alpha_all = torch.ones(len(x)).to(device=x.device, dtype=x.dtype) * self.alpha - - weights = self.portfolio_opt_layer(exp_returns_all, covariance_all, gamma_all, alpha_all) + covariance = ( + torch.mm(self.matrix, torch.t(self.matrix)) + if self.force_symmetric + else self.matrix + ) + + exp_returns_all = torch.repeat_interleave( + self.exp_returns[None, ...], repeats=n, dim=0 + ) + covariance_all = torch.repeat_interleave( + covariance[None, ...], repeats=n, dim=0 + ) + gamma_all = ( + torch.ones(len(x)).to(device=x.device, dtype=x.dtype) + * self.gamma_sqrt + ) + alpha_all = ( + torch.ones(len(x)).to(device=x.device, dtype=x.dtype) * self.alpha + ) + + weights = self.portfolio_opt_layer( + exp_returns_all, covariance_all, gamma_all, alpha_all + ) return weights @property def hparams(self): """Hyperparameters relevant to construction of the model.""" - return {k: v if isinstance(v, (int, float, str)) else str(v) for k, v in self._hparams.items() if k != 'self'} + return { + k: v if isinstance(v, (int, float, str)) else str(v) + for k, v in self._hparams.items() + if k != "self" + } diff --git a/deepdow/utils.py b/deepdow/utils.py index 03416f6..980f97d 100644 --- a/deepdow/utils.py +++ b/deepdow/utils.py @@ -22,9 +22,15 @@ class ChangeWorkingDirectory: """ def __init__(self, directory): - self.directory = pathlib.Path(directory) if directory is not None else pathlib.Path.cwd() + self.directory = ( + pathlib.Path(directory) + if directory is not None + else pathlib.Path.cwd() + ) if not self.directory.is_dir(): - raise NotADirectoryError('{} is not a directory'.format(str(self.directory))) + raise NotADirectoryError( + "{} is not a directory".format(str(self.directory)) + ) self._previous = pathlib.Path.cwd() @@ -59,12 +65,14 @@ def check_no_gaps(index): """ if not isinstance(index, pd.DatetimeIndex): - raise TypeError('Unsupported type: {}'.format(type(index))) + raise TypeError("Unsupported type: {}".format(type(index))) - correct_index = pd.date_range(index[0], periods=len(index), freq=index.freq) + correct_index = pd.date_range( + index[0], periods=len(index), freq=index.freq + ) if not correct_index.equals(index): - raise IndexError('Index has gaps.') + raise IndexError("Index has gaps.") @staticmethod def check_valid_entries(table): @@ -85,10 +93,10 @@ def check_valid_entries(table): """ if not isinstance(table, (pd.Series, pd.DataFrame)): - raise TypeError('Unsupported type: {}'.format(type(table))) + raise TypeError("Unsupported type: {}".format(type(table))) if not np.all(np.isfinite(table.values)): - raise ValueError('There is an invalid entry') + raise ValueError("There is an invalid entry") @staticmethod def check_indices_agree(*frames): @@ -109,16 +117,22 @@ def check_indices_agree(*frames): """ if not all([isinstance(x, (pd.Series, pd.DataFrame)) for x in frames]): - raise TypeError('Some elements are not pd.Series or pd.DataFrame') + raise TypeError("Some elements are not pd.Series or pd.DataFrame") reference_index = frames[0].index for i, f in enumerate(frames): if not f.index.equals(reference_index): - raise IndexError('The {} entry has wrong index: {}'.format(i, f.index)) + raise IndexError( + "The {} entry has wrong index: {}".format(i, f.index) + ) - if isinstance(f, pd.DataFrame) and not f.columns.equals(reference_index): - raise IndexError('The {} entry has wrong columns: {}'.format(i, f.columns)) + if isinstance(f, pd.DataFrame) and not f.columns.equals( + reference_index + ): + raise IndexError( + "The {} entry has wrong columns: {}".format(i, f.columns) + ) def prices_to_returns(prices, use_log=True): @@ -144,9 +158,13 @@ def prices_to_returns(prices, use_log=True): if use_log: values = np.log(prices.values) - np.log(prices.shift(1).values) else: - values = (prices.values - prices.shift(1).values) / prices.shift(1).values + values = (prices.values - prices.shift(1).values) / prices.shift( + 1 + ).values - return pd.DataFrame(values[1:, :], index=prices.index[1:], columns=prices.columns) + return pd.DataFrame( + values[1:, :], index=prices.index[1:], columns=prices.columns + ) def returns_to_Xy(returns, lookback=10, horizon=10, gap=0): @@ -182,16 +200,16 @@ def returns_to_Xy(returns, lookback=10, horizon=10, gap=0): n_timesteps = len(returns.index) if lookback >= n_timesteps - horizon - gap + 1: - raise ValueError('Not enough timesteps to extract X and y.') + raise ValueError("Not enough timesteps to extract X and y.") X_list = [] timestamps_list = [] y_list = [] for i in range(lookback, n_timesteps - horizon - gap + 1): - X_list.append(returns.iloc[i - lookback: i, :].values) + X_list.append(returns.iloc[i - lookback : i, :].values) timestamps_list.append(returns.index[i - 1]) - y_list.append(returns.iloc[i + gap: i + gap + horizon, :].values) + y_list.append(returns.iloc[i + gap : i + gap + horizon, :].values) X = np.array(X_list) timestamps = pd.DatetimeIndex(timestamps_list, freq=returns.index.freq) @@ -200,8 +218,16 @@ def returns_to_Xy(returns, lookback=10, horizon=10, gap=0): return X[:, np.newaxis, :, :], timestamps, y[:, np.newaxis, :, :] -def raw_to_Xy(raw_data, lookback=10, horizon=10, gap=0, freq='B', included_assets=None, included_indicators=None, - use_log=True): +def raw_to_Xy( + raw_data, + lookback=10, + horizon=10, + gap=0, + freq="B", + included_assets=None, + included_indicators=None, + use_log=True, +): """Convert raw data to features. Parameters @@ -249,12 +275,22 @@ def raw_to_Xy(raw_data, lookback=10, horizon=10, gap=0, freq='B', included_asset List of indicators. """ if freq is None: - raise ValueError('Frequency freq needs to be specified.') - - asset_names = included_assets if included_assets is not None else raw_data.columns.levels[0].to_list() - indicators = included_indicators if included_indicators is not None else raw_data.columns.levels[1].to_list() - - index = pd.date_range(start=raw_data.index[0], end=raw_data.index[-1], freq=freq) + raise ValueError("Frequency freq needs to be specified.") + + asset_names = ( + included_assets + if included_assets is not None + else raw_data.columns.levels[0].to_list() + ) + indicators = ( + included_indicators + if included_indicators is not None + else raw_data.columns.levels[1].to_list() + ) + + index = pd.date_range( + start=raw_data.index[0], end=raw_data.index[-1], freq=freq + ) new = pd.DataFrame(raw_data, index=index).ffill().bfill() @@ -266,18 +302,24 @@ def raw_to_Xy(raw_data, lookback=10, horizon=10, gap=0, freq='B', included_asset asset_names = sorted(list(set(asset_names) - set(to_exclude))) - absolute = new.iloc[:, new.columns.get_level_values(0).isin(asset_names)][asset_names] # sort - absolute = absolute.iloc[:, absolute.columns.get_level_values(1).isin(indicators)] + absolute = new.iloc[:, new.columns.get_level_values(0).isin(asset_names)][ + asset_names + ] # sort + absolute = absolute.iloc[ + :, absolute.columns.get_level_values(1).isin(indicators) + ] returns = prices_to_returns(absolute, use_log=use_log) X_list = [] y_list = [] for ind in indicators: - X, timestamps, y = (returns_to_Xy(returns.xs(ind, axis=1, level=1), - lookback=lookback, - horizon=horizon, - gap=gap)) + X, timestamps, y = returns_to_Xy( + returns.xs(ind, axis=1, level=1), + lookback=lookback, + horizon=horizon, + gap=gap, + ) X_list.append(X) y_list.append(y) diff --git a/deepdow/visualize.py b/deepdow/visualize.py index d2b5c01..1616bd2 100644 --- a/deepdow/visualize.py +++ b/deepdow/visualize.py @@ -15,7 +15,9 @@ from .losses import Loss, portfolio_cumulative_returns -def generate_metrics_table(benchmarks, dataloader, metrics, device=None, dtype=None): +def generate_metrics_table( + benchmarks, dataloader, metrics, device=None, dtype=None +): """Generate metrics table for all benchmarks. Parameters @@ -45,15 +47,17 @@ def generate_metrics_table(benchmarks, dataloader, metrics, device=None, dtype=N """ # checks if not all(isinstance(bm, Benchmark) for bm in benchmarks.values()): - raise TypeError('The values of benchmarks need to be of type Benchmark') + raise TypeError( + "The values of benchmarks need to be of type Benchmark" + ) if not isinstance(dataloader, RigidDataLoader): - raise TypeError('The type of dataloader needs to be RigidDataLoader') + raise TypeError("The type of dataloader needs to be RigidDataLoader") if not all(isinstance(metric, Loss) for metric in metrics.values()): - raise TypeError('The values of metrics need to be of type Loss') + raise TypeError("The values of metrics need to be of type Loss") - device = device or torch.device('cpu') + device = device or torch.device("cpu") dtype = dtype or torch.float for bm in benchmarks.values(): @@ -64,23 +68,38 @@ def generate_metrics_table(benchmarks, dataloader, metrics, device=None, dtype=N for batch_ix, (X_batch, y_batch, timestamps, _) in enumerate(dataloader): # Get batch - X_batch, y_batch = X_batch.to(device).to(dtype), y_batch.to(device).to(dtype) + X_batch, y_batch = X_batch.to(device).to(dtype), y_batch.to(device).to( + dtype + ) for bm_name, bm in benchmarks.items(): weights = bm(X_batch) for metric_name, metric in metrics.items(): metric_per_s = metric(weights, y_batch).detach().cpu().numpy() - all_entries.append(pd.DataFrame({'timestamp': timestamps, - 'benchmark': bm_name, - 'metric': metric_name, - 'value': metric_per_s})) + all_entries.append( + pd.DataFrame( + { + "timestamp": timestamps, + "benchmark": bm_name, + "metric": metric_name, + "value": metric_per_s, + } + ) + ) metrics_table = pd.concat(all_entries) return metrics_table -def generate_cumrets(benchmarks, dataloader, device=None, dtype=None, returns_channel=0, - input_type='log', output_type='log'): +def generate_cumrets( + benchmarks, + dataloader, + device=None, + dtype=None, + returns_channel=0, + input_type="log", + output_type="log", +): """Generate cumulative returns over the horizon for all benchmarks. Parameters @@ -115,12 +134,14 @@ def generate_cumrets(benchmarks, dataloader, device=None, dtype=None, returns_ch """ # checks if not all(isinstance(bm, Benchmark) for bm in benchmarks.values()): - raise TypeError('The values of benchmarks need to be of type Benchmark') + raise TypeError( + "The values of benchmarks need to be of type Benchmark" + ) if not isinstance(dataloader, RigidDataLoader): - raise TypeError('The type of dataloader needs to be RigidDataLoader') + raise TypeError("The type of dataloader needs to be RigidDataLoader") - device = device or torch.device('cpu') + device = device or torch.device("cpu") dtype = dtype or torch.float all_entries = {} @@ -131,18 +152,26 @@ def generate_cumrets(benchmarks, dataloader, device=None, dtype=None, returns_ch for batch_ix, (X_batch, y_batch, timestamps, _) in enumerate(dataloader): # Get batch - X_batch, y_batch = X_batch.to(device).to(dtype), y_batch.to(device).to(dtype) + X_batch, y_batch = X_batch.to(device).to(dtype), y_batch.to(device).to( + dtype + ) for bm_name, bm in benchmarks.items(): weights = bm(X_batch) - cumrets = portfolio_cumulative_returns(weights, - y_batch[:, returns_channel, ...], - input_type=input_type, - output_type=output_type) - - all_entries[bm_name].append(pd.DataFrame(cumrets.detach().cpu().numpy(), - index=timestamps)) - - cumrets_dict = {bm_name: pd.concat(entries).sort_index() for bm_name, entries in all_entries.items()} + cumrets = portfolio_cumulative_returns( + weights, + y_batch[:, returns_channel, ...], + input_type=input_type, + output_type=output_type, + ) + + all_entries[bm_name].append( + pd.DataFrame(cumrets.detach().cpu().numpy(), index=timestamps) + ) + + cumrets_dict = { + bm_name: pd.concat(entries).sort_index() + for bm_name, entries in all_entries.items() + } return cumrets_dict @@ -161,16 +190,18 @@ def plot_metrics(metrics_table): Axes with number of subaxes equal to number of metrics. """ - all_metrics = metrics_table['metric'].unique() + all_metrics = metrics_table["metric"].unique() n_metrics = len(all_metrics) _, axs = plt.subplots(n_metrics) for i, metric_name in enumerate(all_metrics): - df = pd.pivot_table(metrics_table[metrics_table['metric'] == metric_name], - values='value', - columns='benchmark', - index='timestamp').sort_index() + df = pd.pivot_table( + metrics_table[metrics_table["metric"] == metric_name], + values="value", + columns="benchmark", + index="timestamp", + ).sort_index() df.plot(ax=axs[i]) axs[i].set_title(metric_name) @@ -202,12 +233,14 @@ def generate_weights_table(network, dataloader, device=None, dtype=None): Index represents the timestep and column are different assets. The values are allocations. """ if not isinstance(network, Benchmark): - raise TypeError('The network needs to be an instance of a Benchmark') + raise TypeError("The network needs to be an instance of a Benchmark") if not isinstance(dataloader, RigidDataLoader): - raise TypeError('The network needs to be an instance of a RigidDataloader') + raise TypeError( + "The network needs to be an instance of a RigidDataloader" + ) - device = device or torch.device('cpu') + device = device or torch.device("cpu") dtype = dtype or torch.float if isinstance(network, torch.nn.Module): @@ -225,17 +258,27 @@ def generate_weights_table(network, dataloader, device=None, dtype=None): all_timestamps.extend(timestamps) weights = np.concatenate(all_batches, axis=0) - asset_names = [dataloader.dataset.asset_names[asset_ix] for asset_ix in dataloader.asset_ixs] + asset_names = [ + dataloader.dataset.asset_names[asset_ix] + for asset_ix in dataloader.asset_ixs + ] - weights_table = pd.DataFrame(weights, - index=all_timestamps, - columns=asset_names) + weights_table = pd.DataFrame( + weights, index=all_timestamps, columns=asset_names + ) return weights_table.sort_index() -def plot_weight_anim(weights, always_visible=None, n_displayed_assets=None, n_seconds=3, figsize=(10, 10), - colors=None, autopct='%1.1f%%'): +def plot_weight_anim( + weights, + always_visible=None, + n_displayed_assets=None, + n_seconds=3, + figsize=(10, 10), + colors=None, + autopct="%1.1f%%", +): """Visualize portfolio evolution over time with pie charts. Parameters @@ -273,14 +316,16 @@ def plot_weight_anim(weights, always_visible=None, n_displayed_assets=None, n_se Animated piechart over the time dimension. """ - if 'others' in weights.columns: - raise ValueError('Cannot use an asset named others since it is user internally.') + if "others" in weights.columns: + raise ValueError( + "Cannot use an asset named others since it is user internally." + ) n_timesteps, n_assets = weights.shape n_displayed_assets = n_displayed_assets or n_assets if not n_displayed_assets <= weights.shape[1]: - raise ValueError('Invalid number of assets.') + raise ValueError("Invalid number of assets.") fps = n_timesteps / n_seconds interval = (1 / fps) * 1000 @@ -288,9 +333,14 @@ def plot_weight_anim(weights, always_visible=None, n_displayed_assets=None, n_se always_visible = always_visible or [] if n_displayed_assets <= len(always_visible): - raise ValueError('Too many always visible assets.') + raise ValueError("Too many always visible assets.") - top_assets = weights.sum(0).sort_values(ascending=False).index[:n_displayed_assets].to_list() + top_assets = ( + weights.sum(0) + .sort_values(ascending=False) + .index[:n_displayed_assets] + .to_list() + ) for a in reversed(always_visible): if a not in top_assets: @@ -300,11 +350,11 @@ def plot_weight_anim(weights, always_visible=None, n_displayed_assets=None, n_se remaining_assets = [a for a in weights.columns if a not in top_assets] new_weights = weights[top_assets].copy() - new_weights['others'] = weights[remaining_assets].sum(1) + new_weights["others"] = weights[remaining_assets].sum(1) # create animation fig, ax = plt.subplots(figsize=figsize) - plt.axis('off') + plt.axis("off") labels = new_weights.columns @@ -312,7 +362,7 @@ def plot_weight_anim(weights, always_visible=None, n_displayed_assets=None, n_se colors_ = None elif isinstance(colors, dict): - colors_ = [colors.get(label, 'black') for label in labels] + colors_ = [colors.get(label, "black") for label in labels] elif isinstance(colors, cm.colors.ListedColormap): colors_ = cycle(colors.colors) @@ -320,21 +370,30 @@ def plot_weight_anim(weights, always_visible=None, n_displayed_assets=None, n_se def update(i): """Update function.""" ax.clear() # pragma: no cover - ax.axis('equal') # pragma: no cover + ax.axis("equal") # pragma: no cover values = new_weights.iloc[i].values # pragma: no cover - ax.pie(values, labels=labels, colors=colors_, autopct=autopct) # pragma: no cover + ax.pie( + values, labels=labels, colors=colors_, autopct=autopct + ) # pragma: no cover ax.set_title(new_weights.iloc[i].name) # pragma: no cover - ani = FuncAnimation(fig, - update, - frames=n_timesteps, - interval=interval) + ani = FuncAnimation(fig, update, frames=n_timesteps, interval=interval) return ani -def plot_weight_heatmap(weights, add_sum_column=False, cmap="YlGnBu", ax=None, always_visible=None, - asset_skips=1, time_skips=1, time_format='%d-%m-%Y', vmin=0, vmax=1): +def plot_weight_heatmap( + weights, + add_sum_column=False, + cmap="YlGnBu", + ax=None, + always_visible=None, + asset_skips=1, + time_skips=1, + time_format="%d-%m-%Y", + vmin=0, + vmax=1, +): """Create a heatmap out of the weights. Parameters @@ -376,15 +435,21 @@ def plot_weight_heatmap(weights, add_sum_column=False, cmap="YlGnBu", ax=None, a always_visible = always_visible or [] if add_sum_column: - if 'sum' in displayed_table.columns: - raise ValueError("The weights dataframe already contains the sum column.") + if "sum" in displayed_table.columns: + raise ValueError( + "The weights dataframe already contains the sum column." + ) displayed_table = displayed_table.copy() - displayed_table['sum'] = displayed_table.sum(axis=1) - always_visible.append('sum') + displayed_table["sum"] = displayed_table.sum(axis=1) + always_visible.append("sum") - xlab = [str(c) if ((asset_skips and i % asset_skips == 0) or c in always_visible) else "" for i, c in - enumerate(weights.columns)] + xlab = [ + str(c) + if ((asset_skips and i % asset_skips == 0) or c in always_visible) + else "" + for i, c in enumerate(weights.columns) + ] def formatter(x): """Format row index.""" @@ -393,20 +458,23 @@ def formatter(x): else: return x - ylab = [formatter(ix) if (time_skips and i % time_skips == 0) else "" for i, ix in - enumerate(weights.index)] - - return_ax = sns.heatmap(displayed_table, - vmin=vmin, - vmax=vmax, - cmap=cmap, - ax=ax, - xticklabels=xlab, - yticklabels=ylab, - ) - - return_ax.xaxis.set_ticks_position('top') - return_ax.tick_params(axis='x', rotation=75, length=0) - return_ax.tick_params(axis='y', rotation=0, length=0) + ylab = [ + formatter(ix) if (time_skips and i % time_skips == 0) else "" + for i, ix in enumerate(weights.index) + ] + + return_ax = sns.heatmap( + displayed_table, + vmin=vmin, + vmax=vmax, + cmap=cmap, + ax=ax, + xticklabels=xlab, + yticklabels=ylab, + ) + + return_ax.xaxis.set_ticks_position("top") + return_ax.tick_params(axis="x", rotation=75, length=0) + return_ax.tick_params(axis="y", rotation=0, length=0) return return_ax diff --git a/setup.cfg b/setup.cfg index ffbb94f..aba6af6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,9 @@ [flake8] count = True max-line-length = 120 +ignore = + E203 + W503 [pydocstyle] convention = numpy diff --git a/tests/conftest.py b/tests/conftest.py index 1e41b2c..a8a41d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,9 @@ GPU_AVAILABLE = torch.cuda.is_available() -@pytest.fixture(scope='session', params=['B', 'M'], ids=['true_freq=B', 'true_freq=M']) +@pytest.fixture( + scope="session", params=["B", "M"], ids=["true_freq=B", "true_freq=M"] +) def raw_data(request): """Could represent prices, volumes,... Only positive values are allowed. @@ -36,25 +38,40 @@ def raw_data(request): n_missing_entries = 3 true_freq = request.param - missing_ixs = np.random.choice(list(range(1, n_timestamps - 1)), replace=False, size=n_missing_entries) - - index_full = pd.date_range('1/1/2000', periods=n_timestamps, freq=true_freq) - index = pd.DatetimeIndex([x for ix, x in enumerate(index_full) if ix not in missing_ixs]) # freq=None - - columns = pd.MultiIndex.from_product([['asset_{}'.format(i) for i in range(n_assets)], - ['indicator_{}'.format(i) for i in range(n_indicators)]], - names=['assets', 'indicators']) - - df = pd.DataFrame(np.random.randint(low=1, - high=1000, - size=(n_timestamps - n_missing_entries, n_assets * n_indicators)) / 100, - index=index, - columns=columns) + missing_ixs = np.random.choice( + list(range(1, n_timestamps - 1)), replace=False, size=n_missing_entries + ) + + index_full = pd.date_range( + "1/1/2000", periods=n_timestamps, freq=true_freq + ) + index = pd.DatetimeIndex( + [x for ix, x in enumerate(index_full) if ix not in missing_ixs] + ) # freq=None + + columns = pd.MultiIndex.from_product( + [ + ["asset_{}".format(i) for i in range(n_assets)], + ["indicator_{}".format(i) for i in range(n_indicators)], + ], + names=["assets", "indicators"], + ) + + df = pd.DataFrame( + np.random.randint( + low=1, + high=1000, + size=(n_timestamps - n_missing_entries, n_assets * n_indicators), + ) + / 100, + index=index, + columns=columns, + ) return df, n_missing_entries, true_freq -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def dataset_dummy(): """Minimal instance of ``InRAMDataset``. @@ -69,11 +86,14 @@ def dataset_dummy(): horizon = 10 n_assets = 6 - X = np.random.normal(size=(n_samples, n_channels, lookback, n_assets)) / 100 + X = ( + np.random.normal(size=(n_samples, n_channels, lookback, n_assets)) + / 100 + ) y = np.random.normal(size=(n_samples, n_channels, horizon, n_assets)) / 100 - timestamps = pd.date_range(start='31/01/2000', periods=n_samples, freq='M') - asset_names = ['asset_{}'.format(i) for i in range(n_assets)] + timestamps = pd.date_range(start="31/01/2000", periods=n_samples, freq="M") + asset_names = ["asset_{}".format(i) for i in range(n_assets)] return InRAMDataset(X, y, timestamps=timestamps, asset_names=asset_names) @@ -93,20 +113,25 @@ def dataloader_dummy(dataset_dummy): """ batch_size = 4 - return RigidDataLoader(dataset_dummy, - batch_size=batch_size) - - -@pytest.fixture(params=[ - pytest.param((torch.float32, torch.device('cpu')), id='float32_cpu'), - pytest.param((torch.float64, torch.device('cpu')), id='float64_cpu'), - pytest.param((torch.float32, torch.device('cuda:0')), - id='float32_gpu', - marks=[] if GPU_AVAILABLE else pytest.mark.skip), - pytest.param((torch.float64, torch.device('cuda:0')), - id='float64_gpu', - marks=[] if GPU_AVAILABLE else pytest.mark.skip), -]) + return RigidDataLoader(dataset_dummy, batch_size=batch_size) + + +@pytest.fixture( + params=[ + pytest.param((torch.float32, torch.device("cpu")), id="float32_cpu"), + pytest.param((torch.float64, torch.device("cpu")), id="float64_cpu"), + pytest.param( + (torch.float32, torch.device("cuda:0")), + id="float32_gpu", + marks=[] if GPU_AVAILABLE else pytest.mark.skip, + ), + pytest.param( + (torch.float64, torch.device("cuda:0")), + id="float64_gpu", + marks=[] if GPU_AVAILABLE else pytest.mark.skip, + ), + ] +) def dtype_device(request): dtype, device = request.param return dtype, device @@ -117,7 +142,12 @@ def Xy_dummy(dtype_device, dataloader_dummy): dtype, device = dtype_device X, y, timestamps, asset_names = next(iter(dataloader_dummy)) - return X.to(dtype=dtype, device=device), y.to(dtype=dtype, device=device), timestamps, asset_names + return ( + X.to(dtype=dtype, device=device), + y.to(dtype=dtype, device=device), + timestamps, + asset_names, + ) @pytest.fixture() @@ -133,11 +163,15 @@ def run_dummy(dataloader_dummy, network_dummy, Xy_dummy): device = X_batch.device dtype = X_batch.dtype - return Run(network_dummy, MeanReturns(), dataloader_dummy, - val_dataloaders={'val': dataloader_dummy}, - benchmarks={'bm': OneOverN()}, - device=device, - dtype=dtype) + return Run( + network_dummy, + MeanReturns(), + dataloader_dummy, + val_dataloaders={"val": dataloader_dummy}, + benchmarks={"bm": OneOverN()}, + device=device, + dtype=dtype, + ) @pytest.fixture @@ -150,14 +184,16 @@ def metadata_dummy(Xy_dummy, network_dummy): network_dummy.to(device=device, dtype=dtype) - return {'asset_names': asset_names, - 'batch': 1, - 'batch_loss': 1.4, - 'epoch': 1, - 'exception': ValueError, - 'locals': {'a': 2}, - 'n_epochs': 2, - 'timestamps': timestamps, - 'weights': network_dummy(X_batch), - 'X_batch': X_batch, - 'y_batch': y_batch} + return { + "asset_names": asset_names, + "batch": 1, + "batch_loss": 1.4, + "epoch": 1, + "exception": ValueError, + "locals": {"a": 2}, + "n_epochs": 2, + "timestamps": timestamps, + "weights": network_dummy(X_batch), + "X_batch": X_batch, + "y_batch": y_batch, + } diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index dc35f04..3eb643f 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -2,7 +2,15 @@ import pytest import torch -from deepdow.benchmarks import Benchmark, InverseVolatility, MaximumReturn, MinimumVariance, OneOverN, Random, Singleton +from deepdow.benchmarks import ( + Benchmark, + InverseVolatility, + MaximumReturn, + MinimumVariance, + OneOverN, + Random, + Singleton, +) class TestBenchmark: @@ -28,7 +36,9 @@ def __call__(self, X): class TestInverseVolatility: - @pytest.mark.parametrize('use_std', [True, False], ids=['use_std', 'use_var']) + @pytest.mark.parametrize( + "use_std", [True, False], ids=["use_std", "use_var"] + ) def test_basic(self, Xy_dummy, use_std): X_dummy, _, _, _ = Xy_dummy n_samples, n_channels, lookback, n_assets = X_dummy.shape @@ -42,15 +52,23 @@ def test_basic(self, Xy_dummy, use_std): assert weights.shape == (n_samples, n_assets) assert weights.dtype == dtype assert weights.device == device - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device)) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + ) assert torch.all(weights >= 0) and torch.all(weights <= 1) assert isinstance(bm.hparams, dict) and bm.hparams class TestMaximumReturn: - - @pytest.mark.parametrize('max_weight', [1, 0.5], ids=['max_weight=1', 'max_weight=0.5']) - @pytest.mark.parametrize('predefined_assets', [True, False], ids=['fixed_assets', 'nonfixed_assets']) + @pytest.mark.parametrize( + "max_weight", [1, 0.5], ids=["max_weight=1", "max_weight=0.5"] + ) + @pytest.mark.parametrize( + "predefined_assets", + [True, False], + ids=["fixed_assets", "nonfixed_assets"], + ) def test_basic(self, Xy_dummy, predefined_assets, max_weight): X_dummy, _, _, _ = Xy_dummy eps = 1e-3 @@ -60,8 +78,10 @@ def test_basic(self, Xy_dummy, predefined_assets, max_weight): X_more_assets = torch.cat([X_dummy, X_dummy], dim=-1) - bm = MaximumReturn(n_assets=n_assets if predefined_assets else None, - max_weight=max_weight) + bm = MaximumReturn( + n_assets=n_assets if predefined_assets else None, + max_weight=max_weight, + ) weights = bm(X_dummy) @@ -69,8 +89,14 @@ def test_basic(self, Xy_dummy, predefined_assets, max_weight): assert weights.shape == (n_samples, n_assets) assert weights.dtype == dtype assert weights.device == device - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=1e-4) - assert torch.all(-eps <= weights) and torch.all(weights <= max_weight + eps) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=1e-4, + ) + assert torch.all(-eps <= weights) and torch.all( + weights <= max_weight + eps + ) assert isinstance(bm.hparams, dict) and bm.hparams if predefined_assets: @@ -83,8 +109,14 @@ def test_basic(self, Xy_dummy, predefined_assets, max_weight): class TestMinimumVariance: - @pytest.mark.parametrize('max_weight', [1, 0.5], ids=['max_weight=1', 'max_weight=0.5']) - @pytest.mark.parametrize('predefined_assets', [True, False], ids=['fixed_assets', 'nonfixed_assets']) + @pytest.mark.parametrize( + "max_weight", [1, 0.5], ids=["max_weight=1", "max_weight=0.5"] + ) + @pytest.mark.parametrize( + "predefined_assets", + [True, False], + ids=["fixed_assets", "nonfixed_assets"], + ) def test_basic(self, Xy_dummy, predefined_assets, max_weight): X_dummy, _, _, _ = Xy_dummy eps = 1e-4 @@ -94,8 +126,10 @@ def test_basic(self, Xy_dummy, predefined_assets, max_weight): X_more_assets = torch.cat([X_dummy, X_dummy], dim=-1) - bm = MinimumVariance(n_assets=n_assets if predefined_assets else None, - max_weight=max_weight) + bm = MinimumVariance( + n_assets=n_assets if predefined_assets else None, + max_weight=max_weight, + ) weights = bm(X_dummy) @@ -103,8 +137,14 @@ def test_basic(self, Xy_dummy, predefined_assets, max_weight): assert weights.shape == (n_samples, n_assets) assert weights.dtype == dtype assert weights.device == device - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=1e-4) - assert torch.all(-eps <= weights) and torch.all(weights <= max_weight + eps) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=1e-4, + ) + assert torch.all(-eps <= weights) and torch.all( + weights <= max_weight + eps + ) assert isinstance(bm.hparams, dict) and bm.hparams if predefined_assets: @@ -129,7 +169,10 @@ def test_basic(self, Xy_dummy): assert isinstance(weights, torch.Tensor) assert weights.dtype == dtype assert weights.device == device - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device)) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + ) assert len(torch.unique(weights)) == 1 assert isinstance(bm.hparams, dict) and not bm.hparams @@ -148,15 +191,17 @@ def test_basic(self, Xy_dummy): assert weights.shape == (n_samples, n_assets) assert weights.dtype == dtype assert weights.device == device - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device)) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + ) assert torch.all(weights >= 0) and torch.all(weights <= 1) assert isinstance(bm.hparams, dict) and not bm.hparams class TestSingleton: - - @pytest.mark.parametrize('asset_ix', [0, 3]) + @pytest.mark.parametrize("asset_ix", [0, 3]) def test_basic(self, asset_ix, Xy_dummy): X_dummy, _, _, _ = Xy_dummy n_samples, n_channels, lookback, n_assets = X_dummy.shape @@ -169,9 +214,15 @@ def test_basic(self, asset_ix, Xy_dummy): assert weights.shape == (n_samples, n_assets) assert weights.dtype == dtype assert weights.device == device - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device)) - - assert torch.allclose(weights[:, asset_ix], torch.ones(n_samples).to(dtype=dtype, device=device)) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + ) + + assert torch.allclose( + weights[:, asset_ix], + torch.ones(n_samples).to(dtype=dtype, device=device), + ) assert isinstance(bm.hparams, dict) and bm.hparams def test_error(self): diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 097fcef..bcda4c0 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -5,18 +5,27 @@ import pandas as pd import pytest -from deepdow.callbacks import (BenchmarkCallback, Callback, EarlyStoppingCallback, EarlyStoppingException, - ModelCheckpointCallback, MLFlowCallback, ProgressBarCallback, TensorBoardCallback, - ValidationCallback) - -ALL_METHODS = ['on_train_begin', - 'on_epoch_begin', - 'on_batch_begin', - 'on_train_interrupt', - 'on_batch_end', - 'on_epoch_end', - 'on_train_end', - ] +from deepdow.callbacks import ( + BenchmarkCallback, + Callback, + EarlyStoppingCallback, + EarlyStoppingException, + ModelCheckpointCallback, + MLFlowCallback, + ProgressBarCallback, + TensorBoardCallback, + ValidationCallback, +) + +ALL_METHODS = [ + "on_train_begin", + "on_epoch_begin", + "on_batch_begin", + "on_train_interrupt", + "on_batch_end", + "on_epoch_end", + "on_train_end", +] ALL_CALLBACKS = [ BenchmarkCallback, @@ -24,10 +33,11 @@ MLFlowCallback, ProgressBarCallback, TensorBoardCallback, - ValidationCallback] + ValidationCallback, +] -@pytest.mark.parametrize('lookbacks', [None, [2, 3]]) +@pytest.mark.parametrize("lookbacks", [None, [2, 3]]) def test_benchmark(run_dummy, metadata_dummy, lookbacks): cb = BenchmarkCallback(lookbacks) cb.run = run_dummy @@ -39,7 +49,7 @@ def test_benchmark(run_dummy, metadata_dummy, lookbacks): getattr(run_dummy, method_name)(metadata_dummy) assert isinstance(run_dummy.history.metrics_per_epoch(-1), pd.DataFrame) - assert len(run_dummy.history.metrics['epoch'].unique()) == 1 + assert len(run_dummy.history.metrics["epoch"].unique()) == 1 class TestEarlyStoppingCallback: @@ -47,10 +57,12 @@ def test_error(self, run_dummy, metadata_dummy): dataloader_name = list(run_dummy.val_dataloaders.keys())[0] metric_name = list(run_dummy.metrics.keys())[0] - cb_wrong_dataloader = EarlyStoppingCallback(dataloader_name='fake', - metric_name=metric_name) - cb_wrong_metric = EarlyStoppingCallback(dataloader_name=dataloader_name, - metric_name='fake') + cb_wrong_dataloader = EarlyStoppingCallback( + dataloader_name="fake", metric_name=metric_name + ) + cb_wrong_metric = EarlyStoppingCallback( + dataloader_name=dataloader_name, metric_name="fake" + ) cb_wrong_dataloader.run = run_dummy cb_wrong_metric.run = run_dummy @@ -65,32 +77,48 @@ def test_basic(self, run_dummy, metadata_dummy): dataloader_name = list(run_dummy.val_dataloaders.keys())[0] metric_name = list(run_dummy.metrics.keys())[0] - cb = EarlyStoppingCallback(dataloader_name=dataloader_name, - metric_name=metric_name, - patience=0) + cb = EarlyStoppingCallback( + dataloader_name=dataloader_name, + metric_name=metric_name, + patience=0, + ) cb.run = run_dummy cb_val = ValidationCallback() cb_val.run = run_dummy - run_dummy.callbacks = [cb_val, cb] # make sure there are no default callbacks + run_dummy.callbacks = [ + cb_val, + cb, + ] # make sure there are no default callbacks with pytest.raises(EarlyStoppingException): for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) - cb.on_train_interrupt({'exception': EarlyStoppingException()}) + cb.on_train_interrupt({"exception": EarlyStoppingException()}) class TestMLFlowCallback: - @pytest.mark.parametrize('log_benchmarks', [True, False], ids=['log_bmarks', 'dont_log_bmarks']) - def test_independent(self, run_dummy, metadata_dummy, tmpdir, log_benchmarks): + @pytest.mark.parametrize( + "log_benchmarks", [True, False], ids=["log_bmarks", "dont_log_bmarks"] + ) + def test_independent( + self, run_dummy, metadata_dummy, tmpdir, log_benchmarks + ): with pytest.raises(ValueError): - MLFlowCallback(run_name='name', run_id='some_id', log_benchmarks=log_benchmarks, - mlflow_path=pathlib.Path(str(tmpdir))) - - cb = MLFlowCallback(mlflow_path=pathlib.Path(str(tmpdir)), experiment_name='test', - log_benchmarks=log_benchmarks) + MLFlowCallback( + run_name="name", + run_id="some_id", + log_benchmarks=log_benchmarks, + mlflow_path=pathlib.Path(str(tmpdir)), + ) + + cb = MLFlowCallback( + mlflow_path=pathlib.Path(str(tmpdir)), + experiment_name="test", + log_benchmarks=log_benchmarks, + ) cb.run = run_dummy run_dummy.callbacks = [cb] # make sure there are no default callbacks @@ -99,25 +127,39 @@ def test_independent(self, run_dummy, metadata_dummy, tmpdir, log_benchmarks): getattr(run_dummy, method_name)(metadata_dummy) def test_benchmarks(self, run_dummy, metadata_dummy, tmpdir): - cb = MLFlowCallback(mlflow_path=pathlib.Path(str(tmpdir)), experiment_name='test', log_benchmarks=True) + cb = MLFlowCallback( + mlflow_path=pathlib.Path(str(tmpdir)), + experiment_name="test", + log_benchmarks=True, + ) cb_bm = BenchmarkCallback() cb.run = run_dummy cb_bm.run = run_dummy - run_dummy.callbacks = [cb_bm, cb] # make sure there are no default callbacks + run_dummy.callbacks = [ + cb_bm, + cb, + ] # make sure there are no default callbacks for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) def test_validation(self, run_dummy, metadata_dummy, tmpdir): - cb = MLFlowCallback(mlflow_path=pathlib.Path(str(tmpdir)), experiment_name='test', log_benchmarks=False) + cb = MLFlowCallback( + mlflow_path=pathlib.Path(str(tmpdir)), + experiment_name="test", + log_benchmarks=False, + ) cb_val = ValidationCallback() cb.run = run_dummy cb_val.run = run_dummy - run_dummy.callbacks = [cb_val, cb] # make sure there are no default callbacks + run_dummy.callbacks = [ + cb_val, + cb, + ] # make sure there are no default callbacks for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) @@ -129,20 +171,22 @@ def test_error(self, run_dummy, metadata_dummy, tmpdir): metric_name = list(run_dummy.metrics.keys())[0] folder_path = pathlib.Path(str(tmpdir)) - some_file_path = folder_path / 'some_file.txt' + some_file_path = folder_path / "some_file.txt" some_file_path.touch() with pytest.raises(NotADirectoryError): - ModelCheckpointCallback(folder_path=some_file_path, - dataloader_name=dataloader_name, - metric_name=metric_name) - - cb_wrong_dataloader = ModelCheckpointCallback(folder_path, - dataloader_name='fake', - metric_name=metric_name) - cb_wrong_metric = ModelCheckpointCallback(folder_path, - dataloader_name=dataloader_name, - metric_name='fake') + ModelCheckpointCallback( + folder_path=some_file_path, + dataloader_name=dataloader_name, + metric_name=metric_name, + ) + + cb_wrong_dataloader = ModelCheckpointCallback( + folder_path, dataloader_name="fake", metric_name=metric_name + ) + cb_wrong_metric = ModelCheckpointCallback( + folder_path, dataloader_name=dataloader_name, metric_name="fake" + ) cb_wrong_dataloader.run = run_dummy cb_wrong_metric.run = run_dummy @@ -157,28 +201,33 @@ def test_basic(self, run_dummy, metadata_dummy, tmpdir): dataloader_name = list(run_dummy.val_dataloaders.keys())[0] metric_name = list(run_dummy.metrics.keys())[0] - cb = ModelCheckpointCallback(folder_path=pathlib.Path(str(tmpdir)), - dataloader_name=dataloader_name, - metric_name=metric_name, - verbose=True) + cb = ModelCheckpointCallback( + folder_path=pathlib.Path(str(tmpdir)), + dataloader_name=dataloader_name, + metric_name=metric_name, + verbose=True, + ) cb.run = run_dummy cb_val = ValidationCallback() cb_val.run = run_dummy - run_dummy.callbacks = [cb_val, cb] # make sure there are no default callbacks + run_dummy.callbacks = [ + cb_val, + cb, + ] # make sure there are no default callbacks for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) - cb.on_train_interrupt({'exception': EarlyStoppingException()}) + cb.on_train_interrupt({"exception": EarlyStoppingException()}) class TestProgressBarCallback: - @pytest.mark.parametrize('output', ['stderr', 'stdout']) + @pytest.mark.parametrize("output", ["stderr", "stdout"]) def test_independent(self, run_dummy, metadata_dummy, output): with pytest.raises(ValueError): - ProgressBarCallback(output='{}_fake'.format(output)) + ProgressBarCallback(output="{}_fake".format(output)) cb = ProgressBarCallback(output=output) @@ -196,46 +245,60 @@ def test_validation(self, run_dummy, metadata_dummy): cb.run = run_dummy cb_val.run = run_dummy - run_dummy.callbacks = [cb_val, cb] # make sure there are no default callbacks + run_dummy.callbacks = [ + cb_val, + cb, + ] # make sure there are no default callbacks for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) class TestTensorBoardCallback: - @pytest.mark.parametrize('ts_type', ['single_inside', 'single_outside', 'all']) + @pytest.mark.parametrize( + "ts_type", ["single_inside", "single_outside", "all"] + ) def test_independent(self, run_dummy, metadata_dummy, tmpdir, ts_type): - if ts_type == 'single_inside': - ts = metadata_dummy['timestamps'][0] - elif ts_type == 'single_outside': + if ts_type == "single_inside": + ts = metadata_dummy["timestamps"][0] + elif ts_type == "single_outside": ts = datetime.datetime.now() - elif ts_type == 'all': + elif ts_type == "all": ts = None else: ValueError() - cb = TensorBoardCallback(log_dir=pathlib.Path(str(tmpdir)), - ts=ts) + cb = TensorBoardCallback(log_dir=pathlib.Path(str(tmpdir)), ts=ts) cb.run = run_dummy run_dummy.callbacks = [cb] # make sure there are no default callbacks for method_name in ALL_METHODS: - if method_name == 'on_batch_end': - run_dummy.network(metadata_dummy['X_batch']) # let the forward hook take effect + if method_name == "on_batch_end": + run_dummy.network( + metadata_dummy["X_batch"] + ) # let the forward hook take effect getattr(run_dummy, method_name)(metadata_dummy) - @pytest.mark.parametrize('bm_available', [True, False], ids=['bmarks_available', 'bmarks_unavailable']) + @pytest.mark.parametrize( + "bm_available", + [True, False], + ids=["bmarks_available", "bmarks_unavailable"], + ) def test_benchmark(self, run_dummy, metadata_dummy, bm_available, tmpdir): - cb = TensorBoardCallback(log_benchmarks=True, log_dir=pathlib.Path(str(tmpdir))) + cb = TensorBoardCallback( + log_benchmarks=True, log_dir=pathlib.Path(str(tmpdir)) + ) cb_bm = BenchmarkCallback() cb.run = run_dummy cb_bm.run = run_dummy - run_dummy.callbacks = [cb_bm, cb] if bm_available else [cb] # make sure there are no default callbacks + run_dummy.callbacks = ( + [cb_bm, cb] if bm_available else [cb] + ) # make sure there are no default callbacks for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) @@ -247,13 +310,16 @@ def test_validation(self, run_dummy, metadata_dummy, tmpdir): cb.run = run_dummy cb_val.run = run_dummy - run_dummy.callbacks = [cb_val, cb] # make sure there are no default callbacks + run_dummy.callbacks = [ + cb_val, + cb, + ] # make sure there are no default callbacks for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) -@pytest.mark.parametrize('lookbacks', [None, [2, 3]]) +@pytest.mark.parametrize("lookbacks", [None, [2, 3]]) def test_validation(run_dummy, metadata_dummy, lookbacks): cb = ValidationCallback(lookbacks=lookbacks) cb.run = run_dummy @@ -263,5 +329,8 @@ def test_validation(run_dummy, metadata_dummy, lookbacks): for method_name in ALL_METHODS: getattr(run_dummy, method_name)(metadata_dummy) - assert isinstance(run_dummy.history.metrics_per_epoch(metadata_dummy['epoch']), pd.DataFrame) - assert len(run_dummy.history.metrics['epoch'].unique()) == 1 + assert isinstance( + run_dummy.history.metrics_per_epoch(metadata_dummy["epoch"]), + pd.DataFrame, + ) + assert len(run_dummy.history.metrics["epoch"].unique()) == 1 diff --git a/tests/test_data/test_augment.py b/tests/test_data/test_augment.py index a6ac01c..a898318 100644 --- a/tests/test_data/test_augment.py +++ b/tests/test_data/test_augment.py @@ -4,17 +4,32 @@ import pytest import torch -from deepdow.data import (Compose, Dropout, Multiply, Noise, Scale, prepare_robust_scaler, - prepare_standard_scaler) - - -@pytest.mark.parametrize('tform', [Compose([lambda a, b, c, d: (2 * a, b, c, d), - lambda a, b, c, d: (3 + a, b, c, d)]), - Dropout(p=0.5), - Multiply(c=4), - Noise(0.3), - Scale(np.array([1.2]), np.array([5.7])), - ]) +from deepdow.data import ( + Compose, + Dropout, + Multiply, + Noise, + Scale, + prepare_robust_scaler, + prepare_standard_scaler, +) + + +@pytest.mark.parametrize( + "tform", + [ + Compose( + [ + lambda a, b, c, d: (2 * a, b, c, d), + lambda a, b, c, d: (3 + a, b, c, d), + ] + ), + Dropout(p=0.5), + Multiply(c=4), + Noise(0.3), + Scale(np.array([1.2]), np.array([5.7])), + ], +) def test_tforms_not_in_place_for_x(tform): X = torch.randn(1, 4, 5) X_orig = X.clone() @@ -26,8 +41,8 @@ def test_tforms_not_in_place_for_x(tform): assert X_after.shape == X.shape -@pytest.mark.parametrize('overlap', [True, False]) -@pytest.mark.parametrize('indices', [None, [1, 4, 6]]) +@pytest.mark.parametrize("overlap", [True, False]) +@pytest.mark.parametrize("indices", [None, [1, 4, 6]]) def test_prepare_standard_scaler(overlap, indices): n_samples, n_channels, lookback, n_assets = 10, 3, 5, 12 @@ -41,22 +56,27 @@ def test_prepare_standard_scaler(overlap, indices): class TestPrepareRobustScaler: - def test_error(self): with pytest.raises(ValueError): - prepare_robust_scaler(np.ones((1, 2, 3, 4)), percentile_range=(20, 10)) + prepare_robust_scaler( + np.ones((1, 2, 3, 4)), percentile_range=(20, 10) + ) with pytest.raises(ValueError): - prepare_robust_scaler(np.ones((1, 2, 3, 4)), percentile_range=(-2, 99)) + prepare_robust_scaler( + np.ones((1, 2, 3, 4)), percentile_range=(-2, 99) + ) - @pytest.mark.parametrize('overlap', [True, False]) - @pytest.mark.parametrize('indices', [None, [1, 4, 6]]) + @pytest.mark.parametrize("overlap", [True, False]) + @pytest.mark.parametrize("indices", [None, [1, 4, 6]]) def test_basic(self, overlap, indices): n_samples, n_channels, lookback, n_assets = 10, 3, 5, 12 X = np.random.random((n_samples, n_channels, lookback, n_assets)) - 0.5 - medians, ranges = prepare_robust_scaler(X, overlap=overlap, indices=indices) + medians, ranges = prepare_robust_scaler( + X, overlap=overlap, indices=indices + ) assert medians.shape == (n_channels,) assert ranges.shape == (n_channels,) @@ -67,8 +87,12 @@ def test_sanity(self): X = np.random.random((n_samples, n_channels, lookback, n_assets)) - 0.5 - medians_1, ranges_1 = prepare_robust_scaler(X, percentile_range=(20, 80)) - medians_2, ranges_2 = prepare_robust_scaler(X, percentile_range=(10, 90)) + medians_1, ranges_1 = prepare_robust_scaler( + X, percentile_range=(20, 80) + ) + medians_2, ranges_2 = prepare_robust_scaler( + X, percentile_range=(10, 90) + ) assert np.all(ranges_2 > ranges_1) @@ -81,7 +105,7 @@ def test_erorrs(self): with pytest.raises(ValueError): raise Scale(np.array([1, -1]), np.array([9, -0.1])) - tform = Scale(np.array([1, -1]), np.array([9, 10.])) + tform = Scale(np.array([1, -1]), np.array([9, 10.0])) with pytest.raises(ValueError): tform(torch.rand(3, 4, 5), None, None, None) @@ -93,7 +117,9 @@ def test_overall(self): dtype = X_torch.dtype center = X.mean(axis=(1, 2)) - scale = X.std(axis=(1, 2), ) + scale = X.std( + axis=(1, 2), + ) tform = Scale(center, scale) X_scaled = tform(X_torch, None, None, None)[0] @@ -101,5 +127,10 @@ def test_overall(self): assert torch.is_tensor(X_scaled) assert X_torch.shape == X_scaled.shape assert not torch.allclose(X_torch, X_scaled) - assert torch.allclose(X_scaled.mean(dim=(1, 2)), torch.zeros(n_channels, dtype=dtype)) - assert torch.allclose(X_scaled.std(dim=(1, 2), unbiased=False), torch.ones(n_channels, dtype=dtype)) + assert torch.allclose( + X_scaled.mean(dim=(1, 2)), torch.zeros(n_channels, dtype=dtype) + ) + assert torch.allclose( + X_scaled.std(dim=(1, 2), unbiased=False), + torch.ones(n_channels, dtype=dtype), + ) diff --git a/tests/test_data/test_load.py b/tests/test_data/test_load.py index 619eaff..1741b53 100644 --- a/tests/test_data/test_load.py +++ b/tests/test_data/test_load.py @@ -4,13 +4,19 @@ import pytest import torch -from deepdow.data import (Compose, Dropout, FlexibleDataLoader, InRAMDataset, Multiply, Noise, - RigidDataLoader) +from deepdow.data import ( + Compose, + Dropout, + FlexibleDataLoader, + InRAMDataset, + Multiply, + Noise, + RigidDataLoader, +) from deepdow.data.load import collate_uniform class TestCollateUniform: - def test_incorrect_input(self): with pytest.raises(ValueError): collate_uniform([], n_assets_range=(-2, 0)) @@ -28,16 +34,27 @@ def test_dummy(self): max_horizon = 5 n_channels = 2 - batch = [(torch.zeros((n_channels, max_lookback, max_n_assets)), - torch.ones((n_channels, max_horizon, max_n_assets)), - datetime.datetime.now(), - ['asset_{}'.format(i) for i in range(max_n_assets)]) for _ in - range(n_samples)] - - X_batch, y_batch, timestamps_batch, asset_names_batch = collate_uniform(batch, - n_assets_range=(5, 6), - lookback_range=(4, 5), - horizon_range=(3, 4)) + batch = [ + ( + torch.zeros((n_channels, max_lookback, max_n_assets)), + torch.ones((n_channels, max_horizon, max_n_assets)), + datetime.datetime.now(), + ["asset_{}".format(i) for i in range(max_n_assets)], + ) + for _ in range(n_samples) + ] + + ( + X_batch, + y_batch, + timestamps_batch, + asset_names_batch, + ) = collate_uniform( + batch, + n_assets_range=(5, 6), + lookback_range=(4, 5), + horizon_range=(3, 4), + ) assert torch.is_tensor(X_batch) assert torch.is_tensor(y_batch) @@ -57,28 +74,38 @@ def test_replicable(self): max_horizon = 5 n_channels = 2 - batch = [(torch.rand((n_channels, max_lookback, max_n_assets)), - torch.rand((n_channels, max_horizon, max_n_assets)), - datetime.datetime.now(), - ['asset_{}'.format(i) for i in range(max_n_assets)]) for _ in - range(n_samples)] - - X_batch_1, y_batch_1, _, _ = collate_uniform(batch, - random_state=random_state_a, - n_assets_range=(4, 5), - lookback_range=(4, 5), - horizon_range=(3, 4)) - X_batch_2, y_batch_2, _, _ = collate_uniform(batch, - random_state=random_state_a, - n_assets_range=(4, 5), - lookback_range=(4, 5), - horizon_range=(3, 4)) - - X_batch_3, y_batch_3, _, _ = collate_uniform(batch, - random_state=random_state_b, - n_assets_range=(4, 5), - lookback_range=(4, 5), - horizon_range=(3, 4)) + batch = [ + ( + torch.rand((n_channels, max_lookback, max_n_assets)), + torch.rand((n_channels, max_horizon, max_n_assets)), + datetime.datetime.now(), + ["asset_{}".format(i) for i in range(max_n_assets)], + ) + for _ in range(n_samples) + ] + + X_batch_1, y_batch_1, _, _ = collate_uniform( + batch, + random_state=random_state_a, + n_assets_range=(4, 5), + lookback_range=(4, 5), + horizon_range=(3, 4), + ) + X_batch_2, y_batch_2, _, _ = collate_uniform( + batch, + random_state=random_state_a, + n_assets_range=(4, 5), + lookback_range=(4, 5), + horizon_range=(3, 4), + ) + + X_batch_3, y_batch_3, _, _ = collate_uniform( + batch, + random_state=random_state_b, + n_assets_range=(4, 5), + lookback_range=(4, 5), + horizon_range=(3, 4), + ) assert torch.allclose(X_batch_1, X_batch_2) assert torch.allclose(y_batch_1, y_batch_2) @@ -93,11 +120,15 @@ def test_different(self): max_horizon = 12 n_channels = 2 - batch = [(torch.rand((n_channels, max_lookback, max_n_assets)), - torch.rand((n_channels, max_horizon, max_n_assets)), - datetime.datetime.now(), - ['asset_{}'.format(i) for i in range(max_n_assets)]) for _ in - range(n_samples)] + batch = [ + ( + torch.rand((n_channels, max_lookback, max_n_assets)), + torch.rand((n_channels, max_horizon, max_n_assets)), + datetime.datetime.now(), + ["asset_{}".format(i) for i in range(max_n_assets)], + ) + for _ in range(n_samples) + ] n_trials = 10 n_assets_set = set() @@ -105,10 +136,17 @@ def test_different(self): horizon_set = set() for _ in range(n_trials): - X_batch, y_batch, timestamps_batch, asset_names_batch = collate_uniform(batch, - n_assets_range=(2, max_n_assets), - lookback_range=(2, max_lookback), - horizon_range=(2, max_lookback)) + ( + X_batch, + y_batch, + timestamps_batch, + asset_names_batch, + ) = collate_uniform( + batch, + n_assets_range=(2, max_n_assets), + lookback_range=(2, max_lookback), + horizon_range=(2, max_lookback), + ) n_assets_set.add(X_batch.shape[-1]) lookback_set.add(X_batch.shape[-2]) @@ -130,9 +168,11 @@ def test_incorrect_input(self): with pytest.raises(ValueError): InRAMDataset(np.zeros((2, 1, 3, 4)), np.zeros((2, 1, 3, 6))) - @pytest.mark.parametrize('n_samples', [1, 3, 6]) + @pytest.mark.parametrize("n_samples", [1, 3, 6]) def test_lenght(self, n_samples): - dset = InRAMDataset(np.zeros((n_samples, 1, 3, 4)), np.zeros((n_samples, 1, 6, 4))) + dset = InRAMDataset( + np.zeros((n_samples, 1, 3, 4)), np.zeros((n_samples, 1, 6, 4)) + ) assert len(dset) == n_samples @@ -169,10 +209,18 @@ def test_transforms(self): horizon = 10 n_assets = 6 - X = np.random.normal(size=(n_samples, n_channels, lookback, n_assets)) / 100 - y = np.random.normal(size=(n_samples, n_channels, horizon, n_assets)) / 100 + X = ( + np.random.normal(size=(n_samples, n_channels, lookback, n_assets)) + / 100 + ) + y = ( + np.random.normal(size=(n_samples, n_channels, horizon, n_assets)) + / 100 + ) - dataset = InRAMDataset(X, y, transform=Compose([Noise(), Dropout(p=0.5), Multiply(c=100)])) + dataset = InRAMDataset( + X, y, transform=Compose([Noise(), Dropout(p=0.5), Multiply(c=100)]) + ) X_sample, y_sample, timestamps_sample, asset_names = dataset[1] @@ -192,51 +240,63 @@ def test_wrong_construction(self, dataset_dummy): max_horizon = dataset_dummy.horizon with pytest.raises(ValueError): - FlexibleDataLoader(dataset_dummy, - indices=None, - asset_ixs=list(range(len(dataset_dummy))), - n_assets_range=(max_assets, max_assets + 1), - lookback_range=(max_lookback, max_lookback + 1), - horizon_range=(-2, max_horizon + 1)) + FlexibleDataLoader( + dataset_dummy, + indices=None, + asset_ixs=list(range(len(dataset_dummy))), + n_assets_range=(max_assets, max_assets + 1), + lookback_range=(max_lookback, max_lookback + 1), + horizon_range=(-2, max_horizon + 1), + ) with pytest.raises(ValueError): - FlexibleDataLoader(dataset_dummy, - indices=[-1], - n_assets_range=(max_assets, max_assets + 1), - lookback_range=(max_lookback, max_lookback + 1), - horizon_range=(max_horizon, max_horizon + 1)) + FlexibleDataLoader( + dataset_dummy, + indices=[-1], + n_assets_range=(max_assets, max_assets + 1), + lookback_range=(max_lookback, max_lookback + 1), + horizon_range=(max_horizon, max_horizon + 1), + ) with pytest.raises(ValueError): - FlexibleDataLoader(dataset_dummy, - indices=None, - n_assets_range=(max_assets, max_assets + 2), - lookback_range=(max_lookback, max_lookback + 1), - horizon_range=(max_horizon, max_horizon + 1)) + FlexibleDataLoader( + dataset_dummy, + indices=None, + n_assets_range=(max_assets, max_assets + 2), + lookback_range=(max_lookback, max_lookback + 1), + horizon_range=(max_horizon, max_horizon + 1), + ) with pytest.raises(ValueError): - FlexibleDataLoader(dataset_dummy, - indices=None, - n_assets_range=(max_assets, max_assets + 1), - lookback_range=(0, max_lookback + 1), - horizon_range=(max_horizon, max_horizon + 1)) + FlexibleDataLoader( + dataset_dummy, + indices=None, + n_assets_range=(max_assets, max_assets + 1), + lookback_range=(0, max_lookback + 1), + horizon_range=(max_horizon, max_horizon + 1), + ) with pytest.raises(ValueError): - FlexibleDataLoader(dataset_dummy, - indices=None, - n_assets_range=(max_assets, max_assets + 1), - lookback_range=(max_lookback, max_lookback + 1), - horizon_range=(-2, max_horizon + 1)) + FlexibleDataLoader( + dataset_dummy, + indices=None, + n_assets_range=(max_assets, max_assets + 1), + lookback_range=(max_lookback, max_lookback + 1), + horizon_range=(-2, max_horizon + 1), + ) def test_basic(self, dataset_dummy): max_assets = dataset_dummy.n_assets max_lookback = dataset_dummy.lookback max_horizon = dataset_dummy.horizon - dl = FlexibleDataLoader(dataset_dummy, - indices=None, - n_assets_range=(max_assets, max_assets + 1), - lookback_range=(max_lookback, max_lookback + 1), - horizon_range=(max_horizon, max_horizon + 1)) + dl = FlexibleDataLoader( + dataset_dummy, + indices=None, + n_assets_range=(max_assets, max_assets + 1), + lookback_range=(max_lookback, max_lookback + 1), + horizon_range=(max_horizon, max_horizon + 1), + ) dl = FlexibleDataLoader(dataset_dummy) @@ -257,20 +317,18 @@ def test_wrong_construction(self, dataset_dummy): max_horizon = dataset_dummy.horizon with pytest.raises(ValueError): - RigidDataLoader(dataset_dummy, - indices=[-1]) + RigidDataLoader(dataset_dummy, indices=[-1]) with pytest.raises(ValueError): - RigidDataLoader(dataset_dummy, - asset_ixs=[max_assets + 1, max_assets + 2]) + RigidDataLoader( + dataset_dummy, asset_ixs=[max_assets + 1, max_assets + 2] + ) with pytest.raises(ValueError): - RigidDataLoader(dataset_dummy, - lookback=max_lookback + 1) + RigidDataLoader(dataset_dummy, lookback=max_lookback + 1) with pytest.raises(ValueError): - RigidDataLoader(dataset_dummy, - horizon=max_horizon + 1) + RigidDataLoader(dataset_dummy, horizon=max_horizon + 1) def test_basic(self, dataset_dummy): dl = RigidDataLoader(dataset_dummy) diff --git a/tests/test_data/test_synthetic.py b/tests/test_data/test_synthetic.py index d14bea0..82cdaaf 100644 --- a/tests/test_data/test_synthetic.py +++ b/tests/test_data/test_synthetic.py @@ -7,13 +7,14 @@ class TestSin: - - @pytest.mark.parametrize('n_timesteps', [50, 120]) - @pytest.mark.parametrize('period_length', [2, 5, 9]) - @pytest.mark.parametrize('amplitude', [0.1, 10]) + @pytest.mark.parametrize("n_timesteps", [50, 120]) + @pytest.mark.parametrize("period_length", [2, 5, 9]) + @pytest.mark.parametrize("amplitude", [0.1, 10]) def test_basic(self, n_timesteps, period_length, amplitude): freq = 1 / period_length - res = sin_single(n_timesteps, freq=freq, phase=0.4, amplitude=amplitude) + res = sin_single( + n_timesteps, freq=freq, phase=0.4, amplitude=amplitude + ) assert isinstance(res, np.ndarray) assert res.shape == (n_timesteps,) diff --git a/tests/test_experiments.py b/tests/test_experiments.py index 1a3e785..0b6dbd4 100644 --- a/tests/test_experiments.py +++ b/tests/test_experiments.py @@ -21,10 +21,10 @@ def test_basic(): def test_history(): history = History() - history.add_entry(model='whatever', epoch=1) - history.add_entry(model='whatever_2', epoch=1, value=3) + history.add_entry(model="whatever", epoch=1) + history.add_entry(model="whatever_2", epoch=1, value=3) - history.add_entry(model='1111', epoch=2) + history.add_entry(model="1111", epoch=2) metrics_1 = history.metrics_per_epoch(1) metrics_2 = history.metrics_per_epoch(2) @@ -50,50 +50,96 @@ class TestRun: def test_wrong_construction_1(self, dataloader_dummy): """Wrong positional arguments.""" with pytest.raises(TypeError): - Run('this_is_fake', MeanReturns(), dataloader_dummy) + Run("this_is_fake", MeanReturns(), dataloader_dummy) with pytest.raises(TypeError): - Run(DummyNet(), 'this_is_fake', dataloader_dummy) + Run(DummyNet(), "this_is_fake", dataloader_dummy) with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), 'this_is_fake') + Run(DummyNet(), MeanReturns(), "this_is_fake") def test_wrong_construction_2(self, dataloader_dummy): """Wrong keyword arguments.""" with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, metrics='this_is_fake') + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + metrics="this_is_fake", + ) with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, metrics={'a': 'this_is_fake'}) + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + metrics={"a": "this_is_fake"}, + ) with pytest.raises(ValueError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, metrics={'loss': MeanReturns()}) + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + metrics={"loss": MeanReturns()}, + ) with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, val_dataloaders='this_is_fake') + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + val_dataloaders="this_is_fake", + ) with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, val_dataloaders={'val': 'this_is_fake'}) + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + val_dataloaders={"val": "this_is_fake"}, + ) with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, benchmarks='this_is_fake') + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + benchmarks="this_is_fake", + ) with pytest.raises(TypeError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, benchmarks={'uniform': 'this_is_fake'}) + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + benchmarks={"uniform": "this_is_fake"}, + ) with pytest.raises(ValueError): - Run(DummyNet(), MeanReturns(), dataloader_dummy, benchmarks={'main': OneOverN()}) - - @pytest.mark.parametrize('additional_kwargs', [True, False]) - def test_attributes_after_construction(self, dataloader_dummy, additional_kwargs): + Run( + DummyNet(), + MeanReturns(), + dataloader_dummy, + benchmarks={"main": OneOverN()}, + ) + + @pytest.mark.parametrize("additional_kwargs", [True, False]) + def test_attributes_after_construction( + self, dataloader_dummy, additional_kwargs + ): network = DummyNet() loss = MeanReturns() kwargs = {} if additional_kwargs: - kwargs.update({'metrics': {'std': StandardDeviation()}, - 'val_dataloaders': {'val': dataloader_dummy}, - 'benchmarks': {'whatever': OneOverN()}}) + kwargs.update( + { + "metrics": {"std": StandardDeviation()}, + "val_dataloaders": {"val": dataloader_dummy}, + "benchmarks": {"whatever": OneOverN()}, + } + ) run = Run(network, loss, dataloader_dummy, **kwargs) @@ -119,7 +165,7 @@ class TempCallback(Callback): def on_train_begin(self, metadata): raise KeyboardInterrupt() - monkeypatch.setattr('time.sleep', lambda x: None) + monkeypatch.setattr("time.sleep", lambda x: None) run = Run(network, loss, dataloader_dummy, callbacks=[TempCallback()]) run.launch(n_epochs=1) diff --git a/tests/test_explain.py b/tests/test_explain.py index 24b72ac..7bf3ad0 100644 --- a/tests/test_explain.py +++ b/tests/test_explain.py @@ -14,42 +14,85 @@ def test_basic(dtype_device): target_weights[1] = 1 initial_guess = torch.zeros(n_channels, lookback, n_assets) - network = BachelierNet(n_input_channels=n_channels, n_assets=n_assets, hidden_size=2) + network = BachelierNet( + n_input_channels=n_channels, n_assets=n_assets, hidden_size=2 + ) # WRONG MASK with pytest.raises(ValueError): - gradient_wrt_input(network, target_weights=target_weights, initial_guess=initial_guess, n_iter=3, - dtype=dtype, device=device, mask=torch.zeros(n_channels, lookback + 1, n_assets)) + gradient_wrt_input( + network, + target_weights=target_weights, + initial_guess=initial_guess, + n_iter=3, + dtype=dtype, + device=device, + mask=torch.zeros(n_channels, lookback + 1, n_assets), + ) with pytest.raises(TypeError): - gradient_wrt_input(network, target_weights=target_weights, initial_guess=initial_guess, n_iter=3, - dtype=dtype, device=device, mask='wrong_type') + gradient_wrt_input( + network, + target_weights=target_weights, + initial_guess=initial_guess, + n_iter=3, + dtype=dtype, + device=device, + mask="wrong_type", + ) # NO MASK - res, hist = gradient_wrt_input(network, target_weights=target_weights, initial_guess=initial_guess, n_iter=3, - dtype=dtype, device=device, verbose=True) + res, hist = gradient_wrt_input( + network, + target_weights=target_weights, + initial_guess=initial_guess, + n_iter=3, + dtype=dtype, + device=device, + verbose=True, + ) assert len(hist) == 3 assert torch.is_tensor(res) assert res.shape == initial_guess.shape assert res.dtype == dtype assert res.device == device - assert not torch.allclose(initial_guess.to(device=device, dtype=dtype), res) + assert not torch.allclose( + initial_guess.to(device=device, dtype=dtype), res + ) # SOME MASK some_mask = torch.ones_like(initial_guess, dtype=torch.bool) some_mask[0] = False - res_s, _ = gradient_wrt_input(network, target_weights=target_weights, initial_guess=initial_guess, n_iter=3, - dtype=dtype, device=device, mask=some_mask) + res_s, _ = gradient_wrt_input( + network, + target_weights=target_weights, + initial_guess=initial_guess, + n_iter=3, + dtype=dtype, + device=device, + mask=some_mask, + ) - assert torch.allclose(initial_guess.to(device=device, dtype=dtype)[0], res_s[0]) - assert not torch.allclose(initial_guess.to(device=device, dtype=dtype)[1], res_s[1]) + assert torch.allclose( + initial_guess.to(device=device, dtype=dtype)[0], res_s[0] + ) + assert not torch.allclose( + initial_guess.to(device=device, dtype=dtype)[1], res_s[1] + ) # EXTREME_MASK extreme_mask = torch.zeros_like(initial_guess, dtype=torch.bool) - res_e, _ = gradient_wrt_input(network, target_weights=target_weights, initial_guess=initial_guess, n_iter=3, - dtype=dtype, device=device, mask=extreme_mask) + res_e, _ = gradient_wrt_input( + network, + target_weights=target_weights, + initial_guess=initial_guess, + n_iter=3, + dtype=dtype, + device=device, + mask=extreme_mask, + ) assert torch.allclose(initial_guess.to(device=device, dtype=dtype), res_e) diff --git a/tests/test_layers.py b/tests/test_layers.py index 7424699..4bfc14d 100644 --- a/tests/test_layers.py +++ b/tests/test_layers.py @@ -1,32 +1,69 @@ import pytest import torch -from deepdow.layers import (AverageCollapse, AttentionCollapse, ElementCollapse, - ExponentialCollapse, MaxCollapse, - SumCollapse) -from deepdow.layers import (AnalyticalMarkowitz, NCO, NumericalMarkowitz, - NumericalRiskBudgeting, Resample, - SoftmaxAllocator, SparsemaxAllocator, WeightNorm) -from deepdow.layers import Cov2Corr, CovarianceMatrix, KMeans, MultiplyByConstant +from deepdow.layers import ( + AverageCollapse, + AttentionCollapse, + ElementCollapse, + ExponentialCollapse, + MaxCollapse, + SumCollapse, +) +from deepdow.layers import ( + AnalyticalMarkowitz, + NCO, + NumericalMarkowitz, + NumericalRiskBudgeting, + Resample, + SoftmaxAllocator, + SparsemaxAllocator, + WeightNorm, +) +from deepdow.layers import ( + Cov2Corr, + CovarianceMatrix, + KMeans, + MultiplyByConstant, +) from deepdow.layers import Conv, RNN, Warp, Zoom -ALL_COLLAPSE = [AverageCollapse, AttentionCollapse, ElementCollapse, ExponentialCollapse, - MaxCollapse, SumCollapse] +ALL_COLLAPSE = [ + AverageCollapse, + AttentionCollapse, + ElementCollapse, + ExponentialCollapse, + MaxCollapse, + SumCollapse, +] ALL_TRANSFORM = [Conv] class TestAnalyticalMarkowitz: - @pytest.mark.parametrize('use_rets', [True, False], ids=['use_rets', 'dont_use_rets']) + @pytest.mark.parametrize( + "use_rets", [True, False], ids=["use_rets", "dont_use_rets"] + ) def test_eye(self, dtype_device, use_rets): dtype, device = dtype_device n_samples = 2 n_assets = 4 - covmat = torch.stack([torch.eye(n_assets, n_assets, dtype=dtype, device=device) for _ in range(n_samples)], - dim=0) - rets = torch.ones(n_samples, n_assets, dtype=dtype, device=device) if use_rets else None - true_weights = torch.ones(n_samples, n_assets, dtype=dtype, device=device) / n_assets + covmat = torch.stack( + [ + torch.eye(n_assets, n_assets, dtype=dtype, device=device) + for _ in range(n_samples) + ], + dim=0, + ) + rets = ( + torch.ones(n_samples, n_assets, dtype=dtype, device=device) + if use_rets + else None + ) + true_weights = ( + torch.ones(n_samples, n_assets, dtype=dtype, device=device) + / n_assets + ) pred_weights = AnalyticalMarkowitz()(covmat, rets=rets) @@ -37,8 +74,14 @@ def test_eye(self, dtype_device, use_rets): def test_diagonal(self, dtype_device): dtype, device = dtype_device - covmat = torch.tensor([[[1 / 2, 0, 0], [0, 1 / 3, 0], [0, 0, 1 / 5]]], dtype=dtype, device=device) - true_weights = torch.tensor([[0.2, 0.3, 0.5]], dtype=dtype, device=device) + covmat = torch.tensor( + [[[1 / 2, 0, 0], [0, 1 / 3, 0], [0, 0, 1 / 5]]], + dtype=dtype, + device=device, + ) + true_weights = torch.tensor( + [[0.2, 0.3, 0.5]], dtype=dtype, device=device + ) pred_weights = AnalyticalMarkowitz()(covmat) @@ -48,7 +91,7 @@ def test_diagonal(self, dtype_device): class TestCollapse: - @pytest.mark.parametrize('layer', ALL_COLLAPSE) + @pytest.mark.parametrize("layer", ALL_COLLAPSE) def test_default(self, layer, Xy_dummy): X, _, _, _ = Xy_dummy @@ -71,25 +114,32 @@ def test_default(self, layer, Xy_dummy): class TestConv: - def test_wrong_method(self): with pytest.raises(ValueError): - Conv(1, 2, method='FAKE') - - @pytest.mark.parametrize('n_output_channels', [1, 5], ids=['n_output_channels_1', 'n_output_channels_5']) - @pytest.mark.parametrize('kernel_size', [1, 3], ids=['kernel_size_1', 'kernel_size_3']) - @pytest.mark.parametrize('method', ['1D', '2D']) + Conv(1, 2, method="FAKE") + + @pytest.mark.parametrize( + "n_output_channels", + [1, 5], + ids=["n_output_channels_1", "n_output_channels_5"], + ) + @pytest.mark.parametrize( + "kernel_size", [1, 3], ids=["kernel_size_1", "kernel_size_3"] + ) + @pytest.mark.parametrize("method", ["1D", "2D"]) def test_default(self, Xy_dummy, method, kernel_size, n_output_channels): X, _, _, _ = Xy_dummy n_samples, n_channels, lookback, n_assets = X.shape - if method == '1D': + if method == "1D": X = X.mean(dim=2) - layer_inst = Conv(n_channels, - n_output_channels, - kernel_size=kernel_size, - method=method) + layer_inst = Conv( + n_channels, + n_output_channels, + kernel_size=kernel_size, + method=method, + ) layer_inst.to(device=X.device, dtype=X.dtype) @@ -107,11 +157,20 @@ def test_default(self, Xy_dummy, method, kernel_size, n_output_channels): class TestCov2Corr: def test_eye(self, dtype_device): - dtype, device = dtype_device # we just use X to steal the device and dtype + ( + dtype, + device, + ) = dtype_device # we just use X to steal the device and dtype n_samples = 2 n_assets = 3 - covmat = torch.stack([torch.eye(n_assets, device=device, dtype=dtype) for _ in range(n_samples)], dim=0) + covmat = torch.stack( + [ + torch.eye(n_assets, device=device, dtype=dtype) + for _ in range(n_samples) + ], + dim=0, + ) corrmat = Cov2Corr()(covmat) assert torch.allclose(covmat, corrmat) @@ -121,9 +180,16 @@ def test_eye(self, dtype_device): def test_diagonal(self, Xy_dummy): X, _, _, _ = Xy_dummy - device, dtype = X.device, X.dtype # we just use X to steal the device and dtype + device, dtype = ( + X.device, + X.dtype, + ) # we just use X to steal the device and dtype - covmat = torch.Tensor([[4, 0, 0], [0, 9, 0], [0, 0, 16]]).to(device=device, dtype=dtype).view(1, 3, 3) + covmat = ( + torch.Tensor([[4, 0, 0], [0, 9, 0], [0, 0, 16]]) + .to(device=device, dtype=dtype) + .view(1, 3, 3) + ) corrmat_true = torch.eye(3, device=device, dtype=dtype).view(1, 3, 3) corrmat_pred = Cov2Corr()(covmat) @@ -134,17 +200,18 @@ def test_diagonal(self, Xy_dummy): class TestCovarianceMatrix: - def test_wrong_construction(self): with pytest.raises(ValueError): - CovarianceMatrix(shrinkage_strategy='fake') + CovarianceMatrix(shrinkage_strategy="fake") with pytest.raises(ValueError): layer = CovarianceMatrix(shrinkage_coef=None) layer(torch.ones(1, 5, 2)) - @pytest.mark.parametrize('shrinkage_strategy', ['diagonal', 'identity', 'scaled_identity', None]) - @pytest.mark.parametrize('sqrt', [True, False], ids=['sqrt', 'nosqrt']) + @pytest.mark.parametrize( + "shrinkage_strategy", ["diagonal", "identity", "scaled_identity", None] + ) + @pytest.mark.parametrize("sqrt", [True, False], ids=["sqrt", "nosqrt"]) def test_basic(self, Xy_dummy, sqrt, shrinkage_strategy): X, _, _, _ = Xy_dummy n_samples, n_channels, lookback, n_assets = X.shape @@ -153,17 +220,23 @@ def test_basic(self, Xy_dummy, sqrt, shrinkage_strategy): if n_channels == 1: with pytest.raises(ZeroDivisionError): - CovarianceMatrix(sqrt, shrinkage_strategy=shrinkage_strategy)(X_) + CovarianceMatrix(sqrt, shrinkage_strategy=shrinkage_strategy)( + X_ + ) else: - out = CovarianceMatrix(sqrt, shrinkage_strategy=shrinkage_strategy)(X_) + out = CovarianceMatrix( + sqrt, shrinkage_strategy=shrinkage_strategy + )(X_) assert out.shape == (n_samples, n_assets, n_assets) - @pytest.mark.parametrize('sqrt', [True, False], ids=['sqrt', 'nosqrt']) + @pytest.mark.parametrize("sqrt", [True, False], ids=["sqrt", "nosqrt"]) def test_n_parameters(self, sqrt): layer = CovarianceMatrix() - n_parameters = sum(p.numel() for p in layer.parameters() if p.requires_grad) + n_parameters = sum( + p.numel() for p in layer.parameters() if p.requires_grad + ) assert n_parameters == 0 @@ -190,10 +263,16 @@ def test_shrinkage_coef(self): x = torch.rand((n_samples, n_channels, n_assets)) * 100 - layer_1 = CovarianceMatrix(sqrt=False, shrinkage_strategy='diagonal', shrinkage_coef=0.3) - layer_2 = CovarianceMatrix(sqrt=False, shrinkage_strategy='diagonal', shrinkage_coef=None) + layer_1 = CovarianceMatrix( + sqrt=False, shrinkage_strategy="diagonal", shrinkage_coef=0.3 + ) + layer_2 = CovarianceMatrix( + sqrt=False, shrinkage_strategy="diagonal", shrinkage_coef=None + ) - assert torch.allclose(layer_1(x), layer_2(x, 0.3 * torch.ones(n_samples, dtype=x.dtype))) + assert torch.allclose( + layer_1(x), layer_2(x, 0.3 * torch.ones(n_samples, dtype=x.dtype)) + ) x_ = torch.rand((n_channels, n_assets)) * 100 x_stacked = torch.stack([x_, x_, x_], dim=0) @@ -213,7 +292,7 @@ def test_shrinkage_coef(self): class TestKMeans: def test_errors(self): with pytest.raises(ValueError): - KMeans(init='fake') + KMeans(init="fake") with pytest.raises(ValueError): KMeans(n_clusters=4)(torch.ones(3, 2)) @@ -221,19 +300,27 @@ def test_errors(self): def test_compute_distances(self, dtype_device): dtype, device = dtype_device - x = torch.tensor([[1, 0], [0, 1], [2, 2]]).to(dtype=dtype, device=device) - cluster_centers = torch.tensor([[0, 0], [1, 1]]).to(dtype=dtype, device=device) + x = torch.tensor([[1, 0], [0, 1], [2, 2]]).to( + dtype=dtype, device=device + ) + cluster_centers = torch.tensor([[0, 0], [1, 1]]).to( + dtype=dtype, device=device + ) - correct_result = torch.tensor([[1, 1], [1, 1], [8, 2]]).to(dtype=dtype, device=device) + correct_result = torch.tensor([[1, 1], [1, 1], [8, 2]]).to( + dtype=dtype, device=device + ) - assert torch.allclose(KMeans.compute_distances(x, cluster_centers), correct_result) + assert torch.allclose( + KMeans.compute_distances(x, cluster_centers), correct_result + ) def test_manual_init(self): n_samples = 10 n_features = 3 n_clusters = 2 - kmeans_layer = KMeans(init='manual', n_clusters=n_clusters) + kmeans_layer = KMeans(init="manual", n_clusters=n_clusters) x = torch.rand((n_samples, n_features)) manual_init = torch.rand((n_clusters, n_features)) @@ -249,9 +336,11 @@ def test_manual_init(self): with pytest.raises(ValueError): kmeans_layer.initialize(x, manual_init=wrong_init_2) - assert torch.allclose(manual_init, kmeans_layer.initialize(x, manual_init=manual_init)) + assert torch.allclose( + manual_init, kmeans_layer.initialize(x, manual_init=manual_init) + ) - @pytest.mark.parametrize('init', ['random', 'k-means++']) + @pytest.mark.parametrize("init", ["random", "k-means++"]) def test_init_deterministic(self, init, dtype_device): dtype, device = dtype_device @@ -267,12 +356,18 @@ def test_init_deterministic(self, init, dtype_device): assert torch.allclose(cluster_centers_1, cluster_centers_2) - @pytest.mark.parametrize('init', ['random', 'k-means++']) + @pytest.mark.parametrize("init", ["random", "k-means++"]) def test_all_deterministic(self, init, dtype_device): dtype, device = dtype_device random_state = 2 - kmeans_layer = KMeans(n_clusters=3, init=init, random_state=random_state, n_init=2, verbose=True) + kmeans_layer = KMeans( + n_clusters=3, + init=init, + random_state=random_state, + n_init=2, + verbose=True, + ) x = torch.rand((20, 5), dtype=dtype, device=device) @@ -282,7 +377,7 @@ def test_all_deterministic(self, init, dtype_device): assert torch.allclose(cluster_centers_1, cluster_centers_2) assert torch.allclose(cluster_ixs_1, cluster_ixs_2) - @pytest.mark.parametrize('random_state', [None, 1, 2]) + @pytest.mark.parametrize("random_state", [None, 1, 2]) def test_dummy(self, dtype_device, random_state): """Create a dummy feature matrix. @@ -293,19 +388,33 @@ def test_dummy(self, dtype_device, random_state): """ dtype, device = dtype_device - x = torch.tensor([[0, 0], [0.5, 0], [0.5, 1], [1, 1]]).to(dtype=dtype, device=device) + x = torch.tensor([[0, 0], [0.5, 0], [0.5, 1], [1, 1]]).to( + dtype=dtype, device=device + ) manual_init = torch.tensor([[0, 0], [1, 1]]) - kmeans_layer = KMeans(n_clusters=2, init='manual', random_state=random_state, n_init=1, verbose=True) + kmeans_layer = KMeans( + n_clusters=2, + init="manual", + random_state=random_state, + n_init=1, + verbose=True, + ) cluster_ixs, cluster_centers = kmeans_layer(x, manual_init=manual_init) - assert torch.allclose(cluster_ixs, torch.tensor([0, 0, 1, 1]).to(device=device)) - assert torch.allclose(cluster_centers, torch.tensor([[0.25, 0], [0.75, 1]]).to(dtype=dtype, device=device)) + assert torch.allclose( + cluster_ixs, torch.tensor([0, 0, 1, 1]).to(device=device) + ) + assert torch.allclose( + cluster_centers, + torch.tensor([[0.25, 0], [0.75, 1]]).to( + dtype=dtype, device=device + ), + ) class TestNumericalMarkowitz: - def test_basic(self, Xy_dummy): X, _, _, _ = Xy_dummy device, dtype = X.device, X.dtype @@ -315,36 +424,42 @@ def test_basic(self, Xy_dummy): rets = X.mean(dim=(1, 2)) - covmat_sqrt__ = torch.rand((n_assets, n_assets)).to(device=X.device, dtype=X.dtype) + covmat_sqrt__ = torch.rand((n_assets, n_assets)).to( + device=X.device, dtype=X.dtype + ) covmat_sqrt_ = covmat_sqrt__ @ covmat_sqrt__ covmat_sqrt_.add_(torch.eye(n_assets, dtype=dtype, device=device)) covmat_sqrt = torch.stack(n_samples * [covmat_sqrt_]) - gamma = (torch.rand(n_samples) * 5 + 0.1).to(device=X.device, dtype=X.dtype) + gamma = (torch.rand(n_samples) * 5 + 0.1).to( + device=X.device, dtype=X.dtype + ) alpha = torch.ones(n_samples).to(device=X.device, dtype=X.dtype) weights = popt(rets, covmat_sqrt, gamma, alpha) assert weights.shape == (n_samples, n_assets) - assert torch.allclose(weights.sum(dim=-1), torch.ones(n_samples, - device=device, - dtype=dtype)) + assert torch.allclose( + weights.sum(dim=-1), + torch.ones(n_samples, device=device, dtype=dtype), + ) assert weights.dtype == X.dtype assert weights.device == X.device class TestMultiplyByConstant: - def test_error(self): with pytest.raises(ValueError): MultiplyByConstant(dim_ix=1, dim_size=2)(torch.ones((2, 3))) - @pytest.mark.parametrize('dim_ix', [1, 2, 3]) + @pytest.mark.parametrize("dim_ix", [1, 2, 3]) def test_basic(self, Xy_dummy, dim_ix): X, _, _, _ = Xy_dummy - layer_inst = MultiplyByConstant(dim_ix=dim_ix, dim_size=X.shape[dim_ix]) + layer_inst = MultiplyByConstant( + dim_ix=dim_ix, dim_size=X.shape[dim_ix] + ) layer_inst.to(device=X.device, dtype=X.dtype) @@ -357,19 +472,27 @@ def test_basic(self, Xy_dummy, dim_ix): class TestNCO: - @pytest.mark.parametrize('use_rets', [True, False], ids=['use_rets', 'dont_use_rets']) - @pytest.mark.parametrize('edge_case', ['single_cluster', 'n_clusters=n_samples']) + @pytest.mark.parametrize( + "use_rets", [True, False], ids=["use_rets", "dont_use_rets"] + ) + @pytest.mark.parametrize( + "edge_case", ["single_cluster", "n_clusters=n_samples"] + ) def test_edge_case(self, dtype_device, use_rets, edge_case): dtype, device = dtype_device n_samples = 2 n_assets = 4 - n_clusters = 1 if edge_case == 'single_cluster' else n_assets + n_clusters = 1 if edge_case == "single_cluster" else n_assets single_ = torch.rand(n_assets, n_assets, dtype=dtype, device=device) single = single_ @ single_.t() covmat = torch.stack([single for _ in range(n_samples)], dim=0) - rets = torch.rand(n_samples, n_assets, dtype=dtype, device=device) if use_rets else None + rets = ( + torch.rand(n_samples, n_assets, dtype=dtype, device=device) + if use_rets + else None + ) true_weights = AnalyticalMarkowitz()(covmat, rets=rets) pred_weights = NCO(n_clusters=n_clusters)(covmat, rets=rets) @@ -378,7 +501,9 @@ def test_edge_case(self, dtype_device, use_rets, edge_case): assert true_weights.device == pred_weights.device assert true_weights.dtype == pred_weights.dtype - @pytest.mark.parametrize('use_rets', [True, False], ids=['use_rets', 'dont_use_rets']) + @pytest.mark.parametrize( + "use_rets", [True, False], ids=["use_rets", "dont_use_rets"] + ) def test_reproducible(self, dtype_device, use_rets): dtype, device = dtype_device @@ -389,7 +514,11 @@ def test_reproducible(self, dtype_device, use_rets): single_ = torch.rand(n_assets, n_assets, dtype=dtype, device=device) single = single_ @ single_.t() covmat = torch.stack([single for _ in range(n_samples)], dim=0) - rets = torch.rand(n_samples, n_assets, dtype=dtype, device=device) if use_rets else None + rets = ( + torch.rand(n_samples, n_assets, dtype=dtype, device=device) + if use_rets + else None + ) layer = NCO(n_clusters=n_clusters, random_state=2) weights_1 = layer(covmat, rets=rets) @@ -403,10 +532,14 @@ def test_reproducible(self, dtype_device, use_rets): class TestResample: def test_error(self): with pytest.raises(TypeError): - Resample('wrong_type') - - @pytest.mark.parametrize('random_state', [1, None], ids=['random', 'not_random']) - @pytest.mark.parametrize('allocator_class', [AnalyticalMarkowitz, NCO, NumericalMarkowitz]) + Resample("wrong_type") + + @pytest.mark.parametrize( + "random_state", [1, None], ids=["random", "not_random"] + ) + @pytest.mark.parametrize( + "allocator_class", [AnalyticalMarkowitz, NCO, NumericalMarkowitz] + ) def test_basic(self, dtype_device, allocator_class, random_state): dtype, device = dtype_device @@ -416,21 +549,27 @@ def test_basic(self, dtype_device, allocator_class, random_state): single_ = torch.rand(n_assets, n_assets, dtype=dtype, device=device) single = single_ @ single_.t() covmat = torch.stack([single for _ in range(n_samples)], dim=0) - rets = torch.rand(n_samples, n_assets, dtype=dtype, device=device, requires_grad=True) + rets = torch.rand( + n_samples, n_assets, dtype=dtype, device=device, requires_grad=True + ) - if allocator_class.__name__ == 'AnalyticalMarkowitz': + if allocator_class.__name__ == "AnalyticalMarkowitz": allocator = allocator_class() kwargs = {} - elif allocator_class.__name__ == 'NCO': + elif allocator_class.__name__ == "NCO": allocator = allocator_class(n_clusters=2) kwargs = {} - elif allocator_class.__name__ == 'NumericalMarkowitz': + elif allocator_class.__name__ == "NumericalMarkowitz": allocator = allocator_class(n_assets=n_assets) - kwargs = {'gamma': torch.ones(n_samples, dtype=dtype, device=device), - 'alpha': torch.ones(n_samples, dtype=dtype, device=device)} + kwargs = { + "gamma": torch.ones(n_samples, dtype=dtype, device=device), + "alpha": torch.ones(n_samples, dtype=dtype, device=device), + } - resample_layer = Resample(allocator, n_portfolios=2, sqrt=False, random_state=random_state) + resample_layer = Resample( + allocator, n_portfolios=2, sqrt=False, random_state=random_state + ) weights_1 = resample_layer(covmat, rets=rets, **kwargs) weights_2 = resample_layer(covmat, rets=rets, **kwargs) @@ -456,19 +595,25 @@ def test_basic(self, dtype_device, allocator_class, random_state): class TestRNN: - @pytest.mark.parametrize('bidirectional', [True, False], ids=['bidir', 'onedir']) - @pytest.mark.parametrize('cell_type', ['LSTM', 'RNN']) - @pytest.mark.parametrize('hidden_size', [4, 6]) - @pytest.mark.parametrize('n_layers', [1, 2]) - def test_basic(self, Xy_dummy, hidden_size, bidirectional, cell_type, n_layers): + @pytest.mark.parametrize( + "bidirectional", [True, False], ids=["bidir", "onedir"] + ) + @pytest.mark.parametrize("cell_type", ["LSTM", "RNN"]) + @pytest.mark.parametrize("hidden_size", [4, 6]) + @pytest.mark.parametrize("n_layers", [1, 2]) + def test_basic( + self, Xy_dummy, hidden_size, bidirectional, cell_type, n_layers + ): X, _, _, _ = Xy_dummy n_samples, n_channels, lookback, n_assets = X.shape - layer_inst = RNN(n_channels, - hidden_size, - n_layers=n_layers, - bidirectional=bidirectional, - cell_type=cell_type) + layer_inst = RNN( + n_channels, + hidden_size, + n_layers=n_layers, + bidirectional=bidirectional, + cell_type=cell_type, + ) layer_inst.to(device=X.device, dtype=X.dtype) @@ -483,50 +628,68 @@ def test_basic(self, Xy_dummy, hidden_size, bidirectional, cell_type, n_layers): assert res.shape[1] == hidden_size assert X.shape[2:] == res.shape[2:] - @pytest.mark.parametrize('bidirectional', [True, False], ids=['bidir', 'onedir']) - @pytest.mark.parametrize('cell_type', ['LSTM', 'RNN']) - @pytest.mark.parametrize('hidden_size', [4, 6]) - @pytest.mark.parametrize('n_channels', [1, 4]) - def test_n_parameters(self, n_channels, hidden_size, cell_type, bidirectional): - layer = RNN(n_channels, hidden_size, bidirectional=bidirectional, cell_type=cell_type) - - n_parameters = sum(p.numel() for p in layer.parameters() if p.requires_grad) - n_dir = (1 + int(bidirectional)) + @pytest.mark.parametrize( + "bidirectional", [True, False], ids=["bidir", "onedir"] + ) + @pytest.mark.parametrize("cell_type", ["LSTM", "RNN"]) + @pytest.mark.parametrize("hidden_size", [4, 6]) + @pytest.mark.parametrize("n_channels", [1, 4]) + def test_n_parameters( + self, n_channels, hidden_size, cell_type, bidirectional + ): + layer = RNN( + n_channels, + hidden_size, + bidirectional=bidirectional, + cell_type=cell_type, + ) + + n_parameters = sum( + p.numel() for p in layer.parameters() if p.requires_grad + ) + n_dir = 1 + int(bidirectional) hidden_size_a = int(hidden_size // n_dir) - if cell_type == 'RNN': + if cell_type == "RNN": assert n_parameters == n_dir * ( - (n_channels * hidden_size_a) + (hidden_size_a * hidden_size_a) + 2 * hidden_size_a) + (n_channels * hidden_size_a) + + (hidden_size_a * hidden_size_a) + + 2 * hidden_size_a + ) else: assert n_parameters == n_dir * 4 * ( - (n_channels * hidden_size_a) + (hidden_size_a * hidden_size_a) + 2 * hidden_size_a) + (n_channels * hidden_size_a) + + (hidden_size_a * hidden_size_a) + + 2 * hidden_size_a + ) def test_error(self): with pytest.raises(ValueError): - RNN(2, 4, cell_type='FAKE') + RNN(2, 4, cell_type="FAKE") with pytest.raises(ValueError): - RNN(3, 3, cell_type='LSTM', bidirectional=True) + RNN(3, 3, cell_type="LSTM", bidirectional=True) class TestSoftmax: - def test_errors(self): with pytest.raises(ValueError): - SoftmaxAllocator(formulation='wrong') + SoftmaxAllocator(formulation="wrong") with pytest.raises(ValueError): - SoftmaxAllocator(formulation='variational', n_assets=None) + SoftmaxAllocator(formulation="variational", n_assets=None) with pytest.raises(ValueError): - SoftmaxAllocator(formulation='analytical', max_weight=0.3) + SoftmaxAllocator(formulation="analytical", max_weight=0.3) with pytest.raises(ValueError): - SoftmaxAllocator(formulation='variational', max_weight=0.09, n_assets=10) + SoftmaxAllocator( + formulation="variational", max_weight=0.09, n_assets=10 + ) - @pytest.mark.parametrize('formulation', ['analytical', 'variational']) + @pytest.mark.parametrize("formulation", ["analytical", "variational"]) def test_basic(self, Xy_dummy, formulation): eps = 1e-5 X, _, _, _ = Xy_dummy @@ -536,38 +699,43 @@ def test_basic(self, Xy_dummy, formulation): rets = X.mean(dim=(1, 2)) with pytest.raises(ValueError): - SoftmaxAllocator(temperature=None, - formulation=formulation, - n_assets=n_assets)(rets, temperature=None) - - weights = SoftmaxAllocator(temperature=2, - formulation=formulation, - n_assets=n_assets)(rets) - - assert torch.allclose(weights, - SoftmaxAllocator(temperature=None, - formulation=formulation, - n_assets=n_assets)(rets, - 2 * torch.ones(n_samples, - dtype=dtype, - device=device))) + SoftmaxAllocator( + temperature=None, formulation=formulation, n_assets=n_assets + )(rets, temperature=None) + + weights = SoftmaxAllocator( + temperature=2, formulation=formulation, n_assets=n_assets + )(rets) + + assert torch.allclose( + weights, + SoftmaxAllocator( + temperature=None, formulation=formulation, n_assets=n_assets + )(rets, 2 * torch.ones(n_samples, dtype=dtype, device=device)), + ) assert weights.shape == (n_samples, n_assets) assert weights.dtype == X.dtype assert weights.device == X.device assert torch.all(-eps <= weights) and torch.all(weights <= 1 + eps) - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, - device=device), - atol=eps) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) def test_equality_formulations(self, dtype_device): dtype, device = dtype_device n_samples, n_assets = 3, 6 - layer_analytical = SoftmaxAllocator(formulation='analytical') - layer_variational = SoftmaxAllocator(formulation='variational', n_assets=n_assets) + layer_analytical = SoftmaxAllocator(formulation="analytical") + layer_variational = SoftmaxAllocator( + formulation="variational", n_assets=n_assets + ) torch.manual_seed(2) - rets = torch.randint(10, size=(n_samples, n_assets)) + torch.rand(size=(n_samples, n_assets)) + rets = torch.randint(10, size=(n_samples, n_assets)) + torch.rand( + size=(n_samples, n_assets) + ) rets = rets.to(device=device, dtype=dtype) weights_analytical = layer_analytical(rets) @@ -577,29 +745,32 @@ def test_equality_formulations(self, dtype_device): assert weights_analytical.dtype == weights_variational.dtype assert weights_analytical.device == weights_variational.device - assert torch.allclose(weights_analytical, weights_variational, atol=1e-4) + assert torch.allclose( + weights_analytical, weights_variational, atol=1e-4 + ) - @pytest.mark.parametrize('formulation', ['analytical', 'variational']) + @pytest.mark.parametrize("formulation", ["analytical", "variational"]) def test_uniform(self, formulation): rets = torch.ones(2, 5) - weights = SoftmaxAllocator(formulation=formulation, - n_assets=5, - temperature=1)(rets) + weights = SoftmaxAllocator( + formulation=formulation, n_assets=5, temperature=1 + )(rets) assert torch.allclose(weights, rets / 5, atol=1e-4) - @pytest.mark.parametrize('max_weight', [0.2, 0.25, 0.34]) + @pytest.mark.parametrize("max_weight", [0.2, 0.25, 0.34]) def test_contstrained(self, max_weight): rets = torch.tensor([[1.7909, -2, -0.6818, -0.4972, 0.0333]]) - w_const = SoftmaxAllocator(n_assets=5, - temperature=1, - max_weight=max_weight, - formulation='variational')(rets) - w_unconst = SoftmaxAllocator(n_assets=5, - temperature=1, - max_weight=1, - formulation='variational')(rets) + w_const = SoftmaxAllocator( + n_assets=5, + temperature=1, + max_weight=max_weight, + formulation="variational", + )(rets) + w_unconst = SoftmaxAllocator( + n_assets=5, temperature=1, max_weight=1, formulation="variational" + )(rets) assert not torch.allclose(w_const, w_unconst) assert w_const.max().item() == pytest.approx(max_weight, abs=1e-4) @@ -619,23 +790,27 @@ def test_basic(self, Xy_dummy): rets = X.mean(dim=(1, 2)) with pytest.raises(ValueError): - SparsemaxAllocator(n_assets, temperature=None)(rets, temperature=None) + SparsemaxAllocator(n_assets, temperature=None)( + rets, temperature=None + ) weights = SparsemaxAllocator(n_assets, temperature=2)(rets) - assert torch.allclose(weights, - SparsemaxAllocator(n_assets, temperature=None)(rets, - 2 * torch.ones( - n_samples, - dtype=dtype, - device=device))) + assert torch.allclose( + weights, + SparsemaxAllocator(n_assets, temperature=None)( + rets, 2 * torch.ones(n_samples, dtype=dtype, device=device) + ), + ) assert weights.shape == (n_samples, n_assets) assert weights.dtype == X.dtype assert weights.device == X.device assert torch.all(-eps <= weights) and torch.all(weights <= 1 + eps) - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, - device=device), - atol=eps) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) def test_uniform(self): rets = torch.ones(2, 5) @@ -644,19 +819,31 @@ def test_uniform(self): assert torch.allclose(weights, rets / 5) def test_known(self): - rets = torch.tensor([[1.7909, 0.3637, -0.6818, -0.4972, 0.0333], - [0.6655, -0.9960, 1.1463, 1.9849, -0.1662]]) - - true_weights = torch.tensor([[1.0000, 0.0000, 0.0000, 0.0000, 0.0000], - [0.0000, 0.0000, 0.0807, 0.9193, 0.0000]]) - - assert torch.allclose(SparsemaxAllocator(5, temperature=1)(rets), true_weights, atol=1e-3) - - @pytest.mark.parametrize('max_weight', [0.2, 0.25, 0.34]) + rets = torch.tensor( + [ + [1.7909, 0.3637, -0.6818, -0.4972, 0.0333], + [0.6655, -0.9960, 1.1463, 1.9849, -0.1662], + ] + ) + + true_weights = torch.tensor( + [ + [1.0000, 0.0000, 0.0000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.0807, 0.9193, 0.0000], + ] + ) + + assert torch.allclose( + SparsemaxAllocator(5, temperature=1)(rets), true_weights, atol=1e-3 + ) + + @pytest.mark.parametrize("max_weight", [0.2, 0.25, 0.34]) def test_contstrained(self, max_weight): rets = torch.tensor([[1.7909, -2, -0.6818, -0.4972, 0.0333]]) - w_const = SparsemaxAllocator(5, temperature=1, max_weight=max_weight)(rets) + w_const = SparsemaxAllocator(5, temperature=1, max_weight=max_weight)( + rets + ) w_unconst = SparsemaxAllocator(5, temperature=1)(rets) assert not torch.allclose(w_const, w_unconst) @@ -664,13 +851,17 @@ def test_contstrained(self, max_weight): class TestWarp: - def test_error(self): with pytest.raises(ValueError): - Warp()(torch.rand(1, 2, 3, 4), torch.ones(4, )) - - @pytest.mark.parametrize('mode', ['nearest', 'bilinear']) - @pytest.mark.parametrize('padding_mode', ['zeros', 'reflection', 'border']) + Warp()( + torch.rand(1, 2, 3, 4), + torch.ones( + 4, + ), + ) + + @pytest.mark.parametrize("mode", ["nearest", "bilinear"]) + @pytest.mark.parametrize("padding_mode", ["zeros", "reflection", "border"]) def test_basic(self, Xy_dummy, mode, padding_mode): X, _, _, _ = Xy_dummy dtype, device = X.dtype, X.device @@ -679,7 +870,9 @@ def test_basic(self, Xy_dummy, mode, padding_mode): tform_ = torch.rand(n_samples, lookback, dtype=dtype, device=device) tform_cumsum = tform_.cumsum(dim=-1) - tform = 2 * (tform_cumsum / tform_cumsum.max(dim=1, keepdim=True)[0] - 0.5) + tform = 2 * ( + tform_cumsum / tform_cumsum.max(dim=1, keepdim=True)[0] - 0.5 + ) x_warped = layer(X, tform) assert torch.is_tensor(x_warped) @@ -687,13 +880,15 @@ def test_basic(self, Xy_dummy, mode, padding_mode): assert x_warped.dtype == X.dtype assert x_warped.device == X.device - @pytest.mark.parametrize('mode', ['nearest', 'bilinear']) - @pytest.mark.parametrize('padding_mode', ['zeros', 'reflection', 'border']) + @pytest.mark.parametrize("mode", ["nearest", "bilinear"]) + @pytest.mark.parametrize("padding_mode", ["zeros", "reflection", "border"]) def test_no_change(self, mode, padding_mode): # scale=1 n_samples, n_channels, lookback, n_assets = 2, 3, 4, 5 X = torch.rand(n_samples, n_channels, lookback, n_assets) - tform = torch.stack(n_samples * [torch.linspace(-1, end=1, steps=lookback)], dim=0) + tform = torch.stack( + n_samples * [torch.linspace(-1, end=1, steps=lookback)], dim=0 + ) layer = Warp(mode=mode, padding_mode=padding_mode) @@ -713,7 +908,9 @@ def test_asset_specific(self): assert X.shape == x_warped.shape def test_n_parameters(self): - n_parameters = sum(p.numel() for p in Warp().parameters() if p.requires_grad) + n_parameters = sum( + p.numel() for p in Warp().parameters() if p.requires_grad + ) assert n_parameters == 0 @@ -731,20 +928,26 @@ def test_basic(self, Xy_dummy): assert torch.is_tensor(weights) assert weights.shape == (n_samples, n_assets) - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, - device=device), - atol=eps) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) - @pytest.mark.parametrize('n_assets', [2, 5, 10]) + @pytest.mark.parametrize("n_assets", [2, 5, 10]) def test_n_parameters(self, n_assets): - n_parameters = sum(p.numel() for p in WeightNorm(n_assets).parameters() if p.requires_grad) + n_parameters = sum( + p.numel() + for p in WeightNorm(n_assets).parameters() + if p.requires_grad + ) assert n_parameters == n_assets class TestZoom: - @pytest.mark.parametrize('mode', ['nearest', 'bilinear']) - @pytest.mark.parametrize('padding_mode', ['zeros', 'reflection', 'border']) + @pytest.mark.parametrize("mode", ["nearest", "bilinear"]) + @pytest.mark.parametrize("padding_mode", ["zeros", "reflection", "border"]) def test_basic(self, Xy_dummy, mode, padding_mode): X, _, _, _ = Xy_dummy dtype, device = X.dtype, X.device @@ -759,8 +962,8 @@ def test_basic(self, Xy_dummy, mode, padding_mode): assert x_zoomed.dtype == X.dtype assert x_zoomed.device == X.device - @pytest.mark.parametrize('mode', ['nearest', 'bilinear']) - @pytest.mark.parametrize('padding_mode', ['zeros', 'reflection', 'border']) + @pytest.mark.parametrize("mode", ["nearest", "bilinear"]) + @pytest.mark.parametrize("padding_mode", ["zeros", "reflection", "border"]) def test_no_change(self, mode, padding_mode): # scale=1 n_samples, n_channels, lookback, n_assets = 2, 3, 4, 5 @@ -774,7 +977,9 @@ def test_no_change(self, mode, padding_mode): assert torch.allclose(x_zoomed, X) def test_n_parameters(self): - n_parameters = sum(p.numel() for p in Zoom().parameters() if p.requires_grad) + n_parameters = sum( + p.numel() for p in Zoom().parameters() if p.requires_grad + ) assert n_parameters == 0 @@ -782,7 +987,9 @@ def test_equality_with_warp(self): n_samples, n_channels, lookback, n_assets = 2, 3, 4, 5 X = torch.rand(n_samples, n_channels, lookback, n_assets) scale = torch.ones(n_samples, dtype=X.dtype) * 0.5 - tform = torch.stack(n_samples * [torch.linspace(0, end=1, steps=lookback)], dim=0) + tform = torch.stack( + n_samples * [torch.linspace(0, end=1, steps=lookback)], dim=0 + ) layer_zoom = Zoom() layer_warp = Warp() @@ -801,7 +1008,9 @@ def test_basic(self, Xy_dummy): popt = NumericalRiskBudgeting(n_assets) - covmat_sqrt__ = torch.rand((n_assets, n_assets)).to(device=X.device, dtype=X.dtype) + covmat_sqrt__ = torch.rand((n_assets, n_assets)).to( + device=X.device, dtype=X.dtype + ) covmat_sqrt_ = covmat_sqrt__ @ covmat_sqrt__ covmat_sqrt_.add_(torch.eye(n_assets, dtype=dtype, device=device)) @@ -813,8 +1022,9 @@ def test_basic(self, Xy_dummy): weights = popt(covmat_sqrt, budgets) assert weights.shape == (n_samples, n_assets) - assert torch.allclose(weights.sum(dim=-1), torch.ones(n_samples, - device=device, - dtype=dtype)) + assert torch.allclose( + weights.sum(dim=-1), + torch.ones(n_samples, device=device, dtype=dtype), + ) assert weights.dtype == X.dtype assert weights.device == X.device diff --git a/tests/test_losses.py b/tests/test_losses.py index e6b7fd1..93408f6 100644 --- a/tests/test_losses.py +++ b/tests/test_losses.py @@ -3,18 +3,48 @@ import pytest import torch -from deepdow.losses import (Alpha, CumulativeReturn, LargestWeight, Loss, - MaximumDrawdown, MeanReturns, RiskParity, Quantile, - SharpeRatio, Softmax, SortinoRatio, SquaredWeights, - StandardDeviation, TargetMeanReturn, - TargetStandardDeviation, WorstReturn, DownsideRisk, log2simple, - portfolio_returns, portfolio_cumulative_returns, - simple2log) - -ALL_LOSSES = [Alpha, CumulativeReturn, LargestWeight, MaximumDrawdown, - MeanReturns, RiskParity, Quantile, SharpeRatio, Softmax, - SortinoRatio, SquaredWeights, StandardDeviation, - TargetMeanReturn, TargetStandardDeviation, WorstReturn, DownsideRisk] +from deepdow.losses import ( + Alpha, + CumulativeReturn, + LargestWeight, + Loss, + MaximumDrawdown, + MeanReturns, + RiskParity, + Quantile, + SharpeRatio, + Softmax, + SortinoRatio, + SquaredWeights, + StandardDeviation, + TargetMeanReturn, + TargetStandardDeviation, + WorstReturn, + DownsideRisk, + log2simple, + portfolio_returns, + portfolio_cumulative_returns, + simple2log, +) + +ALL_LOSSES = [ + Alpha, + CumulativeReturn, + LargestWeight, + MaximumDrawdown, + MeanReturns, + RiskParity, + Quantile, + SharpeRatio, + Softmax, + SortinoRatio, + SquaredWeights, + StandardDeviation, + TargetMeanReturn, + TargetStandardDeviation, + WorstReturn, + DownsideRisk, +] class TestHelpers: @@ -29,17 +59,21 @@ def test_return_conversion(self): class TestPortfolioReturns: - @pytest.mark.parametrize('input_type', ['log', 'simple']) - @pytest.mark.parametrize('output_type', ['log', 'simple']) + @pytest.mark.parametrize("input_type", ["log", "simple"]) + @pytest.mark.parametrize("output_type", ["log", "simple"]) def test_shape(self, Xy_dummy, input_type, output_type): _, y_dummy, _, _ = Xy_dummy y_dummy = y_dummy[:, 0, ...] n_samples, horizon, n_assets = y_dummy.shape - weights = torch.randint(1, 10, size=(n_samples, n_assets)).to(device=y_dummy.device, dtype=y_dummy.dtype) + weights = torch.randint(1, 10, size=(n_samples, n_assets)).to( + device=y_dummy.device, dtype=y_dummy.dtype + ) weights = weights / weights.sum(-1, keepdim=True) - prets = portfolio_returns(weights, y_dummy, input_type=input_type, output_type=output_type) + prets = portfolio_returns( + weights, y_dummy, input_type=input_type, output_type=output_type + ) assert prets.shape == (n_samples, horizon) @@ -52,18 +86,20 @@ def test_errors(self): y = torch.ones((n_samples, horizon, n_assets)) with pytest.raises(ValueError): - portfolio_returns(weights, y, input_type='fake') + portfolio_returns(weights, y, input_type="fake") with pytest.raises(ValueError): - portfolio_returns(weights, y, output_type='fake') + portfolio_returns(weights, y, output_type="fake") - @pytest.mark.parametrize('input_type', ['simple', 'log']) + @pytest.mark.parametrize("input_type", ["simple", "log"]) def test_sanity_check(self, input_type): initial_wealth = 50 - y_1_ = torch.tensor([[0.01, 0.02], - [-0.05, 0.04]]) # assets move in a different way - y_2_ = torch.tensor([[0.01, 0.01], - [-0.05, -0.05]]) # assets move in the same way + y_1_ = torch.tensor( + [[0.01, 0.02], [-0.05, 0.04]] + ) # assets move in a different way + y_2_ = torch.tensor( + [[0.01, 0.01], [-0.05, -0.05]] + ) # assets move in the same way w_a_ = torch.tensor([0.2, 0.8]) @@ -72,77 +108,131 @@ def test_sanity_check(self, input_type): w_a = w_a_[None, ...] # No need to rebalance when returns evolve in the same way - assert torch.allclose(portfolio_returns(w_a, - y_2, - rebalance=True, - input_type=input_type), - portfolio_returns(w_a, - y_2, - rebalance=False, - input_type=input_type)) + assert torch.allclose( + portfolio_returns(w_a, y_2, rebalance=True, input_type=input_type), + portfolio_returns( + w_a, y_2, rebalance=False, input_type=input_type + ), + ) # rebalancing necessary when returns evolve differently - assert not torch.allclose(portfolio_returns(w_a, y_1, rebalance=True, input_type=input_type), - portfolio_returns(w_a, y_1, rebalance=False, input_type=input_type)) + assert not torch.allclose( + portfolio_returns(w_a, y_1, rebalance=True, input_type=input_type), + portfolio_returns( + w_a, y_1, rebalance=False, input_type=input_type + ), + ) # manually computed returns - if input_type == 'simple': - h_per_asset_1 = torch.tensor([[w_a_[0], w_a_[1]], - [w_a_[0] * (1 + y_1_[0, 0]), w_a_[1] * (1 + y_1_[0, 1])], - [w_a_[0] * (1 + y_1_[0, 0]) * (1 + y_1_[1, 0]), - w_a_[1] * (1 + y_1_[0, 1]) * (1 + y_1_[1, 1])] - ]) * initial_wealth + if input_type == "simple": + h_per_asset_1 = ( + torch.tensor( + [ + [w_a_[0], w_a_[1]], + [ + w_a_[0] * (1 + y_1_[0, 0]), + w_a_[1] * (1 + y_1_[0, 1]), + ], + [ + w_a_[0] * (1 + y_1_[0, 0]) * (1 + y_1_[1, 0]), + w_a_[1] * (1 + y_1_[0, 1]) * (1 + y_1_[1, 1]), + ], + ] + ) + * initial_wealth + ) else: - h_per_asset_1 = torch.tensor([[w_a_[0], w_a_[1]], - [w_a_[0] * torch.exp(y_1_[0, 0]), w_a_[1] * torch.exp(y_1_[0, 1])], - [w_a_[0] * torch.exp(y_1_[0, 0]) * torch.exp(y_1_[1, 0]), - w_a_[1] * torch.exp(y_1_[0, 1]) * torch.exp(y_1_[1, 1])] - ]) * initial_wealth + h_per_asset_1 = ( + torch.tensor( + [ + [w_a_[0], w_a_[1]], + [ + w_a_[0] * torch.exp(y_1_[0, 0]), + w_a_[1] * torch.exp(y_1_[0, 1]), + ], + [ + w_a_[0] + * torch.exp(y_1_[0, 0]) + * torch.exp(y_1_[1, 0]), + w_a_[1] + * torch.exp(y_1_[0, 1]) + * torch.exp(y_1_[1, 1]), + ], + ] + ) + * initial_wealth + ) h_1 = h_per_asset_1.sum(1) - correct_simple_returns = torch.tensor([(h_1[1] / h_1[0]) - 1, (h_1[2] / h_1[1]) - 1]) - correct_log_returns = torch.tensor([torch.log(h_1[1] / h_1[0]), torch.log(h_1[2] / h_1[1])]) - correct_simple_creturns = torch.tensor([(h_1[1] / h_1[0]) - 1, (h_1[2] / h_1[0]) - 1]) - correct_log_creturns = torch.tensor([torch.log(h_1[1] / h_1[0]), torch.log(h_1[2] / h_1[0])]) - - assert torch.allclose(portfolio_returns(w_a, - y_1, - input_type=input_type, - output_type='simple', - rebalance=False)[0], - correct_simple_returns) - - assert torch.allclose(portfolio_returns(w_a, - y_1, - input_type=input_type, - output_type='log', - rebalance=False)[0], - correct_log_returns) - - assert torch.allclose(portfolio_cumulative_returns(w_a, - y_1, - input_type=input_type, - output_type='simple', - rebalance=False)[0], - correct_simple_creturns) - - assert torch.allclose(portfolio_cumulative_returns(w_a, - y_1, - input_type=input_type, - output_type='log', - rebalance=False)[0], - correct_log_creturns) - - @pytest.mark.parametrize('input_type', ['log', 'simple']) - @pytest.mark.parametrize('output_type', ['log', 'simple']) - @pytest.mark.parametrize('rebalance', [True, False]) + correct_simple_returns = torch.tensor( + [(h_1[1] / h_1[0]) - 1, (h_1[2] / h_1[1]) - 1] + ) + correct_log_returns = torch.tensor( + [torch.log(h_1[1] / h_1[0]), torch.log(h_1[2] / h_1[1])] + ) + correct_simple_creturns = torch.tensor( + [(h_1[1] / h_1[0]) - 1, (h_1[2] / h_1[0]) - 1] + ) + correct_log_creturns = torch.tensor( + [torch.log(h_1[1] / h_1[0]), torch.log(h_1[2] / h_1[0])] + ) + + assert torch.allclose( + portfolio_returns( + w_a, + y_1, + input_type=input_type, + output_type="simple", + rebalance=False, + )[0], + correct_simple_returns, + ) + + assert torch.allclose( + portfolio_returns( + w_a, + y_1, + input_type=input_type, + output_type="log", + rebalance=False, + )[0], + correct_log_returns, + ) + + assert torch.allclose( + portfolio_cumulative_returns( + w_a, + y_1, + input_type=input_type, + output_type="simple", + rebalance=False, + )[0], + correct_simple_creturns, + ) + + assert torch.allclose( + portfolio_cumulative_returns( + w_a, + y_1, + input_type=input_type, + output_type="log", + rebalance=False, + )[0], + correct_log_creturns, + ) + + @pytest.mark.parametrize("input_type", ["log", "simple"]) + @pytest.mark.parametrize("output_type", ["log", "simple"]) + @pytest.mark.parametrize("rebalance", [True, False]) def test_sample_independence(self, input_type, output_type, rebalance): - y_a = torch.tensor([[0.01, 0.02], - [-0.05, 0.04]]) # assets move in a different way - y_b = torch.tensor([[0.01, 0.03], - [-0.01, -0.02]]) # assets move in the same way + y_a = torch.tensor( + [[0.01, 0.02], [-0.05, 0.04]] + ) # assets move in a different way + y_b = torch.tensor( + [[0.01, 0.03], [-0.01, -0.02]] + ) # assets move in the same way w_a = torch.tensor([0.2, 0.8]) w_b = torch.tensor([0.45, 0.55]) @@ -151,23 +241,39 @@ def test_sample_independence(self, input_type, output_type, rebalance): w_2 = torch.stack([w_b, w_a], dim=0) y_2 = torch.stack([y_b, y_a], dim=0) - res_1 = portfolio_returns(w_1, y_1, input_type=input_type, output_type=output_type, rebalance=rebalance) - res_2 = portfolio_returns(w_2, y_2, input_type=input_type, output_type=output_type, rebalance=rebalance) + res_1 = portfolio_returns( + w_1, + y_1, + input_type=input_type, + output_type=output_type, + rebalance=rebalance, + ) + res_2 = portfolio_returns( + w_2, + y_2, + input_type=input_type, + output_type=output_type, + rebalance=rebalance, + ) assert torch.allclose(res_1[0], res_2[1]) assert torch.allclose(res_1[1], res_2[0]) - @pytest.mark.parametrize('input_type', ['log', 'simple']) - @pytest.mark.parametrize('output_type', ['log', 'simple']) + @pytest.mark.parametrize("input_type", ["log", "simple"]) + @pytest.mark.parametrize("output_type", ["log", "simple"]) def test_shape_cumulative(self, Xy_dummy, input_type, output_type): _, y_dummy, _, _ = Xy_dummy y_dummy = y_dummy.mean(dim=1) n_samples, horizon, n_assets = y_dummy.shape - weights = torch.randint(1, 10, size=(n_samples, n_assets)).to(device=y_dummy.device, dtype=y_dummy.dtype) + weights = torch.randint(1, 10, size=(n_samples, n_assets)).to( + device=y_dummy.device, dtype=y_dummy.dtype + ) - pcrets = portfolio_cumulative_returns(weights, y_dummy, input_type=input_type, output_type=output_type) + pcrets = portfolio_cumulative_returns( + weights, y_dummy, input_type=input_type, output_type=output_type + ) assert pcrets.shape == (n_samples, horizon) @@ -180,19 +286,23 @@ def test_errors_cumulative(self): y = torch.ones((n_samples, horizon, n_assets)) with pytest.raises(ValueError): - portfolio_cumulative_returns(weights, y, input_type='fake') + portfolio_cumulative_returns(weights, y, input_type="fake") with pytest.raises(ValueError): - portfolio_cumulative_returns(weights, y, output_type='fake') + portfolio_cumulative_returns(weights, y, output_type="fake") class TestAllLosses: - @pytest.mark.parametrize('loss_class', ALL_LOSSES, ids=[x.__name__ for x in ALL_LOSSES]) + @pytest.mark.parametrize( + "loss_class", ALL_LOSSES, ids=[x.__name__ for x in ALL_LOSSES] + ) def test_correct_output(self, loss_class, Xy_dummy): _, y_dummy, _, _ = Xy_dummy n_samples, n_channels, horizon, n_assets = y_dummy.shape - weights = (torch.ones(n_samples, n_assets) / n_assets).to(device=y_dummy.device, dtype=y_dummy.dtype) + weights = (torch.ones(n_samples, n_assets) / n_assets).to( + device=y_dummy.device, dtype=y_dummy.dtype + ) loss_instance = loss_class() # only defaults losses = loss_instance(weights, y_dummy) @@ -202,8 +312,12 @@ def test_correct_output(self, loss_class, Xy_dummy): assert losses.dtype == y_dummy.dtype assert losses.device == y_dummy.device - @pytest.mark.parametrize('loss_class_r', ALL_LOSSES + [3], ids=[x.__name__ for x in ALL_LOSSES] + ['constant']) - @pytest.mark.parametrize('op', ['add', 'truediv', 'mul', 'pow']) + @pytest.mark.parametrize( + "loss_class_r", + ALL_LOSSES + [3], + ids=[x.__name__ for x in ALL_LOSSES] + ["constant"], + ) + @pytest.mark.parametrize("op", ["add", "truediv", "mul", "pow"]) def test_arithmetic(self, loss_class_r, op, Xy_dummy): _, y_dummy, _, _ = Xy_dummy n_samples, n_channels, horizon, n_assets = y_dummy.shape @@ -211,14 +325,16 @@ def test_arithmetic(self, loss_class_r, op, Xy_dummy): loss_class_l = SharpeRatio r_is_constant = isinstance(loss_class_r, int) - weights = (torch.ones(n_samples, n_assets) / n_assets).to(device=y_dummy.device, dtype=y_dummy.dtype) + weights = (torch.ones(n_samples, n_assets) / n_assets).to( + device=y_dummy.device, dtype=y_dummy.dtype + ) loss_instance_l = loss_class_l() loss_instance_r = loss_class_r() if not r_is_constant else loss_class_r python_operator = getattr(operator, op) - if op == 'pow' and not r_is_constant: + if op == "pow" and not r_is_constant: with pytest.raises(TypeError): python_operator(loss_instance_l, loss_instance_r) @@ -227,10 +343,14 @@ def test_arithmetic(self, loss_class_r, op, Xy_dummy): else: mixed_loss = python_operator(loss_instance_l, loss_instance_r) - true_tensor = python_operator(loss_instance_l(weights, y_dummy), - loss_instance_r(weights, y_dummy) if not r_is_constant else loss_class_r) + true_tensor = python_operator( + loss_instance_l(weights, y_dummy), + loss_instance_r(weights, y_dummy) + if not r_is_constant + else loss_class_r, + ) - sign = {'add': '+', 'truediv': '/', 'mul': '*', 'pow': '**'}[op] + sign = {"add": "+", "truediv": "/", "mul": "*", "pow": "**"}[op] mixed_tensor = mixed_loss(weights, y_dummy) @@ -250,27 +370,29 @@ def test_parent_undefined_methods(self): def test_invalid_types_on_ops(self): with pytest.raises(TypeError): - Loss() + 'wrong' + Loss() + "wrong" with pytest.raises(TypeError): - 'wrong' + Loss() + "wrong" + Loss() with pytest.raises(TypeError): - Loss() * 'wrong' + Loss() * "wrong" with pytest.raises(TypeError): - 'wrong' * Loss() + "wrong" * Loss() with pytest.raises(TypeError): - Loss() / 'wrong' + Loss() / "wrong" with pytest.raises(ZeroDivisionError): Loss() / 0 with pytest.raises(TypeError): - Loss() ** 'wrong' + Loss() ** "wrong" - @pytest.mark.parametrize('loss_class', ALL_LOSSES, ids=[x.__name__ for x in ALL_LOSSES]) + @pytest.mark.parametrize( + "loss_class", ALL_LOSSES, ids=[x.__name__ for x in ALL_LOSSES] + ) def test_repr_single(self, loss_class): n_samples, n_assets, n_channels = 3, 4, 2 @@ -286,9 +408,15 @@ def test_repr_single(self, loss_class): assert torch.allclose(losses_orig, losses_recreated) - @pytest.mark.parametrize('loss_class_l', ALL_LOSSES, ids=[x.__name__ for x in ALL_LOSSES]) - @pytest.mark.parametrize('loss_class_r', ALL_LOSSES + [3], ids=[x.__name__ for x in ALL_LOSSES] + ['constant']) - @pytest.mark.parametrize('op', ['sum', 'div', 'mul', 'pow']) + @pytest.mark.parametrize( + "loss_class_l", ALL_LOSSES, ids=[x.__name__ for x in ALL_LOSSES] + ) + @pytest.mark.parametrize( + "loss_class_r", + ALL_LOSSES + [3], + ids=[x.__name__ for x in ALL_LOSSES] + ["constant"], + ) + @pytest.mark.parametrize("op", ["sum", "div", "mul", "pow"]) def test_repr_arithmetic(self, loss_class_l, loss_class_r, op): n_samples, n_assets, n_channels = 3, 4, 2 @@ -297,22 +425,26 @@ def test_repr_arithmetic(self, loss_class_l, loss_class_r, op): y = (torch.rand(n_samples, n_channels, 5, n_assets) - 1) / 100 loss_instance_l = loss_class_l() - loss_instance_r = loss_class_r() if not isinstance(loss_class_r, int) else loss_class_r + loss_instance_r = ( + loss_class_r() + if not isinstance(loss_class_r, int) + else loss_class_r + ) - if op == 'sum': + if op == "sum": mixed = loss_instance_l + loss_instance_r - elif op == 'mul': + elif op == "mul": mixed = loss_instance_l * loss_instance_r - elif op == 'div': + elif op == "div": mixed = loss_instance_l / loss_instance_r - elif op == 'pow': - mixed = loss_instance_l ** 2 + elif op == "pow": + mixed = loss_instance_l**2 else: - raise ValueError('Unrecognized op') + raise ValueError("Unrecognized op") mixed_recreated = eval(repr(mixed)) losses_orig = mixed(weights, y) @@ -330,23 +462,31 @@ def test_manual(self, Xy_dummy): benchmark_weights = torch.zeros(n_assets, dtype=dtype, device=device) benchmark_weights[0] = 1 - weights = benchmark_weights[None, :].repeat(n_samples, 1).to(device=device, dtype=dtype) + weights = ( + benchmark_weights[None, :] + .repeat(n_samples, 1) + .to(device=device, dtype=dtype) + ) loss = Alpha(benchmark_weights=benchmark_weights) loss = loss(weights, y) - assert torch.allclose(loss, torch.zeros(n_samples, dtype=dtype, device=device), atol=1e-3) + assert torch.allclose( + loss, torch.zeros(n_samples, dtype=dtype, device=device), atol=1e-3 + ) class TestMaximumDrawdown: def test_no_drawdowns(self): - y_ = torch.tensor([[0.01, 0.02], - [0.02, 0.02], - [0.01, 0.01], - [0, 0], - ] - ) + y_ = torch.tensor( + [ + [0.01, 0.02], + [0.02, 0.02], + [0.01, 0.01], + [0, 0], + ] + ) y = y_[None, None, ...] w_ = torch.tensor([0.4, 0.6]) @@ -367,13 +507,27 @@ def stupid_compute(w, y): n_samples, n_assets = w.shape covar = CovarianceMatrix(sqrt=False)(y[:, 0, ...]) # returns_channel=0 - var = torch.cat([(w[[i]] @ covar[i]) @ w[[i]].permute(1, 0) for i in range(n_samples)], dim=0) + var = torch.cat( + [ + (w[[i]] @ covar[i]) @ w[[i]].permute(1, 0) + for i in range(n_samples) + ], + dim=0, + ) vol = torch.sqrt(var) lhs = vol / n_assets - rhs = torch.cat([(1 / vol[i]) * w[[i]] * (w[[i]] @ covar[i]) for i in range(n_samples)], dim=0) - - res = torch.tensor([((lhs[i] - rhs[i]) ** 2).sum() for i in range(n_samples)]) + rhs = torch.cat( + [ + (1 / vol[i]) * w[[i]] * (w[[i]] @ covar[i]) + for i in range(n_samples) + ], + dim=0, + ) + + res = torch.tensor( + [((lhs[i] - rhs[i]) ** 2).sum() for i in range(n_samples)] + ) return res @@ -383,13 +537,13 @@ def test_correct_fpass(self): # Generate weights and targets torch.manual_seed(2) - y = torch.rand((n_samples, n_channels, horizon, n_assets), - dtype=dtype, - device=device) + y = torch.rand( + (n_samples, n_channels, horizon, n_assets), + dtype=dtype, + device=device, + ) - weights = torch.rand((n_samples, n_assets), - dtype=dtype, - device=device) + weights = torch.rand((n_samples, n_assets), dtype=dtype, device=device) weights /= weights.sum(dim=-1, keepdim=True) diff --git a/tests/test_nn.py b/tests/test_nn.py index 4fbb56a..f3fcf2f 100644 --- a/tests/test_nn.py +++ b/tests/test_nn.py @@ -2,7 +2,14 @@ import pytest import torch -from deepdow.nn import BachelierNet, DummyNet, KeynesNet, LinearNet, MinimalNet, ThorpNet +from deepdow.nn import ( + BachelierNet, + DummyNet, + KeynesNet, + LinearNet, + MinimalNet, + ThorpNet, +) class TestDummyNetwork: @@ -21,11 +28,19 @@ def test_basic(self, Xy_dummy): assert weights.shape == (n_samples, n_assets) assert X.device == weights.device assert X.dtype == weights.dtype - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=1e-4) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=1e-4, + ) class TestBachelierNet: - @pytest.mark.parametrize('max_weight', [0.25, 0.5, 1], ids=['max_weight_0.25', 'max_weight_0.5', 'max_weight_1']) + @pytest.mark.parametrize( + "max_weight", + [0.25, 0.5, 1], + ids=["max_weight_0.25", "max_weight_0.5", "max_weight_1"], + ) def test_basic(self, Xy_dummy, max_weight): eps = 1e-3 @@ -45,20 +60,26 @@ def test_basic(self, Xy_dummy, max_weight): assert weights.shape == (n_samples, n_assets) assert X.device == weights.device assert X.dtype == weights.dtype - assert torch.all(-eps <= weights) and torch.all(weights <= max_weight + eps) - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=eps) + assert torch.all(-eps <= weights) and torch.all( + weights <= max_weight + eps + ) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) class TestKeynesNet: def test_error(self): with pytest.raises(ValueError): - KeynesNet(2, transform_type='FAKE', hidden_size=10, n_groups=2) + KeynesNet(2, transform_type="FAKE", hidden_size=10, n_groups=2) with pytest.raises(ValueError): KeynesNet(2, hidden_size=10, n_groups=3) - @pytest.mark.parametrize('transform_type', ['Conv', 'RNN']) - @pytest.mark.parametrize('hidden_size', [4, 6]) + @pytest.mark.parametrize("transform_type", ["Conv", "RNN"]) + @pytest.mark.parametrize("hidden_size", [4, 6]) def test_basic(self, Xy_dummy, transform_type, hidden_size): eps = 1e-4 @@ -67,7 +88,12 @@ def test_basic(self, Xy_dummy, transform_type, hidden_size): dtype = X.dtype device = X.device - network = KeynesNet(n_channels, hidden_size=hidden_size, transform_type=transform_type, n_groups=2) # + network = KeynesNet( + n_channels, + hidden_size=hidden_size, + transform_type=transform_type, + n_groups=2, + ) # network.to(device=device, dtype=dtype) weights = network(X) @@ -78,28 +104,42 @@ def test_basic(self, Xy_dummy, transform_type, hidden_size): assert weights.shape == (n_samples, n_assets) assert X.device == weights.device assert X.dtype == weights.dtype - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=eps) - - @pytest.mark.parametrize('n_input_channels', [4, 8]) - @pytest.mark.parametrize('hidden_size', [16, 32]) - @pytest.mark.parametrize('n_groups', [2, 4, 8]) - @pytest.mark.parametrize('transform_type', ['Conv', 'RNN']) - def test_n_params(self, n_input_channels, hidden_size, n_groups, transform_type): - network = KeynesNet(n_input_channels=n_input_channels, - hidden_size=hidden_size, - n_groups=n_groups, - transform_type=transform_type) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) + + @pytest.mark.parametrize("n_input_channels", [4, 8]) + @pytest.mark.parametrize("hidden_size", [16, 32]) + @pytest.mark.parametrize("n_groups", [2, 4, 8]) + @pytest.mark.parametrize("transform_type", ["Conv", "RNN"]) + def test_n_params( + self, n_input_channels, hidden_size, n_groups, transform_type + ): + network = KeynesNet( + n_input_channels=n_input_channels, + hidden_size=hidden_size, + n_groups=n_groups, + transform_type=transform_type, + ) expected = 0 expected += n_input_channels * 2 # instance norm - if transform_type == 'Conv': + if transform_type == "Conv": expected += n_input_channels * 3 * hidden_size + hidden_size else: - expected += 4 * ((n_input_channels * hidden_size) + (hidden_size * hidden_size) + 2 * hidden_size) + expected += 4 * ( + (n_input_channels * hidden_size) + + (hidden_size * hidden_size) + + 2 * hidden_size + ) expected += 2 * hidden_size # group_norm expected += 1 # temperature - actual = sum(p.numel() for p in network.parameters() if p.requires_grad) + actual = sum( + p.numel() for p in network.parameters() if p.requires_grad + ) assert expected == actual @@ -117,7 +157,16 @@ def test_basic(self, Xy_dummy): network.to(device=device, dtype=dtype) with pytest.raises(ValueError): - network(torch.ones(n_samples, n_channels + 1, lookback, n_assets, device=device, dtype=dtype)) + network( + torch.ones( + n_samples, + n_channels + 1, + lookback, + n_assets, + device=device, + dtype=dtype, + ) + ) weights = network(X) @@ -127,11 +176,15 @@ def test_basic(self, Xy_dummy): assert weights.shape == (n_samples, n_assets) assert X.device == weights.device assert X.dtype == weights.dtype - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=eps) - - @pytest.mark.parametrize('n_channels', [1, 3]) - @pytest.mark.parametrize('lookback', [2, 10]) - @pytest.mark.parametrize('n_assets', [40, 4]) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) + + @pytest.mark.parametrize("n_channels", [1, 3]) + @pytest.mark.parametrize("lookback", [2, 10]) + @pytest.mark.parametrize("n_assets", [40, 4]) def test_n_params(self, n_channels, lookback, n_assets): network = LinearNet(n_channels, lookback, n_assets) @@ -141,7 +194,9 @@ def test_n_params(self, n_channels, lookback, n_assets): expected += n_features * n_assets + n_assets # dense expected += 1 # temperature - actual = sum(p.numel() for p in network.parameters() if p.requires_grad) + actual = sum( + p.numel() for p in network.parameters() if p.requires_grad + ) assert expected == actual @@ -161,26 +216,37 @@ def test_basic(self, Xy_dummy): weights = network(X) assert isinstance(network.hparams, dict) - assert 'n_assets' in network.hparams + assert "n_assets" in network.hparams assert torch.is_tensor(weights) assert weights.shape == (n_samples, n_assets) assert X.device == weights.device assert X.dtype == weights.dtype - assert torch.allclose(weights.sum(dim=1), - torch.ones(n_samples).to(dtype=dtype, device=device), atol=eps) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) - @pytest.mark.parametrize('n_assets', [40, 4]) + @pytest.mark.parametrize("n_assets", [40, 4]) def test_n_params(self, n_assets): network = MinimalNet(n_assets) - actual = sum(p.numel() for p in network.parameters() if p.requires_grad) + actual = sum( + p.numel() for p in network.parameters() if p.requires_grad + ) assert n_assets == actual class TestThorpNet: - @pytest.mark.parametrize('force_symmetric', [True, False], ids=['symmetric', 'asymetric']) - @pytest.mark.parametrize('max_weight', [0.25, 0.5, 1], ids=['max_weight_0.25', 'max_weight_0.5', 'max_weight_1']) + @pytest.mark.parametrize( + "force_symmetric", [True, False], ids=["symmetric", "asymetric"] + ) + @pytest.mark.parametrize( + "max_weight", + [0.25, 0.5, 1], + ids=["max_weight_0.25", "max_weight_0.5", "max_weight_1"], + ) def test_basic(self, Xy_dummy, max_weight, force_symmetric): eps = 1e-4 @@ -189,7 +255,9 @@ def test_basic(self, Xy_dummy, max_weight, force_symmetric): dtype = X.dtype device = X.device - network = ThorpNet(n_assets, max_weight=max_weight, force_symmetric=force_symmetric) + network = ThorpNet( + n_assets, max_weight=max_weight, force_symmetric=force_symmetric + ) network.to(device=device, dtype=dtype) weights = network(X) @@ -200,15 +268,25 @@ def test_basic(self, Xy_dummy, max_weight, force_symmetric): assert weights.shape == (n_samples, n_assets) assert X.device == weights.device assert X.dtype == weights.dtype - assert torch.all(-eps <= weights) and torch.all(weights <= max_weight + eps) - assert torch.allclose(weights.sum(dim=1), torch.ones(n_samples).to(dtype=dtype, device=device), atol=eps) - - @pytest.mark.parametrize('force_symmetric', [True, False], ids=['symmetric', 'asymetric']) - @pytest.mark.parametrize('n_assets', [3, 5, 6]) + assert torch.all(-eps <= weights) and torch.all( + weights <= max_weight + eps + ) + assert torch.allclose( + weights.sum(dim=1), + torch.ones(n_samples).to(dtype=dtype, device=device), + atol=eps, + ) + + @pytest.mark.parametrize( + "force_symmetric", [True, False], ids=["symmetric", "asymetric"] + ) + @pytest.mark.parametrize("n_assets", [3, 5, 6]) def test_n_params(self, n_assets, force_symmetric): network = ThorpNet(n_assets, force_symmetric=force_symmetric) expected = n_assets * n_assets + n_assets + 1 + 1 - actual = sum(p.numel() for p in network.parameters() if p.requires_grad) + actual = sum( + p.numel() for p in network.parameters() if p.requires_grad + ) assert expected == actual diff --git a/tests/test_utils.py b/tests/test_utils.py index 22fe240..b06057f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,13 @@ import pandas as pd import pytest -from deepdow.utils import ChangeWorkingDirectory, PandasChecks, prices_to_returns, raw_to_Xy, returns_to_Xy +from deepdow.utils import ( + ChangeWorkingDirectory, + PandasChecks, + prices_to_returns, + raw_to_Xy, + returns_to_Xy, +) class TestChangeWorkingDirectory: @@ -17,7 +23,7 @@ def test_construction(self, tmpdir): assert ChangeWorkingDirectory(dir_path).directory == dir_path with pytest.raises(NotADirectoryError): - ChangeWorkingDirectory('/fake/directory/') + ChangeWorkingDirectory("/fake/directory/") def test_working(self, tmpdir): dir_path = pathlib.Path(str(tmpdir)) @@ -35,11 +41,12 @@ def test_working(self, tmpdir): class TestPandasChecks: - def test_check_no_gaps(self): index_incorrect = [1, 2] - index_without_gaps = pd.date_range('1/1/2000', periods=4, freq='M') - index_with_gaps = pd.DatetimeIndex([x for i, x in enumerate(index_without_gaps) if i != 2]) + index_without_gaps = pd.date_range("1/1/2000", periods=4, freq="M") + index_with_gaps = pd.DatetimeIndex( + [x for i, x in enumerate(index_without_gaps) if i != 2] + ) with pytest.raises(TypeError): PandasChecks.check_no_gaps(index_incorrect) @@ -50,7 +57,7 @@ def test_check_no_gaps(self): PandasChecks.check_no_gaps(index_without_gaps) def test_check_valid_entries(self): - table_incorrect = 'table' + table_incorrect = "table" table_invalid_1 = pd.Series([1, np.nan]) table_invalid_2 = pd.DataFrame([[1, 2], [np.inf, 3]]) table_valid = pd.DataFrame([[1, 2], [2, 4]]) @@ -67,26 +74,31 @@ def test_check_valid_entries(self): PandasChecks.check_valid_entries(table_valid) def test_indices_agree(self): - index_correct = ['A', 'B'] - index_wrong = ['A', 'C'] + index_correct = ["A", "B"] + index_wrong = ["A", "C"] with pytest.raises(TypeError): - PandasChecks.check_indices_agree([], 'a') + PandasChecks.check_indices_agree([], "a") with pytest.raises(IndexError): - PandasChecks.check_indices_agree(pd.Series(index=index_correct), pd.Series(index=index_wrong)) + PandasChecks.check_indices_agree( + pd.Series(index=index_correct), pd.Series(index=index_wrong) + ) with pytest.raises(IndexError): - PandasChecks.check_indices_agree(pd.Series(index=index_correct), pd.DataFrame(index=index_correct, - columns=index_wrong)) + PandasChecks.check_indices_agree( + pd.Series(index=index_correct), + pd.DataFrame(index=index_correct, columns=index_wrong), + ) - PandasChecks.check_indices_agree(pd.Series(index=index_correct), pd.DataFrame(index=index_correct, - columns=index_correct)) + PandasChecks.check_indices_agree( + pd.Series(index=index_correct), + pd.DataFrame(index=index_correct, columns=index_correct), + ) class TestPricesToReturns: - - @pytest.mark.parametrize('use_log', [True, False]) + @pytest.mark.parametrize("use_log", [True, False]) def test_dummy_(self, raw_data, use_log): prices_dummy, _, _ = raw_data @@ -97,9 +109,13 @@ def test_dummy_(self, raw_data, use_log): assert returns.columns.equals(prices_dummy.columns) if use_log: - assert np.log(prices_dummy.iloc[2, 3] / prices_dummy.iloc[1, 3]) == pytest.approx(returns.iloc[1, 3]) + assert np.log( + prices_dummy.iloc[2, 3] / prices_dummy.iloc[1, 3] + ) == pytest.approx(returns.iloc[1, 3]) else: - assert (prices_dummy.iloc[2, 3] / prices_dummy.iloc[1, 3]) - 1 == pytest.approx(returns.iloc[1, 3]) + assert ( + prices_dummy.iloc[2, 3] / prices_dummy.iloc[1, 3] + ) - 1 == pytest.approx(returns.iloc[1, 3]) class TestRawToXy: @@ -112,13 +128,19 @@ def test_wrong(self, raw_data): with pytest.raises(ValueError): raw_to_Xy(df, freq=None) - @pytest.mark.parametrize('included_assets', - [None, ['asset_1', 'asset_3']], - ids=['all_assets', 'some_assets']) - @pytest.mark.parametrize('included_indicators', - [None, ['indicator_0', 'indicator_2', 'indicator_4']], - ids=['all_indicators', 'some_indicators']) - def test_sanity_check(self, raw_data, included_assets, included_indicators): + @pytest.mark.parametrize( + "included_assets", + [None, ["asset_1", "asset_3"]], + ids=["all_assets", "some_assets"], + ) + @pytest.mark.parametrize( + "included_indicators", + [None, ["indicator_0", "indicator_2", "indicator_4"]], + ids=["all_indicators", "some_indicators"], + ) + def test_sanity_check( + self, raw_data, included_assets, included_indicators + ): df, n_missing_entries, true_freq = raw_data n_timesteps = len(df) @@ -129,16 +151,19 @@ def test_sanity_check(self, raw_data, included_assets, included_indicators): horizon = n_timesteps // 4 gap = 1 - X, timestamps, y, asset_names, indicators = raw_to_Xy(df, - lookback=lookback, - horizon=horizon, - gap=1, - freq=true_freq, - included_assets=included_assets, - included_indicators=included_indicators - ) + X, timestamps, y, asset_names, indicators = raw_to_Xy( + df, + lookback=lookback, + horizon=horizon, + gap=1, + freq=true_freq, + included_assets=included_assets, + included_indicators=included_indicators, + ) - n_new = n_timesteps + n_missing_entries - lookback - horizon - gap + 1 - 1 # we start with prices + n_new = ( + n_timesteps + n_missing_entries - lookback - horizon - gap + 1 - 1 + ) # we start with prices # types assert isinstance(X, np.ndarray) @@ -151,7 +176,12 @@ def test_sanity_check(self, raw_data, included_assets, included_indicators): # shapes assert X.shape == (n_new, n_indicators, lookback, n_assets) assert y.shape == (n_new, n_indicators, horizon, n_assets) - assert timestamps[0] == pd.date_range(start=df.index[1], periods=lookback, freq=true_freq)[-1] # prices + assert ( + timestamps[0] + == pd.date_range( + start=df.index[1], periods=lookback, freq=true_freq + )[-1] + ) # prices assert len(asset_names) == n_assets assert len(indicators) == n_indicators @@ -167,32 +197,36 @@ def test_invalid_values(self, raw_data): df_invalid = df.copy() - df_invalid.at[df.index[0], ('asset_1', 'indicator_3')] = -2 + df_invalid.at[df.index[0], ("asset_1", "indicator_3")] = -2 - X, timestamps, y, asset_names, indicators = raw_to_Xy(df_invalid, - lookback=lookback, - horizon=horizon, - gap=gap, - freq=true_freq - ) + X, timestamps, y, asset_names, indicators = raw_to_Xy( + df_invalid, + lookback=lookback, + horizon=horizon, + gap=gap, + freq=true_freq, + ) - assert ['asset_{}'.format(i) for i in range(n_assets) if i != 1] == asset_names + assert [ + "asset_{}".format(i) for i in range(n_assets) if i != 1 + ] == asset_names class TestReturnsToXY: - - @pytest.mark.parametrize('lookback', [3, 5]) - @pytest.mark.parametrize('horizon', [4, 6]) + @pytest.mark.parametrize("lookback", [3, 5]) + @pytest.mark.parametrize("horizon", [4, 6]) def test_basic(self, raw_data, lookback, horizon): df, _, _ = raw_data - returns_dummy = df.xs('indicator_1', axis=1, level=1) + returns_dummy = df.xs("indicator_1", axis=1, level=1) n_timesteps = len(returns_dummy.index) n_assets = len(returns_dummy.columns) n_samples = n_timesteps - lookback - horizon + 1 - X, timesteps, y = returns_to_Xy(returns_dummy, lookback=lookback, horizon=horizon) + X, timesteps, y = returns_to_Xy( + returns_dummy, lookback=lookback, horizon=horizon + ) assert isinstance(X, np.ndarray) assert isinstance(timesteps, pd.DatetimeIndex) diff --git a/tests/test_visualize.py b/tests/test_visualize.py index 19d256d..3f78de9 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -9,65 +9,81 @@ import pytest from deepdow.losses import MeanReturns -from deepdow.visualize import (plot_weight_anim, plot_weight_heatmap, generate_cumrets, - generate_metrics_table, generate_weights_table, plot_metrics) +from deepdow.visualize import ( + plot_weight_anim, + plot_weight_heatmap, + generate_cumrets, + generate_metrics_table, + generate_weights_table, + plot_metrics, +) class TestGenerateCumrets: def test_errors(self, dataloader_dummy, network_dummy): with pytest.raises(TypeError): - generate_cumrets({'bm_1': 'WRONG'}, dataloader_dummy) + generate_cumrets({"bm_1": "WRONG"}, dataloader_dummy) with pytest.raises(TypeError): - generate_cumrets({'bm_1': network_dummy}, 'FAKE') + generate_cumrets({"bm_1": network_dummy}, "FAKE") def test_basic(self, dataloader_dummy, network_dummy): - cumrets_dict = generate_cumrets({'bm_1': network_dummy}, - dataloader_dummy) + cumrets_dict = generate_cumrets( + {"bm_1": network_dummy}, dataloader_dummy + ) assert isinstance(cumrets_dict, dict) assert len(cumrets_dict) == 1 - assert 'bm_1' in cumrets_dict - assert cumrets_dict['bm_1'].shape == (len(dataloader_dummy.dataset), - dataloader_dummy.horizon) + assert "bm_1" in cumrets_dict + assert cumrets_dict["bm_1"].shape == ( + len(dataloader_dummy.dataset), + dataloader_dummy.horizon, + ) class TestGenerateMetricsTable: def test_errors(self, dataloader_dummy, network_dummy): with pytest.raises(TypeError): - generate_metrics_table({'bm_1': 'WRONG'}, dataloader_dummy, {'metric': MeanReturns()}) + generate_metrics_table( + {"bm_1": "WRONG"}, dataloader_dummy, {"metric": MeanReturns()} + ) with pytest.raises(TypeError): - generate_metrics_table({'bm_1': network_dummy}, 'FAKE', {'metric': MeanReturns()}) + generate_metrics_table( + {"bm_1": network_dummy}, "FAKE", {"metric": MeanReturns()} + ) with pytest.raises(TypeError): - generate_metrics_table({'bm_1': network_dummy}, dataloader_dummy, {'metric': 'FAKE'}) + generate_metrics_table( + {"bm_1": network_dummy}, dataloader_dummy, {"metric": "FAKE"} + ) def test_basic(self, dataloader_dummy, network_dummy): - metrics_table = generate_metrics_table({'bm_1': network_dummy}, - dataloader_dummy, - {'rets': MeanReturns()}) + metrics_table = generate_metrics_table( + {"bm_1": network_dummy}, dataloader_dummy, {"rets": MeanReturns()} + ) assert isinstance(metrics_table, pd.DataFrame) assert len(metrics_table) == len(dataloader_dummy.dataset) - assert {'metric', 'value', 'benchmark', 'timestamp'} == set(metrics_table.columns.to_list()) + assert {"metric", "value", "benchmark", "timestamp"} == set( + metrics_table.columns.to_list() + ) def test_plot_metrics(monkeypatch): n_entries = 100 - metrics_table = pd.DataFrame(np.random.random((n_entries, 2)), - columns=[ - 'value', - 'timestamp']) - metrics_table['metric'] = 'M' - metrics_table['benchmark'] = 'B' + metrics_table = pd.DataFrame( + np.random.random((n_entries, 2)), columns=["value", "timestamp"] + ) + metrics_table["metric"] = "M" + metrics_table["benchmark"] = "B" fake_plt = Mock() fake_plt.subplots.return_value = None, MagicMock() fake_pd = Mock() - monkeypatch.setattr('deepdow.visualize.plt', fake_plt) - monkeypatch.setattr('deepdow.visualize.pd', fake_pd) + monkeypatch.setattr("deepdow.visualize.plt", fake_plt) + monkeypatch.setattr("deepdow.visualize.pd", fake_pd) plot_metrics(metrics_table) @@ -75,74 +91,98 @@ def test_plot_metrics(monkeypatch): class TestGenerateWeightsTable: def test_errors(self, dataloader_dummy, network_dummy): with pytest.raises(TypeError): - generate_weights_table('FAKE', dataloader_dummy) + generate_weights_table("FAKE", dataloader_dummy) with pytest.raises(TypeError): - generate_weights_table(network_dummy, 'FAKE') + generate_weights_table(network_dummy, "FAKE") def test_basic(self, dataloader_dummy, network_dummy): weights_table = generate_weights_table(network_dummy, dataloader_dummy) assert isinstance(weights_table, pd.DataFrame) assert len(weights_table) == len(dataloader_dummy.dataset) - assert set(weights_table.index.to_list()) == set(dataloader_dummy.dataset.timestamps) - assert weights_table.columns.to_list() == dataloader_dummy.dataset.asset_names + assert set(weights_table.index.to_list()) == set( + dataloader_dummy.dataset.timestamps + ) + assert ( + weights_table.columns.to_list() + == dataloader_dummy.dataset.asset_names + ) class TestPlotWeightAnim: def test_errors(self): with pytest.raises(ValueError): - plot_weight_anim(pd.DataFrame([[0, 1], [1, 2]], columns=['others', 'asset_1'])) + plot_weight_anim( + pd.DataFrame([[0, 1], [1, 2]], columns=["others", "asset_1"]) + ) with pytest.raises(ValueError): - plot_weight_anim(pd.DataFrame([[0, 1], [1, 2]]), n_displayed_assets=3) + plot_weight_anim( + pd.DataFrame([[0, 1], [1, 2]]), n_displayed_assets=3 + ) with pytest.raises(ValueError): - plot_weight_anim(pd.DataFrame([[0, 1], [1, 2]], columns=['a', 'b']), - n_displayed_assets=1, - always_visible=['a', 'b']) - - @pytest.mark.parametrize('colors', [None, {'asset_1': 'green'}, ListedColormap(['green', 'red'])]) + plot_weight_anim( + pd.DataFrame([[0, 1], [1, 2]], columns=["a", "b"]), + n_displayed_assets=1, + always_visible=["a", "b"], + ) + + @pytest.mark.parametrize( + "colors", + [None, {"asset_1": "green"}, ListedColormap(["green", "red"])], + ) def test_portfolio_evolution(self, monkeypatch, colors): n_timesteps = 4 n_assets = 3 n_displayed_assets = 2 - weights = pd.DataFrame(np.random.random((n_timesteps, n_assets)), - index=pd.date_range(start='1/1/2000', periods=n_timesteps), - columns=['asset_{}'.format(i) for i in range(n_assets)]) + weights = pd.DataFrame( + np.random.random((n_timesteps, n_assets)), + index=pd.date_range(start="1/1/2000", periods=n_timesteps), + columns=["asset_{}".format(i) for i in range(n_assets)], + ) - weights['asset_0'] = 0 # the smallest but we will force its display anyway + weights[ + "asset_0" + ] = 0 # the smallest but we will force its display anyway fake_functanim = Mock() fake_functanim.return_value = Mock(spec=FuncAnimation) - monkeypatch.setattr('deepdow.visualize.FuncAnimation', fake_functanim) + monkeypatch.setattr("deepdow.visualize.FuncAnimation", fake_functanim) plt_mock = Mock() plt_mock.subplots = Mock(return_value=[Mock(), Mock()]) - monkeypatch.setattr('deepdow.visualize.plt', plt_mock) - ani = plot_weight_anim(weights, - n_displayed_assets=n_displayed_assets, - always_visible=['asset_0'], - n_seconds=10, - figsize=(1, 1), - colors=colors) + monkeypatch.setattr("deepdow.visualize.plt", plt_mock) + ani = plot_weight_anim( + weights, + n_displayed_assets=n_displayed_assets, + always_visible=["asset_0"], + n_seconds=10, + figsize=(1, 1), + colors=colors, + ) assert isinstance(ani, FuncAnimation) class TestPlotWeightHeatmap: - @pytest.mark.parametrize('add_sum_column', [True, False]) - @pytest.mark.parametrize('time_format', [None, '%d-%m-%Y']) + @pytest.mark.parametrize("add_sum_column", [True, False]) + @pytest.mark.parametrize("time_format", [None, "%d-%m-%Y"]) def test_basic(self, time_format, add_sum_column, monkeypatch): n_timesteps = 20 n_assets = 10 - index = list(range(n_timesteps)) if time_format is None else pd.date_range('1/1/2000', - periods=n_timesteps) + index = ( + list(range(n_timesteps)) + if time_format is None + else pd.date_range("1/1/2000", periods=n_timesteps) + ) - weights = pd.DataFrame(np.random.random(size=(n_timesteps, n_assets)), - index=index) + weights = pd.DataFrame( + np.random.random(size=(n_timesteps, n_assets)), index=index + ) fake_axes = Mock(spec=Axes) fake_axes.xaxis = Mock() @@ -150,10 +190,10 @@ def test_basic(self, time_format, add_sum_column, monkeypatch): fake_sns = Mock() fake_sns.heatmap.return_value = fake_axes - monkeypatch.setattr('deepdow.visualize.sns', fake_sns) - ax = plot_weight_heatmap(weights, - time_format=time_format, - add_sum_column=add_sum_column) + monkeypatch.setattr("deepdow.visualize.sns", fake_sns) + ax = plot_weight_heatmap( + weights, time_format=time_format, add_sum_column=add_sum_column + ) assert isinstance(ax, Axes) assert fake_sns.heatmap.call_count == 1 @@ -162,7 +202,7 @@ def test_basic(self, time_format, add_sum_column, monkeypatch): def test_sum_column(self): with pytest.raises(ValueError): now = datetime.datetime.now() - df = pd.DataFrame(np.zeros((2, 2)), - columns=["asset", "sum"], - index=[now, now]) + df = pd.DataFrame( + np.zeros((2, 2)), columns=["asset", "sum"], index=[now, now] + ) plot_weight_heatmap(df, add_sum_column=True)