-
-
Notifications
You must be signed in to change notification settings - Fork 815
feat: strict typing stream.iter_array
#1993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
FBruzzesi
wants to merge
2
commits into
online-ml:main
Choose a base branch
from
FBruzzesi:feat/typing-iter-array
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+298
−28
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,20 +2,109 @@ | |
|
|
||
| import itertools | ||
| import random | ||
| import typing | ||
|
|
||
| import numpy as np | ||
|
|
||
| from river import base | ||
|
|
||
| if typing.TYPE_CHECKING: | ||
| from collections.abc import Callable, Iterator, Sequence | ||
|
|
||
| Target = typing.TypeVar("Target", bound=base.typing.Target) | ||
| """The type of a single target value, i.e. of a row of a 1D array of targets.""" | ||
|
|
||
| Array: typing.TypeAlias = "np.ndarray | Sequence[typing.Any]" | ||
| """A numpy array or a plain Python sequence.""" | ||
|
|
||
| Features: typing.TypeAlias = "dict[base.typing.FeatureName, typing.Any]" | ||
| """One row of features, labeled, which is what an estimator takes as its `x`.""" | ||
|
|
||
|
|
||
| def _passthrough(row: str) -> str: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is used exactly once. Why not define it at its usage location? |
||
| """Leaves a row as it is, which is how an array of texts is yielded.""" | ||
| return row | ||
|
|
||
|
|
||
| def _labeler( | ||
| names: Sequence[base.typing.FeatureName], first_row: typing.Any | ||
| ) -> Callable[[typing.Any], Features]: | ||
| """Picks the labeler for every row from the first one, all rows being of the same type.""" | ||
| if isinstance(first_row, np.ndarray): | ||
|
|
||
| def label_numpy(row: np.ndarray) -> Features: | ||
| return dict(zip(names, row.tolist())) | ||
|
|
||
| return label_numpy | ||
|
|
||
| def label(row: typing.Any) -> Features: | ||
| return dict(zip(names, row)) | ||
|
|
||
| return label | ||
|
|
||
|
|
||
| def _take(values: Array, order: Sequence[int]) -> Array: | ||
| """Reorders rows, with fancy indexing for numpy and row lookups for plain sequences.""" | ||
| return values[order] if isinstance(values, np.ndarray) else [values[i] for i in order] | ||
|
|
||
|
|
||
| # NOTE: A row of X is labeled to make a dictionary of features, unless X is an array of texts, | ||
| # which are yielded as they are. A row of y is a target, unless y is multi-output, in which case | ||
| # it is a dictionary too. A plain Python sequence says which of these it is, and the overloads | ||
| # below follow it; a numpy array hands out `Any`, which is where the trail stops. The first | ||
| # overload overlaps the wider ones by design, which is what the pyright suppression is for. | ||
| @typing.overload | ||
| def iter_array( # pyright: ignore[reportOverlappingOverload] | ||
| X: Sequence[str], | ||
| y: Sequence[Target], | ||
| feature_names: list[base.typing.FeatureName] | None = None, | ||
| target_names: list[base.typing.FeatureName] | None = None, | ||
| shuffle: bool = False, | ||
| seed: int | None = None, | ||
| ) -> Iterator[tuple[str, Target]]: ... | ||
|
|
||
|
|
||
| @typing.overload | ||
| def iter_array( | ||
| X: np.ndarray, | ||
| y: np.ndarray | None = None, | ||
| X: Sequence[str], | ||
| y: Array | None = None, | ||
| feature_names: list[base.typing.FeatureName] | None = None, | ||
| target_names: list[base.typing.FeatureName] | None = None, | ||
| shuffle: bool = False, | ||
| seed: int | None = None, | ||
| ) -> base.typing.Stream: | ||
| ) -> Iterator[tuple[str, typing.Any]]: ... | ||
|
|
||
|
|
||
| @typing.overload | ||
| def iter_array( | ||
| X: Array, | ||
| y: Sequence[Target], | ||
| feature_names: list[base.typing.FeatureName] | None = None, | ||
| target_names: list[base.typing.FeatureName] | None = None, | ||
| shuffle: bool = False, | ||
| seed: int | None = None, | ||
| ) -> Iterator[tuple[Features, Target]]: ... | ||
|
|
||
|
|
||
| @typing.overload | ||
| def iter_array( | ||
| X: Array, | ||
| y: Array | None = None, | ||
| feature_names: list[base.typing.FeatureName] | None = None, | ||
| target_names: list[base.typing.FeatureName] | None = None, | ||
| shuffle: bool = False, | ||
| seed: int | None = None, | ||
| ) -> base.typing.Stream: ... | ||
|
|
||
|
|
||
| def iter_array( | ||
| X: Array, | ||
| y: Array | None = None, | ||
| feature_names: list[base.typing.FeatureName] | None = None, | ||
| target_names: list[base.typing.FeatureName] | None = None, | ||
| shuffle: bool = False, | ||
| seed: int | None = None, | ||
| ) -> Iterator[tuple[Features | str, typing.Any]]: | ||
| """Iterates over the rows from an array of features and an array of targets. | ||
|
|
||
| This method is intended to work with `numpy` arrays, but should also work with Python lists. | ||
|
|
@@ -69,36 +158,31 @@ def iter_array( | |
| bar False | ||
|
|
||
| """ | ||
| if (n_rows := len(X)) == 0: | ||
| return | ||
|
|
||
| if shuffle: | ||
| order = random.Random(seed).sample(range(n_rows), k=n_rows) | ||
| X = _take(X, order) | ||
| y = y if y is None else _take(y, order) | ||
|
|
||
| handle_features: Callable[[typing.Any], Features | str] | ||
| # If the first row of X is actually a string, then we assume all the rows are strings and will | ||
| # pass them through | ||
| # pass them through. If not we assume each row is a set of features, and will label them. | ||
| if isinstance(X[0], str): | ||
|
|
||
| def handle_features(x): | ||
| return x.tolist() if isinstance(x, np.ndarray) else x | ||
|
|
||
| # If not we assume each row if a set of features, and will convert them to a dictionary | ||
| handle_features = _passthrough | ||
| else: | ||
| feature_names = list(range(len(X[0]))) if feature_names is None else feature_names | ||
|
|
||
| def handle_features(x): | ||
| return dict(zip(feature_names, xi.tolist() if isinstance(xi, np.ndarray) else xi)) | ||
|
|
||
| multioutput = y is not None and not np.isscalar(y[0]) | ||
| if multioutput and target_names is None: | ||
| target_names = list(range(len(y[0]))) # type: ignore | ||
|
|
||
| # Shuffle the data | ||
| rng = random.Random(seed) | ||
| if shuffle: | ||
| order = rng.sample(range(len(X)), k=len(X)) | ||
| X = X[order] | ||
| y = y if y is None else y[order] | ||
| handle_features = _labeler( | ||
| range(len(X[0])) if feature_names is None else feature_names, X[0] | ||
| ) | ||
|
|
||
| if multioutput: | ||
| for xi, yi in itertools.zip_longest(X, y if hasattr(y, "__iter__") else []): # type: ignore | ||
| yield handle_features(xi), dict(zip(target_names, yi.tolist())) # type: ignore | ||
| # zip_longest pads a target array shorter than X with Nones, instead of stopping short of it. | ||
| rows = itertools.zip_longest(X, () if y is None else y) | ||
|
|
||
| if y is not None and not np.isscalar(y[0]): | ||
| handle_target = _labeler(range(len(y[0])) if target_names is None else target_names, y[0]) | ||
| for xi, yi in rows: | ||
| yield handle_features(xi), handle_target(yi) | ||
| else: | ||
| for xi, yi in itertools.zip_longest(X, y if hasattr(y, "__iter__") else []): # type: ignore | ||
| for xi, yi in rows: | ||
| yield handle_features(xi), yi.item() if isinstance(yi, np.generic) else yi | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import operator | ||
| import typing | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
|
|
||
| from river import stream | ||
|
|
||
| # Imported at runtime, rather than under TYPE_CHECKING, as `assert_type` evaluates its arguments. | ||
| from river.base.typing import FeatureName | ||
|
|
||
| if typing.TYPE_CHECKING: | ||
| from collections.abc import Callable, Sequence | ||
|
|
||
| Array = np.ndarray | Sequence[typing.Any] | ||
| Rows = list[tuple[typing.Any, typing.Any]] | ||
|
|
||
| # The same data reaches `iter_array` as numpy or as plain Python containers, and both are | ||
| # documented as supported. Every case below is expected to yield the very same stream. | ||
| ARRAY_BACKENDS: dict[str, Callable[[list[typing.Any]], Array]] = { | ||
| "numpy": np.array, | ||
| "list": list, | ||
| "tuple": tuple, | ||
| } | ||
|
|
||
| FEATURES = [[1, 2, 3], [11, 12, 13]] | ||
| LABELED = [{0: 1, 1: 2, 2: 3}, {0: 11, 1: 12, 2: 13}] | ||
| TARGET = [True, False] | ||
| MULTI_TARGET = [[1, 2], [11, 12]] | ||
| TEXTS = ["foo", "bar"] | ||
|
|
||
| # Enough rows for a shuffle to be all but certain to reorder them, each labeled with its own | ||
| # first feature so that a row and its target can be checked to travel together. | ||
| ROWS = [[i, i * 10] for i in range(10)] | ||
| LABELS = list(range(10)) | ||
| MULTI_LABELS = [[i, -i] for i in range(10)] | ||
| LONG_TEXTS = [f"text {i}" for i in range(10)] | ||
| SHUFFLE: dict[str, typing.Any] = {"shuffle": True, "seed": 42} | ||
|
|
||
|
|
||
| @pytest.fixture(params=list(ARRAY_BACKENDS)) | ||
| def array(request: pytest.FixtureRequest) -> Callable[[list[typing.Any]], Array]: | ||
| """Yields one array-like constructor per supported input container.""" | ||
| return ARRAY_BACKENDS[request.param] | ||
|
|
||
|
|
||
| class Case(typing.NamedTuple): | ||
| """One call to `iter_array`, with its arrays expressed as plain Python containers.""" | ||
|
|
||
| X: list[typing.Any] | ||
| y: list[typing.Any] | None = None | ||
| kwargs: dict[str, typing.Any] = {} | ||
|
|
||
|
|
||
| def run(case: Case, backend: Callable[[list[typing.Any]], Array]) -> Rows: | ||
| return list( | ||
| stream.iter_array( | ||
| backend(case.X), None if case.y is None else backend(case.y), **case.kwargs | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| STREAMS: dict[str, tuple[Case, Rows]] = { | ||
| "features-only": (Case(FEATURES), [(LABELED[0], None), (LABELED[1], None)]), | ||
| "with-target": (Case(FEATURES, TARGET), [(LABELED[0], True), (LABELED[1], False)]), | ||
| "named-features": ( | ||
| Case(FEATURES, TARGET, {"feature_names": ["x1", "x2", "x3"]}), | ||
| [({"x1": 1, "x2": 2, "x3": 3}, True), ({"x1": 11, "x2": 12, "x3": 13}, False)], | ||
| ), | ||
| "fewer-names-than-features": ( | ||
| Case(FEATURES, kwargs={"feature_names": ["x1"]}), | ||
| [({"x1": 1}, None), ({"x1": 11}, None)], | ||
| ), | ||
| "shorter-target-is-padded": ( | ||
| Case(FEATURES, [True]), | ||
| [(LABELED[0], True), (LABELED[1], None)], | ||
| ), | ||
| "multioutput": ( | ||
| Case(FEATURES, MULTI_TARGET), | ||
| [(LABELED[0], {0: 1, 1: 2}), (LABELED[1], {0: 11, 1: 12})], | ||
| ), | ||
| "named-outputs": ( | ||
| Case(FEATURES, MULTI_TARGET, {"target_names": ["y1", "y2"]}), | ||
| [(LABELED[0], {"y1": 1, "y2": 2}), (LABELED[1], {"y1": 11, "y2": 12})], | ||
| ), | ||
| "target-names-ignored-for-a-single-output": ( | ||
| Case(FEATURES, TARGET, {"target_names": ["y1"]}), | ||
| [(LABELED[0], True), (LABELED[1], False)], | ||
| ), | ||
| "text-passes-through": (Case(TEXTS, TARGET), [("foo", True), ("bar", False)]), | ||
| "empty": (Case([], []), []), | ||
| "empty-without-target": (Case([]), []), | ||
| } | ||
|
|
||
|
|
||
| @pytest.mark.parametrize(("case", "expected"), STREAMS.values(), ids=STREAMS) | ||
| def test_expected_stream( | ||
| case: Case, expected: Rows, array: Callable[[list[typing.Any]], Array] | ||
| ) -> None: | ||
| """Each input shape yields its expected rows, whichever container the arrays come in. | ||
|
|
||
| Features are labeled with their position when no names are given, and features without a | ||
| name are dropped. When `y` is omitted, or shorter than `X`, the target is padded with `None`. | ||
| A 2D target yields one dict per row, and a 1D array of texts is yielded as-is. | ||
| """ | ||
| assert run(case, array) == expected | ||
|
|
||
|
|
||
| SHUFFLED_STREAMS: dict[str, Case] = { | ||
| "with-target": Case(ROWS, LABELS, SHUFFLE), | ||
| "multioutput": Case(ROWS, MULTI_LABELS, SHUFFLE), | ||
| "without-target": Case(ROWS, None, SHUFFLE), | ||
| "text": Case(LONG_TEXTS, LABELS, SHUFFLE), | ||
| } | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("case", SHUFFLED_STREAMS.values(), ids=SHUFFLED_STREAMS) | ||
| def test_backends_agree_on_shuffling(case: Case) -> None: | ||
| """numpy, lists and tuples are all reordered the same way for a given seed.""" | ||
| streams = [run(case, backend) for backend in ARRAY_BACKENDS.values()] | ||
| assert all(rows == streams[0] for rows in streams) | ||
|
|
||
|
|
||
| def test_shuffle_reorders_and_preserves_rows(array: Callable[[list[typing.Any]], Array]) -> None: | ||
| """Shuffling only reorders the rows, each one keeping its own target.""" | ||
| X, y = array(ROWS), array(LABELS) | ||
| plain = list(stream.iter_array(X, y)) | ||
| shuffled = list(stream.iter_array(X, y, shuffle=True, seed=42)) | ||
|
|
||
| assert shuffled != plain | ||
| assert all(xi[0] == yi for xi, yi in shuffled) | ||
| assert sorted(shuffled, key=operator.itemgetter(1)) == plain | ||
|
|
||
|
|
||
| def test_shuffle_is_seeded(array: Callable[[list[typing.Any]], Array]) -> None: | ||
| """The same seed always gives the same order, and different seeds give different ones.""" | ||
| X, y = array(ROWS), array(LABELS) | ||
|
|
||
| assert list(stream.iter_array(X, y, **SHUFFLE)) == list(stream.iter_array(X, y, **SHUFFLE)) | ||
| assert list(stream.iter_array(X, y, shuffle=True, seed=0)) != list( | ||
| stream.iter_array(X, y, shuffle=True, seed=1) | ||
| ) | ||
|
|
||
|
|
||
| def test_native_python_scalars(array: Callable[[list[typing.Any]], Array]) -> None: | ||
| """Cells and targets are plain Python values, not numpy scalars.""" | ||
| xi, yi = next(iter(stream.iter_array(array(FEATURES), array(TARGET)))) | ||
| _, multi = next(iter(stream.iter_array(array(FEATURES), array(MULTI_TARGET)))) | ||
| text, _ = next(iter(stream.iter_array(array(TEXTS), array(TARGET)))) | ||
|
|
||
| assert [type(value) for value in xi.values()] == [int, int, int] | ||
| assert type(yi) is bool | ||
| assert [type(value) for value in multi.values()] == [int, int] | ||
| assert isinstance(text, str) | ||
|
|
||
|
|
||
| def test_static_types_follow_the_input_shapes() -> None: | ||
| """What a checker makes of each input shape, which `assert_type` fails the mypy run over.""" | ||
| # A sequence of texts yields texts, and a sequence of targets yields the target's own type. | ||
| typing.assert_type(next(iter(stream.iter_array(["a", "b"], [1, 2]))), tuple[str, int]) | ||
| typing.assert_type( | ||
| next(iter(stream.iter_array([[1, 2]], [True]))), | ||
| tuple[dict[FeatureName, typing.Any], bool], | ||
| ) | ||
| # numpy hands out `Any`, and so does a target a checker cannot tell apart from a 2D one. | ||
| typing.assert_type( | ||
| next(iter(stream.iter_array(np.array([[1, 2]]), np.array([1])))), | ||
| tuple[dict[FeatureName, typing.Any], typing.Any], | ||
| ) | ||
| typing.assert_type( | ||
| next(iter(stream.iter_array([[1, 2]], [[1, 2]]))), | ||
| tuple[dict[FeatureName, typing.Any], typing.Any], | ||
| ) | ||
|
|
||
|
|
||
| def test_iteration_is_lazy() -> None: | ||
| """Nothing is read from the arrays until the stream is iterated over.""" | ||
| dataset = stream.iter_array(np.array([1, 2, 3])) | ||
| with pytest.raises(TypeError): | ||
| _ = next(iter(dataset)) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment is redundant.