From 6744f843b4f68e1b9d5c52f548cc599555594baf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 19:26:13 +0000 Subject: [PATCH 1/3] WIP: M4 benchmark harness for NNS.ARMA (Hourly/Weekly/Daily) Parallel, checkpointed M4 runner with correct sMAPE/MASE/OWA and a seasonally-adjusted Naive2. Per-series NNS forecast uses M4's declared seasonality as the nns_seas modulo and the default optim objective. Validation (30 series/subset): Hourly is strong (OWA ~0.53, near the M4 winners); Daily/Weekly stay ~3-4x worse than naive due to a catastrophic tail on near-random-walk series. Framing of the final example still TBD; no README/PR yet. Includes _modonly_probe.py scratch comparison. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MxVLKYqazC2uuAW3P3MbAm --- gists/timeseries/m4/.gitignore | 3 + gists/timeseries/m4/_modonly_probe.py | 60 +++++++ gists/timeseries/m4/m4_benchmark.py | 234 ++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 gists/timeseries/m4/.gitignore create mode 100644 gists/timeseries/m4/_modonly_probe.py create mode 100644 gists/timeseries/m4/m4_benchmark.py diff --git a/gists/timeseries/m4/.gitignore b/gists/timeseries/m4/.gitignore new file mode 100644 index 00000000..ca0d096c --- /dev/null +++ b/gists/timeseries/m4/.gitignore @@ -0,0 +1,3 @@ +# Downloaded M4 data and transient benchmark outputs -- regenerated by the script. +m4_data/ +results/ diff --git a/gists/timeseries/m4/_modonly_probe.py b/gists/timeseries/m4/_modonly_probe.py new file mode 100644 index 00000000..7e9f5927 --- /dev/null +++ b/gists/timeseries/m4/_modonly_probe.py @@ -0,0 +1,60 @@ +"""Compare nns_seas mod_only=True vs False (logical modulo, default optim objective). + +Run from a dir containing m4_data/-train.csv and -test.csv. +""" +import warnings + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") +import nns as NNS + + +def smape(a, f): + d = np.abs(a) + np.abs(f) + return 200 * np.mean(np.where(d == 0, 0, np.abs(a - f) / d)) + + +def mase(a, f, tr, m=1): + s = np.mean(np.abs(tr[m:] - tr[:-m])) or 1e-8 + return np.mean(np.abs(a - f)) / s + + +def periods_for(y, modulo, mod_only): + tn = int(0.8 * len(y)) + lim = tn / min(4, max(3, round(tn / 100))) + p = np.asarray(NNS.nns_seas(y, modulo=modulo, mod_only=mod_only).get("periods", []), int) + p = np.unique(p[(p > 1) & (p < lim)])[:25] + return p if p.size else np.array([modulo if modulo < lim else 2]) + + +def run(freq, h, modulo, mod_only, n=30): + tr = pd.read_csv(f"m4_data/{freq}-train.csv") + te = pd.read_csv(f"m4_data/{freq}-test.csv") + ns, n2s, nm, n2m = [], [], [], [] + for i in range(n): + y = tr.iloc[i, 1:].dropna().astype(float).values + test = te.iloc[i, 1:].dropna().astype(float).values[:h] + try: + fc = np.asarray( + NNS.nns_arma_optim( + variable=y, h=h, seasonal_factor=periods_for(y, modulo, mod_only), + print_trace=False, + )["results"], + float, + ) + except Exception: + fc = np.repeat(y[-1], h) + n2 = np.repeat(y[-1], h) + ns.append(smape(test, fc)); nm.append(mase(test, fc, y)) + n2s.append(smape(test, n2)); n2m.append(mase(test, n2, y)) + owa = 0.5 * (np.mean(ns) / np.mean(n2s) + np.mean(nm) / np.mean(n2m)) + return owa, float(np.mean(ns)) + + +if __name__ == "__main__": + for freq, h, mod in [("Hourly", 48, 24), ("Daily", 14, 7), ("Weekly", 13, 12)]: + for mo in (True, False): + owa, sm = run(freq, h, mod, mo) + print(f"{freq:7s} mod={mod:<2} mod_only={str(mo):5s} OWA={owa:.3f} sMAPE={sm:.2f}") diff --git a/gists/timeseries/m4/m4_benchmark.py b/gists/timeseries/m4/m4_benchmark.py new file mode 100644 index 00000000..c4ef4fd4 --- /dev/null +++ b/gists/timeseries/m4/m4_benchmark.py @@ -0,0 +1,234 @@ +"""NNS.ARMA on the M4 competition (Hourly + Weekly + Daily subsets). + +The M4 competition (Makridakis et al., 2018-2020) is the recognised benchmark for +univariate forecasting: 100,000 series scored by OWA -- the average of sMAPE and +MASE, each normalised by the competition's Naive2 baseline (so Naive2 == 1.000). + +This script runs the three *seasonal / longer-series* subsets, where a seasonal +ARMA method is best suited: + + Hourly 414 series m=24 h=48 + Weekly 359 series m=1 h=13 (M4 treats Weekly as non-seasonal) + Daily 4227 series m=1 h=14 (M4 treats Daily as non-seasonal) + +For every series it discovers seasonal periods with nns_seas, forecasts h steps +with NNS.ARMA.optim, and scores sMAPE / MASE / OWA against M4's own Naive2. No +neural network, no training loop. + +Usage: + python m4_benchmark.py # all three subsets, all series + python m4_benchmark.py --freq Hourly # one subset + python m4_benchmark.py --limit 50 # first 50 series per subset (smoke test) + python m4_benchmark.py --workers 4 + +Results are checkpointed to results/m4_.csv (resumable) and summarised to +results/m4_summary.csv. +""" + +from __future__ import annotations + +import argparse +import os +import warnings +from concurrent.futures import ProcessPoolExecutor, as_completed +from urllib.request import urlretrieve + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +import nns as NNS + +BASE = "https://raw.githubusercontent.com/Mcompetitions/M4-methods/master/Dataset" +CACHE = "m4_data" +RESULTS = "results" + +# M4 official seasonality (m) and forecast horizon (h) per frequency. +SUBSETS = { + "Hourly": {"m": 24, "h": 48}, + "Weekly": {"m": 1, "h": 13}, + "Daily": {"m": 1, "h": 14}, +} + + +# ── Data ────────────────────────────────────────────────────────────────────── + +def _download(rel: str) -> str: + os.makedirs(CACHE, exist_ok=True) + dest = os.path.join(CACHE, os.path.basename(rel)) + if not os.path.exists(dest): + urlretrieve(f"{BASE}/{rel}", dest) + return dest + + +def load_subset(freq: str) -> tuple[list[str], list[np.ndarray], np.ndarray]: + train = pd.read_csv(_download(f"Train/{freq}-train.csv")) + test = pd.read_csv(_download(f"Test/{freq}-test.csv")) + ids = train.iloc[:, 0].astype(str).to_numpy() + y_train = [row[~np.isnan(row)] for row in train.iloc[:, 1:].to_numpy(dtype=float)] + y_test = test.iloc[:, 1:].to_numpy(dtype=float) + return ids, y_train, y_test + + +# ── Metrics ───────────────────────────────────────────────────────────────── + +def smape(actual: np.ndarray, forecast: np.ndarray) -> float: + denom = np.abs(actual) + np.abs(forecast) + diff = np.abs(actual - forecast) + return float(200.0 * np.mean(np.where(denom == 0, 0.0, diff / denom))) + + +def mase(actual: np.ndarray, forecast: np.ndarray, train: np.ndarray, m: int) -> float: + m = m if len(train) > m else 1 + scale = np.mean(np.abs(train[m:] - train[:-m])) + if scale == 0: + scale = 1e-8 + return float(np.mean(np.abs(actual - forecast)) / scale) + + +def _is_seasonal(train: np.ndarray, m: int) -> bool: + """M4's 90% autocorrelation seasonality test at lag m.""" + n = len(train) + if m <= 1 or n < 3 * m: + return False + x = train - train.mean() + acf = np.array([np.sum(x[k:] * x[:-k]) / np.sum(x**2) for k in range(1, m + 1)]) + limit = 1.645 * np.sqrt((1.0 + 2.0 * np.sum(acf[:-1] ** 2)) / n) + return bool(np.abs(acf[-1]) > limit) + + +def naive2_forecast(train: np.ndarray, h: int, m: int) -> np.ndarray: + """M4 Naive2: seasonally-adjusted naive (==Naive1 when non-seasonal).""" + if not _is_seasonal(train, m): + return np.repeat(train[-1], h) + # Multiplicative seasonal indices via ratio-to-moving-average. + n = len(train) + ma = pd.Series(train).rolling(m, center=True).mean() + if m % 2 == 0: + ma = ma.rolling(2).mean().shift(-1) + ratio = train / ma.to_numpy() + idx = np.array([np.nanmean(ratio[i::m]) for i in range(m)]) + idx *= m / idx.sum() + seas_train = np.tile(idx, n // m + 1)[:n] + deseason = train / seas_train + fut_seas = np.tile(idx, h // m + 1)[:h] + return deseason[-1] * fut_seas + + +# ── Per-series NNS forecast ────────────────────────────────────────────────── + +def nns_forecast(train: np.ndarray, h: int, m: int) -> np.ndarray: + # Use M4's declared seasonality as the nns_seas modulo where it exists + # (Hourly=24); leave it to auto-detect on the non-seasonal subsets. + modulo = m if m > 1 else None + seas = NNS.nns_seas(train, modulo=modulo, plot=False) + periods = np.asarray(seas.get("periods", []), dtype=np.int64) + # Cap to the same limit nns_arma_optim enforces (period < train_n / denom). + train_n = int(0.8 * len(train)) + limit = train_n / min(4, max(3, round(train_n / 100))) + periods = np.unique(periods[(periods > 1) & (periods < limit)])[:25] + if periods.size == 0: + periods = np.array([m if 1 < m < limit else 2], dtype=np.int64) + fit = NNS.nns_arma_optim( # default objective; let it search lin/nonlin/both + variable=train, + h=h, + seasonal_factor=periods, + negative_values=True, + print_trace=False, + plot=False, + ) + return np.asarray(fit["results"], dtype=np.float64) + + +def _score_one(args: tuple) -> dict: + sid, train, test, m = args + h = len(test) + try: + fc = nns_forecast(train, h, m) + ok = True + except Exception: # noqa: BLE001 -- a failed series falls back to Naive2 + fc = naive2_forecast(train, h, m) + ok = False + n2 = naive2_forecast(train, h, m) + return { + "id": sid, + "nns_smape": smape(test, fc), + "nns_mase": mase(test, fc, train, m), + "naive2_smape": smape(test, n2), + "naive2_mase": mase(test, n2, train, m), + "nns_ok": ok, + } + + +# ── Run one frequency ──────────────────────────────────────────────────────── + +def run_freq(freq: str, limit: int | None, workers: int) -> pd.DataFrame: + m, h = SUBSETS[freq]["m"], SUBSETS[freq]["h"] + ids, y_train, y_test = load_subset(freq) + if limit: + ids, y_train, y_test = ids[:limit], y_train[:limit], y_test[:limit] + + os.makedirs(RESULTS, exist_ok=True) + ckpt = os.path.join(RESULTS, f"m4_{freq}.csv") + done = set(pd.read_csv(ckpt)["id"]) if os.path.exists(ckpt) else set() + + tasks = [ + (sid, y_train[i], y_test[i][: h], m) + for i, sid in enumerate(ids) + if sid not in done + ] + print(f"[{freq}] {len(tasks)} series to run ({len(done)} cached), m={m}, h={h}") + + rows: list[dict] = [] + with ProcessPoolExecutor(max_workers=workers) as ex: + futures = {ex.submit(_score_one, t): t[0] for t in tasks} + for n_done, fut in enumerate(as_completed(futures), 1): + rows.append(fut.result()) + if n_done % 50 == 0 or n_done == len(tasks): + pd.DataFrame(rows).to_csv( + ckpt, mode="a", header=not os.path.exists(ckpt), index=False + ) + print(f" [{freq}] {n_done}/{len(tasks)}") + rows = [] + if rows: + pd.DataFrame(rows).to_csv(ckpt, mode="a", header=not os.path.exists(ckpt), index=False) + return pd.read_csv(ckpt) + + +def summarise(freq: str, df: pd.DataFrame) -> dict: + nns_s, nns_m = df["nns_smape"].mean(), df["nns_mase"].mean() + n2_s, n2_m = df["naive2_smape"].mean(), df["naive2_mase"].mean() + owa = 0.5 * (nns_s / n2_s + nns_m / n2_m) + return { + "freq": freq, + "series": len(df), + "nns_fail": int((~df["nns_ok"]).sum()), + "nns_sMAPE": round(nns_s, 3), + "nns_MASE": round(nns_m, 3), + "naive2_sMAPE": round(n2_s, 3), + "naive2_MASE": round(n2_m, 3), + "NNS_OWA": round(owa, 3), + } + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--freq", choices=list(SUBSETS), help="run a single subset") + ap.add_argument("--limit", type=int, default=None, help="first N series per subset") + ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 2))) + args = ap.parse_args() + + freqs = [args.freq] if args.freq else list(SUBSETS) + summary = [summarise(f, run_freq(f, args.limit, args.workers)) for f in freqs] + + os.makedirs(RESULTS, exist_ok=True) + out = pd.DataFrame(summary) + out.to_csv(os.path.join(RESULTS, "m4_summary.csv"), index=False) + print("\n=== M4 BENCHMARK (NNS.ARMA.optim) ===\n") + print(out.to_string(index=False)) + print("\nOWA < 1.000 beats the Naive2 baseline; lower is better.") + + +if __name__ == "__main__": + main() From fa29ee1215f750af35a26c59ae4716b628efca64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 19:32:01 +0000 Subject: [PATCH 2/3] Make M4 probe self-contained for local experimentation Embed the M4 data download (cached in ./m4_data) and surface a CONFIG block: series count, modulo source, mod_only, optim-vs-plain nns_arma, explicit training_set, and a custom obj_fn hook. Run, tweak, compare OWA. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MxVLKYqazC2uuAW3P3MbAm --- gists/timeseries/m4/_modonly_probe.py | 91 ++++++++++++++++++++------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/gists/timeseries/m4/_modonly_probe.py b/gists/timeseries/m4/_modonly_probe.py index 7e9f5927..24ce2e21 100644 --- a/gists/timeseries/m4/_modonly_probe.py +++ b/gists/timeseries/m4/_modonly_probe.py @@ -1,8 +1,14 @@ -"""Compare nns_seas mod_only=True vs False (logical modulo, default optim objective). +"""Local experimentation harness for NNS.ARMA on the M4 seasonal subsets. -Run from a dir containing m4_data/-train.csv and -test.csv. +Self-contained: downloads the M4 data on first run (cached in ./m4_data), then +scores NNS forecasts vs the Naive (last-value) baseline on a sample of series. + +Tweak the CONFIG block, run `python _modonly_probe.py`, compare OWA/sMAPE, then +revert to m4_benchmark.py for the full committed setup. """ + import warnings +from urllib.request import urlretrieve import numpy as np import pandas as pd @@ -10,6 +16,33 @@ warnings.filterwarnings("ignore") import nns as NNS +# ── CONFIG — edit and re-run ──────────────────────────────────────────────── +N_SERIES = 30 # series per subset (None = all) +MODULO_FROM_M = True # use M4 seasonality (Hourly=24) as nns_seas modulo; else MODULO below +MODULO = {"Hourly": 24, "Daily": 7, "Weekly": 12} +MOD_ONLY = True # nns_seas mod_only +USE_OPTIM = True # True: nns_arma_optim (searches lin/nonlin/both); False: plain nns_arma +TRAINING_SET = None # int to pass an explicit training_set to the optimizer; None = default +OBJ_FN = None # e.g. lambda p, a: np.mean(np.abs(p - a)); None = optimizer default +MAX_PERIODS = 25 +SUBSETS = {"Hourly": (24, 48), "Weekly": (1, 13), "Daily": (1, 14)} # name -> (m, h) +# ───────────────────────────────────────────────────────────────────────────── + +BASE = "https://raw.githubusercontent.com/Mcompetitions/M4-methods/master/Dataset" + + +def fetch(freq: str) -> tuple[pd.DataFrame, pd.DataFrame]: + import os + os.makedirs("m4_data", exist_ok=True) + paths = {} + for split in ("Train", "Test"): + dest = f"m4_data/{freq}-{split.lower()}.csv" + if not os.path.exists(dest): + print(f" downloading {freq}-{split.lower()}.csv ...") + urlretrieve(f"{BASE}/{split}/{freq}-{split.lower()}.csv", dest) + paths[split] = dest + return pd.read_csv(paths["Train"]), pd.read_csv(paths["Test"]) + def smape(a, f): d = np.abs(a) + np.abs(f) @@ -21,40 +54,52 @@ def mase(a, f, tr, m=1): return np.mean(np.abs(a - f)) / s -def periods_for(y, modulo, mod_only): +def periods_for(y, freq): + modulo = (SUBSETS[freq][0] if MODULO_FROM_M else MODULO[freq]) + modulo = modulo if modulo and modulo > 1 else None tn = int(0.8 * len(y)) lim = tn / min(4, max(3, round(tn / 100))) - p = np.asarray(NNS.nns_seas(y, modulo=modulo, mod_only=mod_only).get("periods", []), int) - p = np.unique(p[(p > 1) & (p < lim)])[:25] - return p if p.size else np.array([modulo if modulo < lim else 2]) + p = np.asarray(NNS.nns_seas(y, modulo=modulo, mod_only=MOD_ONLY).get("periods", []), int) + p = np.unique(p[(p > 1) & (p < lim)])[:MAX_PERIODS] + return p if p.size else np.array([2]) + + +def forecast(y, h, freq): + sf = periods_for(y, freq) + kw = dict(variable=y, h=h, seasonal_factor=sf, negative_values=True, print_trace=False) + if OBJ_FN is not None: + kw.update(obj_fn=OBJ_FN, objective="min") + if USE_OPTIM: + if TRAINING_SET is not None: + kw["training_set"] = TRAINING_SET + return np.asarray(NNS.nns_arma_optim(**kw)["results"], float) + kw.pop("print_trace", None) + out = NNS.nns_arma(variable=y, h=h, seasonal_factor=sf, negative_values=True) + return np.asarray(out["results"] if isinstance(out, dict) else out, float) -def run(freq, h, modulo, mod_only, n=30): - tr = pd.read_csv(f"m4_data/{freq}-train.csv") - te = pd.read_csv(f"m4_data/{freq}-test.csv") - ns, n2s, nm, n2m = [], [], [], [] +def run(freq): + m, h = SUBSETS[freq] + tr, te = fetch(freq) + n = len(tr) if N_SERIES is None else min(N_SERIES, len(tr)) + ns, n2s, nm, n2m, fail = [], [], [], [], 0 for i in range(n): y = tr.iloc[i, 1:].dropna().astype(float).values test = te.iloc[i, 1:].dropna().astype(float).values[:h] try: - fc = np.asarray( - NNS.nns_arma_optim( - variable=y, h=h, seasonal_factor=periods_for(y, modulo, mod_only), - print_trace=False, - )["results"], - float, - ) + fc = forecast(y, h, freq) except Exception: - fc = np.repeat(y[-1], h) + fc = np.repeat(y[-1], h); fail += 1 n2 = np.repeat(y[-1], h) ns.append(smape(test, fc)); nm.append(mase(test, fc, y)) n2s.append(smape(test, n2)); n2m.append(mase(test, n2, y)) owa = 0.5 * (np.mean(ns) / np.mean(n2s) + np.mean(nm) / np.mean(n2m)) - return owa, float(np.mean(ns)) + print(f"{freq:7s} n={n:<4} OWA={owa:.3f} sMAPE={np.mean(ns):.2f} MASE={np.mean(nm):.2f} fail={fail}") if __name__ == "__main__": - for freq, h, mod in [("Hourly", 48, 24), ("Daily", 14, 7), ("Weekly", 13, 12)]: - for mo in (True, False): - owa, sm = run(freq, h, mod, mo) - print(f"{freq:7s} mod={mod:<2} mod_only={str(mo):5s} OWA={owa:.3f} sMAPE={sm:.2f}") + print(f"config: optim={USE_OPTIM} mod_only={MOD_ONLY} modulo_from_m={MODULO_FROM_M} " + f"obj_fn={'custom' if OBJ_FN else 'default'} training_set={TRAINING_SET}") + for freq in SUBSETS: + run(freq) + print("\nOWA < 1.000 beats the Naive baseline; lower is better.") From 3f2e119b3303ac67f866a65bd7e1a8b91b5216a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 01:57:39 +0000 Subject: [PATCH 3/3] Fix NaN forecast from zero-variance lag-subsample in NNS.ARMA _numeric_seasonal_weights divided 1 / (subsample_CV / baseline_CV). When a seasonal lag-subsample is perfectly stable (CV 0) in an otherwise-varying series, that is 1/0 = Inf, and the weight normalisation collapses to Inf/Inf = NaN -- silently producing an all-NaN forecast for valid input (e.g. M4 Hourly series 131 at period 168). Floor the CV ratio so a maximally-seasonal lag gets a large but finite weight. A fully constant series (baseline CV 0 -> relative NaN) still propagates NaN, matching the R reference. Adds a regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MxVLKYqazC2uuAW3P3MbAm --- src/nns/arma.py | 8 +++++++- tests/invariants/test_arma.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/nns/arma.py b/src/nns/arma.py index 53b29051..5e55105f 100644 --- a/src/nns/arma.py +++ b/src/nns/arma.py @@ -792,7 +792,13 @@ def _numeric_seasonal_weights( np.float64(np.std(variable, ddof=1)) / np.float64(np.mean(variable)) ) relative = output / baseline_cv - seasonal_weighting = 1.0 / relative + # A perfectly stable lag-subsample in an otherwise-varying series has CV 0, so + # relative 0 -> 1/relative = Inf -> Inf/Inf = NaN in the normalisation below, + # poisoning the forecast. Floor relative so that (maximally seasonal) lag gets a + # large but finite weight. A fully constant series (baseline CV 0 -> relative + # NaN) is intentionally left to propagate NaN, matching the R reference. + floor = np.finfo(np.float64).eps + seasonal_weighting = 1.0 / np.maximum(relative, floor) observation_weighting = 1.0 / np.sqrt(lags.astype(np.float64)) denom = float(np.sum(observation_weighting * seasonal_weighting)) diff --git a/tests/invariants/test_arma.py b/tests/invariants/test_arma.py index 325c2792..f32849ed 100644 --- a/tests/invariants/test_arma.py +++ b/tests/invariants/test_arma.py @@ -33,6 +33,22 @@ def test_nns_arma_numeric_seasonal_dynamic_raises() -> None: nns_arma(variable, h=3, seasonal_factor=5, dynamic=True) +def test_numeric_seasonal_weights_constant_subsample_is_finite() -> None: + # Regression: a lag-subsample with zero variance gives CV 0, so the seasonal + # weighting 1 / (CV / baseline) = 1 / 0 = Inf and the normalisation collapsed to + # Inf / Inf = NaN -- which produced an all-NaN forecast. Now guarded. + rng = np.random.default_rng(0) + variable = rng.uniform(50.0, 150.0, size=40) + variable[::-4] = 10.0 # every 4th point (from the end) identical -> std 0 + + weights = _numeric_seasonal_weights(variable, np.array([4], dtype=np.int64)) + assert np.isfinite(weights).all() + np.testing.assert_allclose(weights.sum(), 1.0) + + forecast = nns_arma(variable, h=8, seasonal_factor=4, method="lin") + assert np.isfinite(forecast).all() + + @pytest.mark.stochastic def test_nns_arma_pred_int_returns_interval_dict() -> None: variable = np.sin(np.arange(1, 40, dtype=np.float64) / 3.0) + 2.0