"""
Scenarios class for the MacroStat model.
"""
__author__ = ["Karl Naumann-Woleske"]
__credits__ = ["Karl Naumann-Woleske"]
__license__ = "MIT"
__maintainer__ = ["Karl Naumann-Woleske"]
import json
import logging
import random
import numpy as np
import pandas as pd
import torch
from macrostat.core.parameters import Parameters
logger = logging.getLogger(__name__)
[docs]
class Scenarios:
"""Scenarios class for the MacroStat model.
The aim of this class is to provide a uniform interface for handling
scenarios, in particular for exogeneous shocks (e.g. also for the case
where the shocks are calibrated to fit the data, such as using productivity
shocks to fit the trajectory of GDP). It also contains user-specified scenarios
such as exogenous supply shocks or the like.
"""
[docs]
def __init__(
self,
parameters: Parameters,
scenarios: dict | None = None,
scenario_info: dict | None = None,
calibration_variables: list[str] | None = None,
):
"""Initialize the scenarios for the model.
If no scenarios are provided, the default scenarios will be used.
Parameters
----------
parameters: Parameters
The parameters of the model. These are necessary to create the
default scenarios.
scenarios: dict | None
The scenarios to initialize the model with. If None, the default
scenarios will be used.
scenario_info: dict | None
The colors to use for the scenario variables. If None, the default
colors will be used.
calibration_variables: list[str] | None
The scenario variables that can be used in the calibration. If
None, calibration will be based on the LabourProductivityGeneral,
ProductivityShockGeneral, and ProductionLossGeneral scenario variables.
"""
self.parameters = parameters
self.trigger = self.parameters["scenario_trigger"]
self.scenario_duration = self.parameters["timesteps"] - self.trigger + 1
self.timeseries = {0: self.get_default_scenario()}
self.info = {
0: {
"Name": "Scenario.0",
"Colour": "#000000",
"Index": torch.arange(self.scenario_duration),
}
}
if scenarios is not None:
for name, timeseries in scenarios.items():
self.add_scenario(timeseries=timeseries, name=name)
self.calibration_variables = (
[] if calibration_variables is None else calibration_variables
)
if scenario_info is not None:
self.info.update(scenario_info)
self.verify_scenario_info()
self.current_scenario = 0
def __getitem__(self, item: tuple[int | str, str]) -> torch.Tensor:
"""Get a scenario timeseries from the model.
Parameters
----------
item: tuple[int, str] | int
The index or name of the scenario.
"""
try:
scenario, variable = item
except Exception as e:
logger.error(
f"Error getting scenario: {item} should be a tuple of (name, variable) or (index, variable)"
)
raise e
if isinstance(scenario, str):
scenario = self.get_scenario_index(scenario)
return self.timeseries[scenario][variable]
[docs]
def add_scenario(self, timeseries: dict, name: str = None, colour: str = None):
"""Add a scenario to the model.
Parameters
----------
timeseries: dict
The timeseries of the scenario.
name: str | None
The name of the scenario. If None, the scenario will be named
"Scenario.N" where N is the number of scenarios.
colour: str | None
The colour of the scenario. If None, a random colour will be generated.
"""
# Add the scenario info
scID = len(self.info)
name = f"Scenario.{scID}" if name is None else name
colour = f"#{random.randint(0, 0xFFFFFF):06x}" if colour is None else colour
self.info[scID] = {
"Name": name,
"Colour": colour,
"Index": np.arange(
self.parameters["timesteps"] - self.parameters["scenario_trigger"]
),
}
# Copy default scenario as a starting point
self.timeseries[scID] = self.get_default_scenario()
trigger = self.parameters["scenario_trigger"]
# Update the scenario timeseries using the user-provided timeseries
for k, v in timeseries.items():
if k not in self.timeseries[scID].keys():
raise KeyError(
f"Key {k} not found in scenario {scID}. Add it to the default scenario."
)
# If the timeseries is a number, replace the default value
if isinstance(v, (int, float)):
self.timeseries[scID][k][trigger:] = v
# If the timeseries is a vector, assume it starts at the trigger
elif isinstance(v, torch.Tensor):
t = min(len(v), self.parameters["timesteps"] - trigger)
self.timeseries[scID][k][trigger : trigger + t, 0] = v.squeeze()[:t]
elif isinstance(v, (pd.Series, pd.DataFrame)):
t = min(len(v), self.parameters["timesteps"] - trigger)
self.timeseries[scID][k][trigger : trigger + t, 0] = torch.tensor(
v.values[:t]
)
self.info[scID]["Index"] = v.index.to_numpy()
else:
t = min(len(v), self.parameters["timesteps"] - trigger)
self.timeseries[scID][k][trigger : trigger + t, 0] = torch.tensor(v[:t])
[docs]
@classmethod
def from_excel(cls, excel_path: str, parameters: Parameters):
"""Initialize the scenarios from an Excel file.
Parameters
----------
excel_path: str
The path to the Excel file containing the scenarios.
parameters: Parameters
The parameters of the model. These are necessary to create the
default scenarios.
"""
raise NotImplementedError("Not implemented")
[docs]
@classmethod
def from_json(cls, json_path: str, parameters: Parameters):
"""Initialize the scenarios from a JSON file.
Parameters
----------
json_path: str
The path to the JSON file containing the scenarios.
parameters: Parameters
The parameters of the model. These are necessary to create the
default scenarios.
"""
with open(json_path, "r") as f:
data = json.load(f)
# Get the scenario details
info = data.pop("ScenarioDetails")
info = {int(float(k)): v for k, v in info.items()}
# Get the calibration variables
calibration_variables = data.pop("CalibrationVariables")
# Get the timeseries
timeseries = {}
for k, v in info.items():
if k != 0:
timeseries[k] = data[f"Scenario.{k}"]
return cls(
parameters=parameters,
scenarios=timeseries,
scenario_info=info,
calibration_variables=calibration_variables,
)
[docs]
def get_default_scenario(self) -> dict:
"""Return the default scenario variable in vectorized form."""
default_values = self.get_default_scenario_values()
vectorized = {}
for k, v in default_values.items():
vectorized[k] = v * torch.ones(self.parameters.hyper["timesteps"], 1)
return vectorized
[docs]
def get_default_scenario_values(self) -> dict:
"""Return the default scenario values.
This function returns a dictionary of the scenario values with their
default values.
"""
return {}
[docs]
def get_scenario_index(self, scenario: str) -> int:
"""Get the index of a scenario by name.
Parameters
----------
scenario: str
The name of the scenario.
Returns
-------
int
The index of the scenario.
"""
for scenario_id, info in self.info.items():
if info["Name"] == scenario:
return scenario_id
raise ValueError(f"Scenario {scenario} not found")
[docs]
def to_excel(self, excel_path: str):
"""Save the scenarios to an Excel file.
Parameters
----------
excel_path: str
The path to the Excel file to save the scenarios to.
"""
raise NotImplementedError("Not implemented")
[docs]
def to_json(self, json_path: str):
"""Save the scenarios to a JSON file.
Parameters
----------
json_path: str
The path to the JSON file to save the scenarios to.
"""
# Convert timeseries to dict of lists
trigger = self.parameters["scenario_trigger"]
data = {
f"Scenario.{sc}": {k: v.squeeze()[trigger:].tolist() for k, v in ts.items()}
for sc, ts in self.timeseries.items()
}
# Add the calibration variables
data["CalibrationVariables"] = self.calibration_variables
# Convert scenario info to dict of lists
data["ScenarioDetails"] = {}
for k, v in self.info.items():
data["ScenarioDetails"][k] = {**v, "Index": v["Index"].tolist()}
# Save the data to a JSON file
with open(json_path, "w") as f:
json.dump(data, f)
[docs]
def to_nn_parameters(self, scenario: int = 0):
"""Convert the scenarios to a PyTorch ParameterDict.
Parameters
----------
scenario: int
The scenario to convert to PyTorch parameters.
"""
# Set the current scenario
self.current_scenario = scenario
# In general, we keep scenarios fixed, so we set requires_grad to False
vscenarios = torch.nn.ParameterDict(self.timeseries[scenario])
for k, tensor in vscenarios.items():
tensor.requires_grad = k in self.calibration_variables
return vscenarios
[docs]
def verify_scenario_info(self):
"""Verify that the scenario info is consistent:
1. There should be a one-to-one mapping between scenario info and timeseries
2. The scenario info should be a subset of the timeseries keys
3. The scenario info should have the ["Name", "Colour", "Index"] keys
"""
# Check that there is a one-to-one mapping between scenario info and timeseries
if set(self.info.keys()) != set(self.timeseries.keys()):
raise ValueError(
"There should be a one-to-one mapping between scenario info and timeseries"
)
# Check that the scenario info is a subset of the timeseries keys
for k, v in self.info.items():
if set(v.keys()) != {"Name", "Colour", "Index"}:
raise ValueError(
f"Scenario info for scenario {k} should have the ['Name', 'Colour', 'Index'] keys"
)
if __name__ == "__main__":
pass