File size: 8,700 Bytes
ba6c090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
"""Group context: what the other series of an evaluation window add to each series' forecast.

All three functions take the univariate series of one window, each as its observed history
(``histories``) and that history's epoch-ns timestamps (``stamps``), in the window's order. Every
value they return comes from those histories, and a series only pools with series whose history
ends at the same timestamp, its forecast origin, so no value after the origin is used.

- :func:`static_group_rows` gives each series past covariate rows: the mean of its static
  group's other z-scored histories, as Chronos-2's groups pool series by their metadata.
- :func:`joint_neighbour_rows` gives each series extra target rows: the series whose z-scored
  histories correlate best with its own, forecast jointly with it.
- :func:`chunk_groups` splits the window into chunks that are forecast jointly, every series a
  target, as t0-gamma's ``cross_inference`` and Chronos-2's ``cross_learning`` do on fev-bench.

The z-scores are taken on a grid of the window's last ``span`` distinct timestamps. Each row
these functions return covers its series' own history timestamps and is missing where no value
is available.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Final

import numpy as np
from numpy.typing import NDArray

# The timestamps that z-scores and correlations are taken over.
SPAN: Final = 2048

# Two histories correlate only over at least this many common steps.
_MIN_OVERLAP: Final = 32

# A window whose largest origin group holds more series is left without joint neighbours, whose
# correlation matrix would grow with the square of the group.
_MAX_JOINT_GROUP: Final = 8000


def _codes(values: Sequence[object]) -> NDArray[np.int64]:
    """Number each distinct value; a group's members share a number."""
    return np.unique(np.asarray(values), return_inverse=True)[1].astype(np.int64)


def _origins(stamps: Sequence[NDArray[np.int64]]) -> NDArray[np.int64]:
    """Number each series by the last timestamp of its history, its forecast origin."""
    return _codes([int(s[-1]) if s.size else -1 for s in stamps])


def _z_scores(
    histories: Sequence[NDArray[np.float64]], stamps: Sequence[NDArray[np.int64]], span: int
) -> tuple[NDArray[np.int64], NDArray[np.float32]]:
    """Return the timestamp grid and each history z-scored onto it.

    A history without spread stays missing.
    """
    grid = np.unique(np.concatenate([s[-span:] for s in stamps]))[-span:]
    z = np.full((len(histories), grid.size), np.nan, dtype=np.float32)
    for i, (row, s) in enumerate(zip(histories, stamps, strict=True)):
        with np.errstate(all="ignore"):
            scale = np.nanstd(row)
        if not np.isfinite(scale) or scale <= 0:
            continue
        at = np.searchsorted(grid, s)
        ok = (at < grid.size) & (grid[np.minimum(at, grid.size - 1)] == s)
        z[i, at[ok]] = ((row[ok] - np.nanmean(row)) / scale).astype(np.float32)
    return grid, z


def _on_own_stamps(
    values: NDArray[np.float64], grid: NDArray[np.int64], stamps: NDArray[np.int64]
) -> NDArray[np.float64]:
    """Read a grid row at a series' own history timestamps; missing off the grid."""
    if (
        values.shape == stamps.shape
        and np.array_equal(grid, stamps)
        and np.all(grid[1:] > grid[:-1])
    ):
        # Duplicate timestamps must retain searchsorted's first-occurrence semantics.
        return values.copy()
    at = np.searchsorted(grid, stamps)
    ok = (at < grid.size) & (grid[np.minimum(at, grid.size - 1)] == stamps)
    row = np.full(stamps.size, np.nan)
    row[ok] = values[at[ok]]
    return row


def static_group_rows(
    histories: Sequence[NDArray[np.float64]],
    stamps: Sequence[NDArray[np.int64]],
    statics: Sequence[Sequence[object]],
    columns: int,
    span: int = SPAN,
) -> list[NDArray[np.float64]] | None:
    """Return each series' static-group rows, one per grouping column, or ``None`` if none groups.

    ``statics`` holds each static column's value for every series. A column groups the window
    when it has at least two distinct values, fewer than the window's series, and a median group
    size of at least three. The ``columns`` of them with the most groups are kept, ties in column
    order. Each gives every series one row: the mean of its same-origin group's other z-scored
    histories. A missing value is a group of its own. A window of fewer than three series gets
    no rows.
    """
    if columns < 1:
        raise ValueError(f"static groups need at least one column, got {columns}")
    count = len(histories)
    if count < 3:
        return None
    groupings = []
    for values in statics:
        codes = _codes([repr(value) for value in values])
        sizes = np.bincount(codes)
        if 2 <= sizes.size < count and np.median(sizes[codes]) >= 3:
            groupings.append((sizes.size, codes))
    chosen = sorted(groupings, key=lambda grouping: -grouping[0])[:columns]
    if not chosen:
        return None
    grid, z = _z_scores(histories, stamps, span)
    seen = np.isfinite(z)
    filled = np.where(seen, z, np.float32(0.0))
    origin = _origins(stamps)
    rows: list[list[NDArray[np.float64]]] = [[] for _ in histories]
    for _, codes in chosen:
        group = _codes((codes * (origin.max() + 1) + origin).tolist())
        sums_by_group = np.zeros((group.max() + 1, grid.size), dtype=np.float32)
        counts_by_group = np.zeros_like(sums_by_group)
        np.add.at(sums_by_group, group, filled)
        np.add.at(counts_by_group, group, seen.astype(np.float32))
        sums = sums_by_group[group].astype(np.float64)
        counts = counts_by_group[group].astype(np.float64) - seen
        with np.errstate(all="ignore"):
            means = (sums - filled) / counts
        means[counts < 1] = np.nan
        for i, s in enumerate(stamps):
            rows[i].append(_on_own_stamps(means[i], grid, s))
    return [np.stack(own) for own in rows]


def joint_neighbour_rows(
    histories: Sequence[NDArray[np.float64]],
    stamps: Sequence[NDArray[np.int64]],
    size: int,
    span: int = SPAN,
) -> list[NDArray[np.float64]] | None:
    """Return each series' history followed by its ``size - 1`` neighbours', or ``None``.

    A series' neighbours are the same-origin series whose z-scored histories correlate best with
    its own over at least 32 common grid steps, each placed on the series' own history
    timestamps. A series with fewer neighbours repeats its own history in the remaining rows. A
    window of fewer than two series, or whose largest origin group holds more than 8,000, gets
    none.
    """
    if size < 2:
        raise ValueError(f"joint neighbours need a group of at least two series, got {size}")
    if len(histories) < 2:
        return None
    _, z = _z_scores(histories, stamps, span)
    seen = np.isfinite(z)
    filled = np.where(seen, z, np.float32(0.0))
    origin = _origins(stamps)
    if np.bincount(origin).max() > _MAX_JOINT_GROUP:
        return None
    neighbours: list[list[int]] = [[] for _ in histories]
    for group in range(origin.max() + 1):
        members = np.flatnonzero(origin == group)
        if members.size < 2:
            continue
        zf, sf = filled[members], seen[members].astype(np.float32)
        overlap = sf @ sf.T
        corr = (zf @ zf.T) / np.maximum(overlap, 1.0)
        corr[overlap < _MIN_OVERLAP] = -np.inf
        np.fill_diagonal(corr, -np.inf)
        for r, member in enumerate(members.tolist()):
            order = np.argsort(-corr[r], kind="stable")[: size - 1]
            neighbours[member] = [int(members[o]) for o in order if np.isfinite(corr[r, o])]
    out = []
    for i, (own, s) in enumerate(zip(histories, stamps, strict=True)):
        rows = [own] + [_on_own_stamps(histories[j], stamps[j], s) for j in neighbours[i]]
        rows += [own] * (size - len(rows))
        out.append(np.stack(rows))
    return out


def chunk_groups(origins: Sequence[int], size: int) -> list[list[int]]:
    """Split a window's series into consecutive chunks of up to ``size`` with one origin.

    ``origins`` holds each series' last history timestamp. Series keep the window's order within
    a chunk, and a chunk may hold one series.
    """
    if size < 2:
        raise ValueError(f"chunks need room for at least two series, got {size}")
    by_origin: dict[int, list[int]] = {}
    for index, origin in enumerate(origins):
        by_origin.setdefault(origin, []).append(index)
    return [
        members[start : start + size]
        for members in by_origin.values()
        for start in range(0, len(members), size)
    ]