From ad69ffed3ffcb5e102e88ba1ee7fc71df77fcd63 Mon Sep 17 00:00:00 2001 From: alan Date: Tue, 29 Sep 2026 13:08:55 +0000 Subject: [PATCH] Add Parquet-backed rel-amazon sampling example --- examples/relational/amazon_parquet.md | 17 +++ examples/relational/amazon_parquet.py | 145 ++++++++++++++++++ pyproject.toml | 1 + sdm/__init__.py | 2 + sdm/relational/__init__.py | 5 +- sdm/relational/parquet.py | 191 ++++++++++++++++++++++++ test/relational/test_parquet_sampler.py | 117 +++++++++++++++ uv.lock | 32 ++++ 8 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 examples/relational/amazon_parquet.md create mode 100644 examples/relational/amazon_parquet.py create mode 100644 sdm/relational/parquet.py create mode 100644 test/relational/test_parquet_sampler.py diff --git a/examples/relational/amazon_parquet.md b/examples/relational/amazon_parquet.md new file mode 100644 index 000000000..36ea2d33d --- /dev/null +++ b/examples/relational/amazon_parquet.md @@ -0,0 +1,17 @@ +# KumoRelational on rel-amazon from Parquet + +This example predicts the RelBench `rel-amazon` `user-churn` task without loading the full database into pandas. Install SDM with its test dependencies and `relbench`, then run from the repository root: + +```bash +python examples/relational/amazon_parquet.py +``` + +RelBench downloads the dataset to its cache on first use. Use `--context-size`, `--query-size`, and `--num-neighbors` to change the sampled workload. The default uses 512 training and validation task rows as context, 64 test task rows as queries, and two hops with eight neighbors each. The script removes target labels from queries before sampling. + +`ParquetRelationalSampler` builds its graph from relationship keys and timestamps in the Parquet tables, using the same CPU neighbor sampler as `RelationalData.sampler()`. Polars reads the selected feature rows after sampling. The resulting `RelatedTables` go directly to `KumoRelational.fit()` and `predict()`; no full `RelationalData` is created from the database. + +The keys, timestamps, graph index, and categorical dictionaries still occupy RAM. Feature rows are read from Parquet for each sample. This approach is useful when the full feature tables exceed memory but the graph metadata fits. The example reports accuracy for one small query batch and is not a full benchmark evaluation. + +On a 15 GiB RAM machine with an NVIDIA L4, the default run completed against 23,218,245 source rows (6.82 GiB compressed Parquet). It sampled 5,018 related context rows and 1,019 related query rows, with 4.95 GiB peak process RAM and 0.7344 query accuracy. On the same machine, a direct attempt to load the full pandas database through `task.get_db()` was killed before sampling began. + +The focused sampler test in `test/relational/test_parquet_sampler.py` compares sampled task rows, relationship links, and related table contents with the in-memory sampler, including categorical values and temporal sampling. diff --git a/examples/relational/amazon_parquet.py b/examples/relational/amazon_parquet.py new file mode 100644 index 000000000..90d9c5dc5 --- /dev/null +++ b/examples/relational/amazon_parquet.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run KumoRelational on rel-amazon with Parquet-backed sampling.""" + +import argparse +import resource +from typing import cast + +import pandas as pd +import polars as pl +import pyarrow.parquet as pq +import relbench +import torch + +import sdm + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--context-size", type=int, default=512) + parser.add_argument("--query-size", type=int, default=64) + parser.add_argument("--num-neighbors", type=int, nargs="+", default=[8, 8]) + parser.add_argument("--seed", type=int, default=0) + args = parser.parse_args() + torch.manual_seed(args.seed) + + dataset = relbench.load_dataset("rel-amazon") + task = dataset.load_task("user-churn") + tables = { + name: dataset.db_dir / f"{name}.parquet" + for name in dataset.manifest.tables + } + + stypes = {} + for name, spec in dataset.manifest.tables.items(): + sample = ( + pl.scan_parquet(tables[name]) + .head(10_000) + .collect(engine="streaming") + .to_pandas() + ) + keys = dict.fromkeys(spec.fkeys, "id") + if spec.pkey is not None: + keys[spec.pkey] = "id" + stypes[name] = sdm.infer_stypes( + sample, + overrides=keys, + text="drop", + unsupported="drop", + ) + + relationships = [ + { + "left_table": name, + "left_column": column, + "right_table": other, + "right_column": cast(str, dataset.manifest.tables[other].pkey), + } + for name, spec in dataset.manifest.tables.items() + for column, other in spec.fkeys.items() + ] + time_columns = { + name: spec.time_col + for name, spec in dataset.manifest.tables.items() + if spec.time_col is not None + } + sampler = sdm.ParquetRelationalSampler( + tables=tables, + stypes=stypes, + relationships=relationships, + time_columns=time_columns, + ) + + train = task.get_table("train", mask_input_cols=False).df + val = task.get_table("val", mask_input_cols=False).df + test = task.get_table("test", mask_input_cols=False).df + task_table = sdm.TableTensor.from_pandas( + df=pd.concat([train, val, test], ignore_index=True), + stypes={ + task.entity_col: "id", + task.time_col: "datetime", + task.target_col: "categorical", + }, + ) + context, query = task_table.split([len(train) + len(val), len(test)]) + context = context[torch.randperm(len(context))[: args.context_size]] + query = query[: args.query_size] + + task_link = { + "task_column": task.entity_col, + "table": task.entity_table, + "table_column": cast( + str, dataset.manifest.tables[task.entity_table].pkey + ), + } + sampled_context = sampler( + task_table=context, + task_link=task_link, + num_neighbors=args.num_neighbors, + task_time_column=task.time_col, + ) + sampled_query = sampler( + task_table=query.drop_columns(task.target_col), + task_link=task_link, + num_neighbors=args.num_neighbors, + task_time_column=task.time_col, + ) + + source_rows = sum( + pq.ParquetFile(path).metadata.num_rows for path in tables.values() + ) + context_rows = sum( + len(table) for table in sampled_context.related_tables.tables.values() + ) + query_rows = sum( + len(table) for table in sampled_query.related_tables.tables.values() + ) + print(f"Source rows on disk: {source_rows:,}") + print(f"Context related rows: {context_rows:,}") + print(f"Query related rows: {query_rows:,}") + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model = sdm.models.KumoRelational(task="classification", device=device) + sampled_context = sampled_context.to(device) + sampled_query = sampled_query.to(device) + with torch.amp.autocast(device.type, enabled=device.type == "cuda"): + model.fit( + x=sampled_context.task_table.drop_columns(task.target_col), + y=sampled_context.task_table[task.target_col], + related_tables=sampled_context.related_tables, + ) + prediction = model.predict(*sampled_query) + + scores, target = sdm.evaluation.to_class_indices( + prediction, cast(sdm.TableTensor, query[task.target_col].to(device)) + ) + accuracy = (scores.argmax(-1) == target).float().mean() + print(f"Query accuracy: {accuracy:.4f}") + peak_gib = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 2**20 + print(f"Peak process RAM: {peak_gib:.2f} GiB") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 0b7b94e25..896220778 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ test = [ "pytest", "pytest-cov", "pandas", + "polars>=1.30", "cudf-cu13>=26.8; sys_platform == 'linux'", "pyg-lib; sys_platform == 'linux'", "pylibcugraph-cu13>=26.8; sys_platform == 'linux'", diff --git a/sdm/__init__.py b/sdm/__init__.py index 14fcc922c..1c2088daa 100644 --- a/sdm/__init__.py +++ b/sdm/__init__.py @@ -22,6 +22,7 @@ RelationalData, TaskLink, RelatedTables, + ParquetRelationalSampler, ) from sdm.processing import Recipe from sdm import models, evaluation, explain @@ -50,6 +51,7 @@ "RelationalData", "TaskLink", "RelatedTables", + "ParquetRelationalSampler", "Recipe", "models", "evaluation", diff --git a/sdm/relational/__init__.py b/sdm/relational/__init__.py index 490058340..631aafeee 100644 --- a/sdm/relational/__init__.py +++ b/sdm/relational/__init__.py @@ -5,7 +5,8 @@ from sdm.relational.data import Relationship, RelationalData from sdm.relational.task import TaskLink, RelatedTables -from sdm.relational.sampler import RelationalSampler +from sdm.relational.sampler import RelationalSampler, RelationalSamplerOutput +from sdm.relational.parquet import ParquetRelationalSampler __all__ = [ "Relationship", @@ -13,4 +14,6 @@ "TaskLink", "RelatedTables", "RelationalSampler", + "RelationalSamplerOutput", + "ParquetRelationalSampler", ] diff --git a/sdm/relational/parquet.py b/sdm/relational/parquet.py new file mode 100644 index 000000000..f3d700f48 --- /dev/null +++ b/sdm/relational/parquet.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Collection, Mapping, Sequence +from pathlib import Path +from typing import Literal, cast + +import torch + +from sdm import Stype, StypeLike, TableTensor +from sdm.relational import ( + RelatedTables, + RelationalData, + RelationalSamplerOutput, + Relationship, + TaskLink, +) + +_ROW = "__sdm_disk_row__" +_EXAMPLE = "__example__" + + +class ParquetRelationalSampler: + """Sample relational Parquet tables while keeping feature columns on disk. + + The relationship keys and time columns are held in CPU memory and sampled + by the same PyG backend as :class:`RelationalSampler`. Polars reads only + sampled feature rows from Parquet. The task table must be two-dimensional. + Source row order must match the order used to create any in-memory + :class:`RelationalData` being compared. + + Args: + tables: Parquet file path for each table. + stypes: Semantic types to load from each table. Include every key used + by ``relationships`` and the task link as ``"id"``. + relationships: Joins between the source tables. + time_columns: Datetime column used to constrain sampling by time for + each time-aware table. + """ + + def __init__( + self, + tables: Mapping[str, str | Path], + stypes: Mapping[str, Mapping[str, StypeLike]], + relationships: Collection[ + Relationship | Mapping[str, str | Sequence[str]] + ], + time_columns: Mapping[str, str] | None = None, + ) -> None: + try: + import polars as pl # noqa: PLC0415 + except ImportError as error: + raise ImportError( + "ParquetRelationalSampler requires 'polars'" + ) from error + + self._pl = pl + self.paths = {name: Path(path) for name, path in tables.items()} + self.stypes = { + name: {column: Stype(stype) for column, stype in schema.items()} + for name, schema in stypes.items() + } + self.time_columns = dict(time_columns or {}) + self._categories: dict[str, dict[str, list[object]]] = {} + + graph_tables = {} + for name, path in self.paths.items(): + schema = self.stypes[name] + self._categories[name] = {} + for column, stype in schema.items(): + if stype != Stype.categorical: + continue + categories = ( + pl.scan_parquet(path) + .select(column) + .unique(maintain_order=True) + .filter(pl.col(column).is_not_null()) + .collect(engine="streaming") + ) + self._categories[name][column] = categories[column].to_list() + columns = [ + column + for column, stype in schema.items() + if stype == Stype.id or column == self.time_columns.get(name) + ] + if ( + _ROW in schema + or _ROW in pl.scan_parquet(path).collect_schema() + ): + raise ValueError(f"Column {_ROW!r} is reserved") + frame = pl.scan_parquet(path, row_index_name=_ROW).select( + pl.col(_ROW).cast(pl.Int64), *columns + ) + graph_tables[name] = TableTensor.from_pandas( + df=frame.collect(engine="streaming").to_pandas(), + stypes={ + _ROW: Stype.id, + **{column: schema[column] for column in columns}, + }, + ) + + self.data = RelationalData( + tables=graph_tables, + relationships=relationships, + ) + self._sampler = self.data.sampler(time_columns=self.time_columns) + + def __call__( + self, + task_table: TableTensor, + task_link: TaskLink | Mapping[str, str | Sequence[str]], + num_neighbors: Sequence[int], + task_time_column: str | None = None, + temporal_strategy: Literal["last", "uniform"] = "last", + ) -> RelationalSamplerOutput: + """Alias of :meth:`sample`.""" + return self.sample( + task_table=task_table, + task_link=task_link, + num_neighbors=num_neighbors, + task_time_column=task_time_column, + temporal_strategy=temporal_strategy, + ) + + def sample( + self, + task_table: TableTensor, + task_link: TaskLink | Mapping[str, str | Sequence[str]], + num_neighbors: Sequence[int], + task_time_column: str | None = None, + temporal_strategy: Literal["last", "uniform"] = "last", + ) -> RelationalSamplerOutput: + """Return sampled task and related tables for model input. + + Args: + task_table: Task rows with entity IDs and optional timestamps. + task_link: Link from task rows to a source table. + num_neighbors: Number of neighbors per relationship at each hop. + task_time_column: Datetime column in ``task_table``. + temporal_strategy: ``"last"`` or ``"uniform"`` sampling. + + Returns: + The same output type as :meth:`RelationalSampler.sample`. + """ + if task_table.dim() != 2: + raise ValueError("Task table needs to be two-dimensional") + + sampled = self._sampler.sample( + task_table=task_table, + task_link=task_link, + num_neighbors=num_neighbors, + task_time_column=task_time_column, + temporal_strategy=temporal_strategy, + ) + import pandas as pd + + tables: dict[str, TableTensor] = {} + for name, graph_table in sampled.related_tables.tables.items(): + assert isinstance(graph_table, TableTensor) + index = graph_table[_ROW].id[..., 0].numpy() + columns = list(self.stypes[name]) + frame = ( + self._pl.scan_parquet(self.paths[name], row_index_name=_ROW) + .filter(self._pl.col(_ROW).is_in(index.tolist())) + .select(_ROW, *columns) + .collect(engine="streaming") + .to_pandas() + .set_index(_ROW) + .loc[index] + .reset_index(drop=True) + ) + for column, categories in self._categories[name].items(): + frame[column] = pd.Categorical( + frame[column], categories=categories + ) + hydrated = TableTensor.from_pandas( + df=frame, + stypes=self.stypes[name], + ) + tables[name] = cast( + TableTensor, + torch.cat([hydrated, graph_table[_EXAMPLE]], dim=-1), + ) + + return RelationalSamplerOutput( + task_table=sampled.task_table, + related_tables=cast( + RelatedTables[TableTensor], + sampled.related_tables.replace_tables(tables), + ), + ) diff --git a/test/relational/test_parquet_sampler.py b/test/relational/test_parquet_sampler.py new file mode 100644 index 000000000..90bf21520 --- /dev/null +++ b/test/relational/test_parquet_sampler.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path +from typing import Any + +import pandas as pd +import pytest + +import sdm + +pytest.importorskip("polars") +pytest.importorskip("pyg_lib") + + +def test_parquet_sampler_matches_in_memory_sampler(tmp_path: Path) -> None: + users = pd.DataFrame( + { + "user_id": [0, 1, 2], + "age": [20.0, 30.0, 40.0], + "tier": ["gold", "silver", "bronze"], + } + ) + orders = pd.DataFrame( + { + "order_id": [10, 11, 12, 13], + "user_id": [0, 0, 1, 1], + "amount": [1.0, 2.0, 3.0, 4.0], + "time": pd.to_datetime( + ["2020-01-01", "2020-01-03", "2020-01-02", "2020-01-04"] + ), + } + ) + frames = {"users": users, "orders": orders} + stypes = { + "users": { + "user_id": "id", + "age": "numerical", + "tier": "categorical", + }, + "orders": { + "order_id": "id", + "user_id": "id", + "amount": "numerical", + "time": "datetime", + }, + } + paths = {name: tmp_path / f"{name}.parquet" for name in frames} + for name, frame in frames.items(): + frame.to_parquet(paths[name], index=False) + + relationships = [ + { + "left_table": "orders", + "left_column": "user_id", + "right_table": "users", + "right_column": "user_id", + } + ] + time_columns = {"orders": "time"} + memory = sdm.RelationalData( + tables={ + name: sdm.TableTensor.from_pandas(frame, stypes[name]) + for name, frame in frames.items() + }, + relationships=relationships, + ).sampler(time_columns=time_columns) + disk = sdm.ParquetRelationalSampler( + tables=paths, + stypes=stypes, + relationships=relationships, + time_columns=time_columns, + ) + task = sdm.TableTensor.from_pandas( + pd.DataFrame( + { + "user_id": [0, 1], + "time": pd.to_datetime(["2020-01-04", "2020-01-05"]), + } + ), + {"user_id": "id", "time": "datetime"}, + ) + kwargs: dict[str, Any] = { + "task_link": { + "task_column": "user_id", + "table": "users", + "table_column": "user_id", + }, + "num_neighbors": [2], + "task_time_column": "time", + } + + expected = memory(task, **kwargs) + actual = disk(task, **kwargs) + + pd.testing.assert_frame_equal( + actual.task_table.to_pandas(), expected.task_table.to_pandas() + ) + actual_related = actual.related_tables + expected_related = expected.related_tables + assert actual_related.relationships == expected_related.relationships + assert actual_related.task_links == expected_related.task_links + assert actual_related.tables.keys() == expected_related.tables.keys() + for name, table in actual.related_tables.tables.items(): + pd.testing.assert_frame_equal( + table.to_pandas(), expected_related.tables[name].to_pandas() + ) + + orders.loc[0, "amount"] = 99.0 + orders.to_parquet(paths["orders"], index=False) + refreshed = disk(task, **kwargs) + assert ( + 99.0 + in refreshed.related_tables.tables["orders"] + .to_pandas()["amount"] + .tolist() + ) diff --git a/uv.lock b/uv.lock index 068c1b964..1c9d0e624 100644 --- a/uv.lock +++ b/uv.lock @@ -1312,6 +1312,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polars" +version = "1.44.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/15/e8541eefc22fbc7ca89bcb5112298a153729f73cfbc0cf6a668e509f975c/polars-1.44.2.tar.gz", hash = "sha256:86c8e26b6c2de8c8d344bb910b74dfc47b118ac3fe0f19b44909467990a0b281", size = 766272, upload-time = "2026-09-09T07:42:08.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6d/3014112c7f717d1253223faa13b6db3ac3a64ed00ab2a3bc1b942bc9cdd4/polars-1.44.2-py3-none-any.whl", hash = "sha256:1bb331f17a40d9d931101533dcd33637b66edc61eb377b07020dac16a0f0377b", size = 865768, upload-time = "2026-09-09T07:40:12.053Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.44.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/a1/a7eace6587b56f22cf2a21ab4d5e695db372dc23fd96accb68b1ec12660b/polars_runtime_32-1.44.2.tar.gz", hash = "sha256:b84842f7d621aaca7a52e165e19a24f89db45f8aa13744941430218419a14a67", size = 3172205, upload-time = "2026-09-09T07:42:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5b/a5215f82c3dd443dc5d6911b0d3e937f97056e0ef7753f7e123422481a18/polars_runtime_32-1.44.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1fd536720668ba203a16a20b08cd6b23057e407a0279cf36b2f35f879d6e3208", size = 48334014, upload-time = "2026-09-09T07:40:16.43Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e0/f3dc93fce4b4e99370db6a89001a1b8d3c606e3560d0d91dda809d6c6324/polars_runtime_32-1.44.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0fd43720c8222ae39919c8ff891636d53b352706087120e62f83544dd3ff782", size = 43331099, upload-time = "2026-09-09T07:40:21.029Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4f/076626ce93ddd622203c4b27be2a96d034cf5b24110c52e96e6029f0ea33/polars_runtime_32-1.44.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf9b45040291dc1c6c588c837019c33557bde25ec536562a9cca9e1f6dfcc45", size = 46590853, upload-time = "2026-09-09T07:40:24.934Z" }, + { url = "https://files.pythonhosted.org/packages/e9/24/ed9982657c446dd5491b089370eea196725673570cfc61f7225a9fdd7ef0/polars_runtime_32-1.44.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1bafb441e99199a62c63bf1bbdc0ea09ee9776dbac2bf31452b5000fb1df2f7", size = 49912258, upload-time = "2026-09-09T07:40:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/71/42/5490ab360aa2406119825ad82203a5e2ff27a3a5893ca8e0b93c053a59a3/polars_runtime_32-1.44.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10c0c695a418407617b5159db7d9a21074a733e4c6d61275b6762f25cb31ca99", size = 46737310, upload-time = "2026-09-09T07:40:33.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/8f/d741afb1dcd1848161189e017d27972e7e78556d8dce66b94d4235093706/polars_runtime_32-1.44.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c4a09fb14aad711526346efc0cb2015c2fd0555ce4118b6524e5debbaea65ff5", size = 49887654, upload-time = "2026-09-09T07:40:37.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e7/c61c1c7eea37705920fe7c1302d1dd80d1165db2b928f0da3eae6d1ebb75/polars_runtime_32-1.44.2-cp310-abi3-win_amd64.whl", hash = "sha256:8598e7a20efba70bb74978c7df7af7c606ff4d79b9b48fdd808250b189bc9a13", size = 51297967, upload-time = "2026-09-09T07:40:41.993Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/d0dd0d2ec95fa328dd47055905fae53ba3cd79f11c8973326ebe75a49e4c/polars_runtime_32-1.44.2-cp310-abi3-win_arm64.whl", hash = "sha256:d51040d3ab40157f6db3c62be59cab5b80fb3c8d158924769c4982a1c8eef730", size = 44303345, upload-time = "2026-09-09T07:40:47.081Z" }, +] + [[package]] name = "pyarrow" version = "23.0.1" @@ -2127,6 +2155,7 @@ dev = [ { name = "cudf-cu13", marker = "sys_platform == 'linux'" }, { name = "myst-parser" }, { name = "pandas" }, + { name = "polars" }, { name = "pyg-lib", marker = "sys_platform == 'linux'" }, { name = "pylibcugraph-cu13", marker = "sys_platform == 'linux'" }, { name = "pytest" }, @@ -2148,6 +2177,7 @@ doc = [ test = [ { name = "cudf-cu13", marker = "sys_platform == 'linux'" }, { name = "pandas" }, + { name = "polars" }, { name = "pyg-lib", marker = "sys_platform == 'linux'" }, { name = "pylibcugraph-cu13", marker = "sys_platform == 'linux'" }, { name = "pytest" }, @@ -2170,6 +2200,7 @@ dev = [ { name = "cudf-cu13", marker = "sys_platform == 'linux'", specifier = ">=26.8" }, { name = "myst-parser" }, { name = "pandas" }, + { name = "polars", specifier = ">=1.30" }, { name = "pyg-lib", marker = "sys_platform == 'linux'" }, { name = "pylibcugraph-cu13", marker = "sys_platform == 'linux'", specifier = ">=26.8" }, { name = "pytest" }, @@ -2189,6 +2220,7 @@ doc = [ test = [ { name = "cudf-cu13", marker = "sys_platform == 'linux'", specifier = ">=26.8" }, { name = "pandas" }, + { name = "polars", specifier = ">=1.30" }, { name = "pyg-lib", marker = "sys_platform == 'linux'" }, { name = "pylibcugraph-cu13", marker = "sys_platform == 'linux'", specifier = ">=26.8" }, { name = "pytest" },