Time Series
FakerSynthesizer also supports the generation of time series: rows whose values depend on earlier rows of the same entity, such as a monthly balance per account or daily deposits per branch.
A time series is defined by the following elements:
- Entity: what each series belongs to (an account, a branch, a branch × product), set by
partition_byin thesequence. - Time: the order of the rows within each entity, set by
order_byin thesequence. - Column behaviour:
seasonality,trendandpersistenceon a numerical column. - Table behaviour:
reacts_to,balances,regimeandlifecycle, for dependencies across columns and rows.
On a column
deposit_rate:
datatype: numerical
vartype: float
min: 0
max: 1
seasonality: {on: month_start, Dec: 1.5, Feb: 1.3, Mar: 1.2, Apr: 1.3} # bonuses, tax refunds
trend: 0.03 # +3% a year on the mean, compounding
persistence: 0.6 # memory from one row to the next
| Key | Meaning |
|---|---|
seasonality |
Multipliers on the column's mean by calendar position; positions not listed are 1.0. by: month (default, keys Jan…Dec or 1…12), quarter (Q1…Q4), weekday (Mon…Sun), day_of_month (1…31). on: the date column (default: the first order_by column). normalize (default true) rescales the multipliers to average 1 over the cycle, so the declared distribution stays the average one; false applies them literally. mode: additive shifts the mean instead (columns that can be negative, e.g. rate changes). |
trend |
Growth of the mean per year from the first date, compounding: 0.03 is +3%/year. {per_year: 0.001, mode: additive} adds instead. |
persistence |
AR(1) memory of the draw itself, between 0 and 0.99. Implemented with a Gaussian copula: each row is still drawn from exactly its declared distribution — only the dependence between consecutive rows changes. |
In practice: with distributions: {deposit_rate: {distribution: beta, mean: 0.02, concentration: 60}}, the multipliers above average 1.108 over the year, so, normalised, December's mean is 2.7% and the unlisted months' 1.8%; the yearly average stays 2%. Every December row of every entity draws from that December distribution, so monthly deposit totals show the same peak every year. With normalize: false December is 3% and the unlisted months 2%.
Seasonality, trend and reactions move the mean and keep the shape: a beta stays a beta with the same concentration, a lognormal keeps its sigma. They act on the distribution the row would have had anyway — from distributions, from the matching conditions rule, or uniform over min/max — so segment effects and time effects combine.
On the table
reacts_to — a column responds to earlier values
The signal is the sum (stat: mean for the average) of column over window rows, ending lag rows before the current one, within the entity. The mean becomes mean × exp(effect × signal): after two months in which competitors paid one point more (a gap of 0.01 each month, 0.02 in total), withdrawals run exp(0.4) ≈ 1.49× higher. Several entries multiply. column can be any numerical column of the table, or one generated through time (then lag ≥ 1).
balances — stocks that follow from their flows
balances:
balance:
start: opening_balance # value before the entity's first row (column or number)
growth: interest_rate # credited interest, multiplicative per row
inflow: deposit_rate # rate x previous balance
outflow: withdrawal_rate # rate x previous balance, at most what is available
flows_as: {inflow: deposits, outflow: withdrawals} # emit the amounts too
Per row, with previous = start on the entity's first row:
inflow = previous × inflow_rate
available = previous × (1 + growth) + inflow
outflow = min(previous × outflow_rate, available)
balance = available − outflow
The identity balance = previous × (1 + growth) + inflow − outflow holds exactly on every row, and the balance never goes negative, because an outflow takes at most what is available — nothing is clipped afterwards. Aggregates of the balance and its flows reconcile. growth, inflow and outflow are columns or numbers; at least one is required. Inputs that would make a non-negative balance meaningless are rejected, with the offending values: a negative or missing start, growth below −1, negative rates.
regime — a categorical state with memory
A Markov chain per entity: each state lasts its average duration, and the long-run share of each state is proportional to it (here stable 86%, rate competition 14%). transition: {stable: {stable: 0.95, rate_competition: 0.05}, ...} gives the matrix explicitly instead; initial sets the first row's shares. The regime is generated before everything else, so conditions can use it:
conditions:
- column: competitor_rate_gap
rules: [{when: {regime: rate_competition},
distribution: {distribution: beta, mean: 0.01, concentration: 400}}]
lifecycle — contiguous spans
Each entity covers one contiguous span of its rows — products launch and retire at a branch, accounts open and close — and rows outside it are dropped. start: first begins at the entity's first row; random anywhere that leaves room for min_length. Lengths average avg_length (spans reaching the end of the grid are cut there). Balances start from start, and reactions only see rows inside the span.
Worked example: a monthly deposits warehouse
Savings products held at bank branches: a date dimension with the rate environment, a branch and a product dimension, the products each branch offers, and a monthly fact table whose balances follow from interest, deposits and withdrawals.
# Time-series FakerSynthesizer configuration: a monthly deposits warehouse.
#
# Savings products held at bank branches. Rows depend on earlier rows of the same
# entity (a branch x product): balances follow from interest, deposits and
# withdrawals; rates are seasonal and persistent; withdrawals react to the
# competitor rate gap, which a rate-competition regime drives.
#
# dim_month ──────────────┐
# dim_branch ──< branch_products >── dim_product
# └──< fact_balances (branch x product x month) >── dim_month
# └── kpi_product_month (derived)
tables:
dim_month:
primary_key: month_key # 1..60, one per month
scaffold:
dimensions:
- {column: month_start, date_range: {start: "2020-01-01", end: "2024-12-01", freq: MS}}
sequence: {order_by: [month_start]}
regime: {avg_duration: {stable: 18, rate_competition: 3}}
columns:
month_key: {datatype: numerical, vartype: int}
interest_rate: {datatype: numerical, vartype: float, min: 0, max: 0.01, persistence: 0.9}
competitor_rate_gap: {datatype: numerical, vartype: float, min: 0, max: 0.05, persistence: 0.5}
distributions:
interest_rate: {distribution: beta, mean: 0.002, concentration: 2000}
competitor_rate_gap: {distribution: beta, mean: 0.002, concentration: 400}
conditions:
- column: competitor_rate_gap
rules: [{when: {regime: rate_competition},
distribution: {distribution: beta, mean: 0.01, concentration: 400}}]
dim_branch:
primary_key: branch_id
key_format: "BR{n:04d}"
columns:
branch_id: {datatype: string, vartype: string}
location: {datatype: categorical, vartype: string, categories: {urban: 60, rural: 40}}
dim_product:
primary_key: product_id
key_format: "SAV{n:02d}"
columns:
product_id: {datatype: string, vartype: string}
popularity: {datatype: numerical, vartype: float, min: 0.01, max: 100}
distributions:
popularity: {distribution: lognormal, mean: 0, sigma: 1}
branch_products: # which products each branch offers
primary_key: [branch_id, product_id]
foreign_keys:
- {column: branch_id, referenced_table: dim_branch, cardinality: {min: 2, max: 6}}
- {column: product_id, referenced_table: dim_product, weight_column: popularity}
columns:
branch_id: {datatype: string, vartype: string}
product_id: {datatype: string, vartype: string}
opening_balance: {datatype: numerical, vartype: float, min: 1e4, max: 5e8}
distributions:
opening_balance: {distribution: lognormal, mean: 15, sigma: 1}
fact_balances:
primary_key: [branch_id, product_id, month_key]
scaffold:
dimensions:
- {column: _branch_product, from_table: branch_products,
include: [branch_id, product_id, opening_balance]}
- {column: month_key, from_table: dim_month,
include: [month_start, interest_rate, competitor_rate_gap]}
foreign_keys:
- {column: branch_id, referenced_table: dim_branch, context_columns: [location]}
- {column: product_id, referenced_table: dim_product}
- {column: month_key, referenced_table: dim_month}
sequence: {partition_by: [branch_id, product_id], order_by: [month_key]}
columns:
branch_id: {datatype: string, vartype: string}
product_id: {datatype: string, vartype: string}
month_key: {datatype: numerical, vartype: int}
deposit_rate:
{datatype: numerical, vartype: float, min: 0, max: 0.5, persistence: 0.6,
seasonality: {on: month_start, Dec: 1.5, Feb: 1.3, Mar: 1.2, Apr: 1.3}}
withdrawal_rate:
{datatype: numerical, vartype: float, min: 0, max: 0.5, persistence: 0.6,
seasonality: {on: month_start, Apr: 1.4, Nov: 1.1, Dec: 1.3}} # tax payments, holidays
distributions:
deposit_rate: {distribution: beta, mean: 0.02, concentration: 60}
withdrawal_rate: {distribution: beta, mean: 0.018, concentration: 80}
conditions: # rural depositors move less
- column: withdrawal_rate
rules: [{when: {branch_id.location: rural},
distribution: {distribution: beta, mean: 0.012, concentration: 80}}]
reacts_to:
withdrawal_rate: [{column: competitor_rate_gap, lag: 1, window: 2, effect: 20}]
balances:
balance: {start: opening_balance, growth: interest_rate, inflow: deposit_rate,
outflow: withdrawal_rate, flows_as: {inflow: deposits, outflow: withdrawals}}
lifecycle: {start: random, avg_length: 36}
derived_tables:
- name: kpi_product_month
type: aggregate
group_by: [product_id, month_key]
aggregations:
balance: {column: balance, agg: sum}
withdrawals: {column: withdrawals, agg: sum}
"""
Example: a time-series data warehouse with the FakerSynthesizer.
The schema (faker_timeseries.yaml) is a monthly deposits warehouse: date, branch
and product dimensions, a branch x product bridge, and a fact table whose balances
follow from interest, deposits and withdrawals — with seasonality, persistence, a
rate-competition regime, reactions to the competitor rate gap and product
lifecycles — plus a KPI table derived from the facts.
Run with: python faker_timeseries.py
"""
from pathlib import Path
from ydata.metadata import MultiMetadata
from ydata.report import ReferentialIntegrityValidator
from ydata.synthesizers import FakerSynthesizer
HERE = Path(__file__).parent
OUTPUT = Path("deposits_warehouse")
synth = FakerSynthesizer()
synth.fit(tables=HERE / "faker_timeseries.yaml")
# only root tables are sized; every other table follows from its relationships
db = synth.sample(sample_size={"dim_branch": 300, "dim_product": 12}, random_state=42)
# referential integrity of the generated warehouse (ydata-report)
integrity = ReferentialIntegrityValidator(synthetic_data=db, metadata=MultiMetadata(db)).validate()
summary = integrity["summary"]
print(f"PK violations: {summary['total_pk_violations']}, "
f"FK violations: {summary['total_fk_violations']}")
# save every table as a local file — Parquet keeps the dtypes and scales to
# millions of rows; use .to_csv(...) instead for CSV
OUTPUT.mkdir(exist_ok=True)
for name in db.keys():
table = db[name].to_pandas()
table.to_csv(OUTPUT / f"{name}.csv", index=False)
print(f"{name:18s} {len(table):>7,} rows -> {OUTPUT / f'{name}.csv'}")
In code, without a schema, the same goes to fit(..., sequence=..., timeseries={"columns": {...}, "reacts_to": ..., "balances": ..., "regime": ..., "lifecycle": ...}).
Notes
Which columns can vary through time
Time behaviour applies to sampled numerical columns, because seasonality, trend and reactions work by moving a column's mean. Keys, scaffold columns and unique columns define the structure of the table, so they stay fixed.
Condition outcomes for a time-varying column are distributions, so each segment has a mean that time effects can move. Additive mode shifts the mean rather than scaling it, so it uses distributions that can be shifted: normal, beta, uniform and triangular.
To include missing values, add them with error injection rather than missings. Errors are applied after the balances are computed, so balances and lags stay consistent and the missing cells appear only in the delivered data.
Related Materials
