#graph #approximation #machine-learning

graphrox

A graph library for graph compression and fast processing of graph approximations

6 stable releases

1.2.0 Nov 29, 2022
1.1.0 Nov 24, 2022
1.0.4 Nov 21, 2022
1.0.2 Nov 17, 2022
1.0.1 Nov 13, 2022

#159 in Machine learning

MIT license

185KB
3K SLoC

GraphRox

GraphRox is a network graph library for efficiently generating approximations of graphs. GraphRox additionally provides a high-fidelity, lossy graph compression algorithm.

Using the Library

Rust

To use the library in Rust, add it to [dependencies] in Cargo.toml.

[dependencies]
graphrox = "1.1"

How it works

Approximation

The approximation algorithm applies average pooling to a graph's adjacency matrix to construct an approximation of the graph. The approximation will have a lower dimensionality than the original graph. The adjacency matrix will be partitioned into blocks of a specified of dimension and then the matrix entries within each partition will be average pooled. A given threshold will be applied to the average pooled entries such that each entry that is greater than or equal to the threshold will become a 1 in the adjacency matrix of the resulting approximate graph. Average pooled entries that are lower than the threshold will become zeros in the resulting approximate graph. The graph's adjacency matrix will be padded with zeros if a block to be average pooled does not fit withing the adjacency matrix.

Graph Compression

Using the same approximation technique mentioned above, a threshold is applied to 8x8 blocks in a graph's adjacency matrix. If a given block in the matrix meets the threshold, the entire block will be losslessly encoded in an unsigned 64-bit integer. If the block does not meet the threshold, the entire block will be represented by a 0 in the resulting matrix. Because GraphRox stores matrices as adjacency lists, 0 entries have no effect on storage size.

A threshold of 0.0 is essentially a lossless compression.

GraphRox Tutorial

Graph Basics

The graphrox::Graph struct is a basic, unweighted graph structure and the graphrox::GraphRepresentation trait defines some methods for some basic graph operations. Edges and vertices can be added to or removed from a graph. Each vertex in a graph has an ID, indexed from zero. If an edge is created from the vertex with ID 3 to the vertex with ID 5, vertices with IDs 0, 1, 2, and 4 are created implicitly.

use graphrox::{Graph, GraphRepresentation};

let mut graph = Graph::new_directed();
graph.add_edge(3, 5);

// Vertices 0 through 5 have been defined
assert_eq!(graph.vertex_count(), 6);
assert!(graph.does_edge_exist(3, 5));

let edges_to_2 = [4, 0, 5, 1];
graph.add_vertex(2, Some(&edges_to_2));

assert_eq!(graph.edge_count(), 5);

// Add a vertex with ID 8 and no edges. This implicitly defines all vertices IDs less
// than 8
graph.add_vertex(8, None);

assert_eq!(graph.vertex_count(), 9);
assert_eq!(graph.edge_count(), 5);

// Edges can be removed
graph.delete_edge(2, 5);

assert_eq!(graph.edge_count(), 4);
assert!(!graph.does_edge_exist(2, 5));

Graph Approximations

A graph can be approximated into a graphrox::Graph with a lower dimensionality. This is done by average pooling blocks of a given dimension in the adjacency matrix representation of the graph, then applying a threshold to the average pooled matrix to determine which entries in the adjacency matrix of the resulting graph will be 1 rather than 0.

use graphrox::{Graph, GraphRepresentation};

let mut graph = Graph::new_directed();

graph.add_vertex(0, Some(&[1, 2, 6]));
graph.add_vertex(1, Some(&[1, 2]));
graph.add_vertex(2, Some(&[0, 1]));
graph.add_vertex(3, Some(&[1, 2, 4]));
graph.add_vertex(5, Some(&[6, 7]));
graph.add_vertex(6, Some(&[6]));
graph.add_vertex(7, Some(&[6]));

// Average pool 2x2 blocks in the graph's adjacency matrix, then apply a threshold of 0.5,
// or 50%. Any blocks with at least 50% of their entries being 1 (rather than 0) will be
// represented with a 1 in the adjacency matrix of the resulting graph.
let approx_graph = graph.approximate(2, 0.5);

println!("{}", graph.matrix_representation_string());
println!();
println!("{}", approx_graph.matrix_representation_string());

/* Ouput:

[ 0, 0, 1, 0, 0, 0, 0, 0 ]
[ 1, 1, 1, 1, 0, 0, 0, 0 ]
[ 1, 1, 0, 1, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 0, 0, 0, 1, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 1, 0, 0, 0, 0, 1, 1, 1 ]
[ 0, 0, 0, 0, 0, 1, 0, 0 ]

[ 1, 1, 0, 0 ]
[ 1, 0, 0, 0 ]
[ 0, 0, 0, 0 ]
[ 0, 0, 1, 1 ]

*/

Additionally, a graph can be average pooled without applying a threshold:

use graphrox::{Graph, GraphRepresentation};

let mut graph = Graph::new_directed();

graph.add_vertex(0, Some(&[1, 2, 6]));
graph.add_vertex(1, Some(&[1, 2]));
graph.add_vertex(2, Some(&[0, 1]));
graph.add_vertex(3, Some(&[1, 2, 4]));
graph.add_vertex(5, Some(&[6, 7]));
graph.add_vertex(6, Some(&[6]));
graph.add_vertex(7, Some(&[6]));

let avg_pool_matrix = graph.find_avg_pool_matrix(2);

println!("{}", graph.matrix_representation_string());
println!();
println!("{}", avg_pool_matrix.to_string());

/* Ouput:

[ 0, 0, 1, 0, 0, 0, 0, 0 ]
[ 1, 1, 1, 1, 0, 0, 0, 0 ]
[ 1, 1, 0, 1, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 0, 0, 0, 1, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 1, 0, 0, 0, 0, 1, 1, 1 ]
[ 0, 0, 0, 0, 0, 1, 0, 0 ]

[ 0.50, 0.75, 0.00, 0.00 ]
[ 0.50, 0.25, 0.00, 0.00 ]
[ 0.00, 0.25, 0.00, 0.00 ]
[ 0.25, 0.00, 0.50, 0.50 ]

*/

Graph Compression

Graphs can be compressed into a space-efficient form. Using the same approximation technique mentioned above, a threshold can be applied to 8x8 blocks in a graph's adjacency matrix. If a given block in the matrix meets the threshold, the entire block will be losslessly encoded in an unsigned 64-bit integer. If the block does not meet the threshold, the entire block will be represented by a 0 in the resulting matrix. Because GraphRox stores matrices as adjacency lists, 0 entries have no effect on storage size.

A threshold of 0.0 is essentially a lossless compression.

use graphrox::{Graph, GraphRepresentation};

let mut graph = Graph::new_directed();
graph.add_vertex(23, None);

for i in 8..16 {
    for j in 8..16 {
        graph.add_edge(i, j);
    }
}

for i in 0..8 {
    for j in 0..4 {
        graph.add_edge(i, j);
    }
}

graph.add_edge(22, 18);
graph.add_edge(15, 18);

let compressed_graph = graph.compress(0.2);

assert_eq!(compressed_graph.vertex_count(), 24);
assert_eq!(compressed_graph.edge_count(), 96); // 64 + 32

// Because half of the 8x8 block was filled, half of the bits in the u64 are ones.
assert_eq!(compressed_graph.get_adjacency_matrix_entry(0, 0),0x00000000ffffffffu64);

// Because the entire 8x8 block was filled, the block is represented with u64::MAX
assert_eq!(compressed_graph.get_adjacency_matrix_entry(1, 1), u64::MAX);

Compressing a graph yields a graphrox::CompressedGraph. CompressedGraphs can be easily decompressed back into a graphrox::Graph:

use graphrox::{Graph, GraphRepresentation};

let mut graph = Graph::new_undirected();

graph.add_vertex(0, Some(&[1, 2, 6]));
graph.add_vertex(3, Some(&[1, 2]));

let compressed_graph = graph.compress(0.1);
let decompressed_graph = compressed_graph.decompress();

assert_eq!(graph.edge_count(), decompressed_graph.edge_count());
assert_eq!(graph.vertex_count(), decompressed_graph.vertex_count());

for (from_vertex, to_vertex) in &decompressed_graph {
    assert!(graph.does_edge_exist(from_vertex, to_vertex));
}

Saving graphs to disk

GraphRox provides to_bytes() and try_from::<&[u8]>() methods on graphrox::Graph and graphrox::CompressedGraph which convert to and from efficient big-endian byte-array representations of graphs. The byte arrays are perfect for saving to disk or sending over a websocket.

use graphrox::{CompressedGraph, Graph, GraphRepresentation};
use std::fs;

let mut graph = Graph::new_undirected();

graph.add_vertex(0, Some(&[1, 2, 6]));
graph.add_vertex(3, Some(&[1, 2]));

// Convert the graph to bytes
let graph_bytes = graph.to_bytes();

// Save the bytes to a file
fs::write("my-graph.gphrx", graph_bytes).unwrap();

// Read the bytes from a file (then delete the file)
let graph_file = fs::read("my-graph.gphrx").unwrap();
fs::remove_file("my-graph.gphrx").unwrap();

let graph_from_bytes = Graph::try_from(graph_file.as_slice()).unwrap();

assert_eq!(graph.edge_count(), graph_from_bytes.edge_count());

for (from_vertex, to_vertex) in &graph_from_bytes {
    assert!(graph.does_edge_exist(from_vertex, to_vertex));
}

// Compressed graphs can be converted to bytes as well
let compressed_graph = graph.compress(0.05);
fs::write("compressed-graph.cgphrx", compressed_graph.to_bytes()).unwrap();

// Read the compressed_graph from a file (then delete the file)
let compressed_graph_file = fs::read("compressed-graph.cgphrx").unwrap();
fs::remove_file("compressed-graph.cgphrx").unwrap();

let compressed_graph_from_bytes =
    CompressedGraph::try_from(compressed_graph_file.as_slice()).unwrap();

assert_eq!(compressed_graph_from_bytes.edge_count(), compressed_graph.edge_count());

No runtime deps