daft-graph provides graph operations over Daft DataFrames. Edges are a DataFrame
with src and dst integer columns; vertices are a DataFrame with an id
column. Everything runs on the Daft native runner locally or on Ray when
distributed.
There are two graph classes. DirectedGraph treats src to dst as meaningful,
UndirectedGraph walks every edge both ways. Graph is the abstract base, used
for type annotations and isinstance checks, and cannot be constructed.
Pick the class that matches your data. The type is what tells an algorithm which
semantics you meant, so pagerank accepts only a DirectedGraph while
connected_components accepts either.
import daft
from daft_graph import DirectedGraph, UndirectedGraph
edges = daft.from_pydict({"src": [1, 2, 4], "dst": [2, 3, 5]})
# Vertices are derived from the edge endpoints when not supplied
graph = DirectedGraph(edges)
# Or supply vertices explicitly (keeps isolated vertices)
vertices = daft.from_pydict({"id": [1, 2, 3, 4, 5, 99]})
graph = DirectedGraph(edges, vertices)
graph.num_vertices() # 6
graph.num_edges() # 3
graph.degrees() # DataFrame[id, degree]
graph.out_degrees() # directed onlyCustom column names are read on ingest and normalized to src, dst, and id.
df = daft.from_pydict({"from": [1, 2], "to": [2, 3]})
graph = DirectedGraph(df, src_col="from", dst_col="to")
graph.edges.column_names # ["src", "dst"]
people = daft.from_pydict({"node": [1, 2, 3]})
graph = DirectedGraph(df, people, src_col="from", dst_col="to", id_col="node")Validation is opt in. Structural column checks always run because they only read
the schema, while the checks that scan data are behind validate=True.
DirectedGraph(edges, vertices, validate=True) # unique ids, no dangling endpointsdirected = DirectedGraph(edges)
directed.reverse() # DirectedGraph with every edge flipped
directed.as_undirected() # UndirectedGraph over the same rows
undirected = UndirectedGraph(edges)
undirected.as_directed() # read src -> dst as given
undirected.as_directed(src_col="dst", dst_col="src") # or flip on the way outAn UndirectedGraph stores one row per edge, so num_edges and degrees count
each edge once. The symmetrization happens at traversal time, which is why a
single undirected edge gives each endpoint degree 1.
Weakly connected components labelled by the smallest id in each component. Edge
free vertices form singleton components. Output is a DataFrame [id, component].
from daft_graph import connected_components
graph = UndirectedGraph(edges) # or pass a DirectedGraph
components = connected_components(graph) # strategy="auto"
components = connected_components(graph, strategy="distributed")
components = connected_components(graph, strategy="local")Undirected semantics apply whichever flavor you pass, since the implementation symmetrizes internally.
distributedruns large star and small star contraction on Daft and scales to the full edge set.localcollects the edge set and finishes withscipy.sparse.csgraphon one node. Useful once deduplication has collapsed the edge count.auto(default) pickslocalat or belowlocal_thresholdedges and only when the optionallocalextra is installed, elsedistributed.
The local strategy needs numpy and scipy, which ship in the optional extra.
Install it with uv sync --extra local or pip install 'daft-graph[local]'. A
core install has daft alone, where auto quietly stays distributed and an
explicit strategy="local" raises an ImportError naming the extra.
Community detection by synchronous label propagation. Output is [id, label].
from daft_graph import label_propagation
labels = label_propagation(graph, max_iters=10)Updates are synchronous and bounded by max_iters because label propagation can
oscillate on bipartite structures.
PageRank matching networkx semantics, with ranks summing to one. Output is
[id, rank].
from daft_graph import pagerank
ranks = pagerank(graph, damping=0.85, tol=1e-6)
# Personalized PageRank seeded on specific vertices
ranks = pagerank(graph, source_ids=[1, 2])SCCs of a directed graph, labelled by the smallest id in each component. Same
strategy options as connected components.
from daft_graph import strongly_connected_components
sccs = strongly_connected_components(graph) # strategy="auto"
sccs = strongly_connected_components(graph, strategy="distributed")Triangles per vertex (undirected). Output is [id, triangle_count].
from daft_graph import triangle_count
counts = triangle_count(graph)bfs returns a shortest path between two vertices as a list of ids, or None if
unreachable within max_path_length. shortest_paths returns hop distances from
every vertex to each landmark as [id, landmark, distance].
from daft_graph import bfs, shortest_paths
path = bfs(graph, source=0, target=9) # [0, 3, 9] or None
# to walk edges both ways, hand it an undirected graph
path = bfs(graph.as_undirected(), source=0, target=9)
distances = shortest_paths(graph, landmarks=[0, 5])There is no directed keyword. Traversal follows the graph's own semantics, so
bfs, bfs_paths, all_shortest_paths, all_paths, shortest_paths,
random_walks, and hyper_anf all take direction from the class you built.
Core number per vertex (undirected), as [id, core].
from daft_graph import k_core
cores = k_core(graph)Build custom vertex centric algorithms on the same primitive the library uses.
aggregate_messages runs one round; pregel runs to a fixed point. Vertex state
is [id, value, ...]; message expressions read src_<col> and dst_<col>.
from daft import col
from daft.functions import when
from daft_graph import pregel
from daft_graph.message_passing import MSG, VALUE
# Propagate the minimum reachable id (undirected)
labels = pregel(
undirected_edges,
init_state, # [id, value]
to_src=col("dst_value"),
agg=lambda m: m.min(),
update=when(col(MSG).is_null(), col(VALUE)).otherwise(
when(col(VALUE) <= col(MSG), col(VALUE)).otherwise(col(MSG))
),
)Match subgraph patterns with a GraphFrames style DSL. find returns one row per
match, with a struct column per named vertex (the vertex row) and per named edge
(the edge row). Filter with struct field access.
find is a DirectedGraph method, since motif edge patterns are directed.
from daft import col
# directed triangles
triangles = graph.find("(a)-[]->(b); (b)-[]->(c); (c)-[]->(a)")
# edges without a reciprocal edge
one_way = graph.find("(a)-[e]->(b); !(b)-[]->(a)")
# read fields off the struct columns
one_way.where(col("a")["id"] < col("b")["id"]).show()Vertices are (name), edges -[name]->, empty names are anonymous, a leading
! negates an edge, and repeated names bind to the same element. Variable
length and undirected motif edges are not supported in this version.
Both take a DirectedGraph; an undirected graph has no directed cycles.
from daft_graph import has_cycle, vertices_on_cycles
has_cycle(graph) # bool
vertices_on_cycles(graph) # DataFrame[id] of vertices on a directed cyclefrom daft_graph import all_paths
paths = all_paths(graph, source=0, target=9, max_path_length=5) # list[list[int]]from daft_graph import maximal_independent_set
mis = maximal_independent_set(graph) # DataFrame[id, selected]from daft_graph import parallel_personalized_pagerank
# one personalized vector per source, as [id, source, rank]
vectors = parallel_personalized_pagerank(graph, source_ids=[0, 5])from daft import col
from daft_graph import all_shortest_paths, bfs
paths = all_shortest_paths(graph, source=0, target=9) # every shortest path
# restrict traversal to edges matching a predicate
paths = all_shortest_paths(graph, 0, 9, edge_filter=col("type") == "follows")
one = bfs(graph, 0, 9, edge_filter=col("type") == "follows")bfs_paths is the GraphFrames bfs analog: it selects source and target vertex
sets with predicate expressions and returns one row per shortest path. Vertex
columns (from, v1, ..., to) hold a struct of the vertex row; edge columns
(e0, e1, ...) hold a struct of the edge row.
from daft import col
from daft_graph import bfs_paths
# shortest paths from any active user to any admin user
paths = bfs_paths(graph, col("status") == "active", col("role") == "admin")
# equivalently as a method; use an undirected graph to walk both ways
paths = graph.as_undirected().bfs_paths(col("id") == 0, col("id") == 9)
# read attributes off the path structs
paths.select(col("from")["id"], col("to")["id"]).show()All returned paths share the shortest length. When a source is also a target the
length is 0 and the columns are just from and to. An empty DataFrame means no
target was reachable within max_path_length.
Model labeled property graphs with attribute columns on vertices and edges, then
query with filter_vertices, filter_edges, triplets, and find. For degree
broken down by edge type:
g.degree_by_type("type") # [id, type, degree]The algorithms operate on integer ids. To run them on a graph keyed by strings
(or any sortable id type), reindex relabels the graph to contiguous int64
ids and returns the mapping; restore_ids maps result id columns back. The
relabelled graph keeps the flavor it was given, so a DirectedGraph stays
directed.
from daft_graph import connected_components, reindex, restore_ids
indexed = reindex(graph) # IndexedGraph(graph, mapping)
components = connected_components(indexed.graph)
result = restore_ids(components, indexed.mapping, ["id", "component"])indexed.mapping is a DataFrame [original_id, id]. Edges whose endpoints are
not in the vertex set are dropped (they have no index).
from daft_graph import random_walks
walks = random_walks(graph, walk_length=10, num_walks=5, seed=0) # list[list[int]]Seeded and deterministic; the input to walk based node embeddings (embedding training itself is left to a dedicated ML library).
from daft_graph import power_iteration_clustering
clusters = power_iteration_clustering(graph, k=3) # [id, cluster]from daft_graph import hyper_anf
nf = hyper_anf(graph, max_hops=6) # [id, hop, approx_count] via HyperLogLogEdges are users (src) to items (dst) with a rating column, so this takes a
DirectedGraph. Needs the optional local extra for numpy.
from daft_graph import svd_plus_plus
result = svd_plus_plus(graph, rating_column="rating", rank=8)
result.factors # [id, kind, bias, factor]
result.rmse # training RMSEfrom daft_graph import (
canonicalize, # orient src <= dst, drop self loops, dedupe
symmetrize, # add reverse of every edge
dedupe_edges, # remove duplicate (src, dst)
drop_self_loops, # remove src == dst
to_edges, # project two columns into src/dst
validate_edges, # raise if src/dst columns are missing
)Set the runner before launching, then use the same API:
DAFT_RUNNER=ray python my_job.pyimport daft
daft.set_runner_ray()Install Daft's iceberg extra (pip install 'daft[iceberg]') and read an edge table
directly. daft-graph itself needs no extra for this:
import daft
from daft_graph import UndirectedGraph, connected_components
# pass io_config by keyword; daft 0.7 inserted branch and tag ahead of it
edges = daft.read_iceberg(catalog.load_table("db.edges"))
components = connected_components(UndirectedGraph(edges))See examples/cc_on_iceberg.py for a runnable version backed by a local parquet
fixture.
| Accepts | Algorithms |
|---|---|
DirectedGraph only |
pagerank, parallel_personalized_pagerank, strongly_connected_components, has_cycle, vertices_on_cycles, svd_plus_plus, find |
| Either flavor, undirected semantics | connected_components, label_propagation, triangle_count, k_core, maximal_independent_set, power_iteration_clustering |
| Either flavor, traversal follows the type | bfs, bfs_paths, all_shortest_paths, shortest_paths, all_paths, random_walks, hyper_anf |
| No graph, DataFrames only | aggregate_messages, pregel, and the edge utilities |
Passing an UndirectedGraph where a DirectedGraph is required is a type error
that mypy catches, which is the point of the split.
- Iterative algorithms run on
iterate_to_fixed_point, which materializes the state between rounds to truncate the Daft logical plan. This is the analog of Spark GraphFrames checkpointing and is what keeps the iterative joins from growing an unbounded plan. - For very long runs, pass
checkpoint_dir=...to round trip the state through parquet between rounds. - For connected components at large edge counts that collapse after
deduplication, prefer
strategy="local"or a lowlocal_threshold. That path needs thelocalextra installed. - Benchmark with
benchmarks/bench_cc.py --edges 1000000. - Edgeless graphs are handled: every vertex forms its own component or community,
and PageRank returns the uniform distribution. The exception is
power_iteration_clustering, which only returns vertices that have edges. Iterative algorithms emit a warning if they reachmax_iterswithout converging.