GNN in 16 lines

As has been mentioned in [4], multiple instance learning is an essential piece for implementing message passing inference over graphs, the main concept behind spatial Graph Neural Networks (GNNs). It is straightforward and quick to achieve this with Mill.jl. We begin with some dependencies:

using Mill, Flux, Graphs, Statistics

Let's assume a graph g, represented as a SimpleGraph from Graphs.jl

julia> g = SimpleGraph(10){10, 0} undirected simple Int64 graph
julia> for e in [(1, 2), (1, 3), (1, 4), (2, 4), (2, 5), (3, 4), (3, 5), (3, 6), (3, 8), (3, 10), (4, 5), (4, 6), (4, 9), (5, 7), (5, 8), (6, 5), (6, 7), (6, 8), (7, 8), (7, 10), (8, 9) ] add_edge!(g, e...) end

Furthermore, let's assume that each vertex is described by three features stored in a matrix X:

julia> X = ArrayNode(randn(Float32, 3, 10))3×10 ArrayNode{Matrix{Float32}, Nothing}:
 -0.12679662  -0.35648802   0.23759837    0.20088904   0.62448514   0.79077834  0.09222726   0.43388435   0.22977903   0.43026266
 -1.4965914    0.9500747    0.100587904   0.7685701   -0.5699189   -1.7732544   0.7748581   -0.4591931   -1.9597241    0.13925342
  0.55790013  -0.2023222   -0.29415077   -0.9071946    1.8288428    0.7437802   0.03481543  -0.54106444  -1.4010159   -1.1463006

We use ScatteredBags from Mill to encode neighbors of each vertex. In other words, each vertex is described by a bag of its neighbors. This information is conveniently stored in fadjlist field of g, therefore the bags can be constructed as:

julia> b = ScatteredBags(g.fadjlist)ScatteredBags{Int64}([[2, 3, 4], [1, 4, 5], [1, 4, 5, 6, 8, 10], [1, 2, 3, 5, 6, 9], [2, 3, 4, 6, 7, 8], [3, 4, 5, 7, 8], [5, 6, 8, 10], [3, 5, 6, 7, 9], [4, 8], [3, 7]])

Finally, we create two models. First model called lift will pre-process the description of vertices to some latent space for message passing, and the second one will realize the message passing itself, which we will call mp:

julia> lift = reflectinmodel(X, d -> Dense(d, 4), SegmentedMean)ArrayModel(Dense(3 => 4))  # 2 arrays, 16 params, 144 bytes
julia> U = lift(X)4×10 Matrix{Float32}: -0.694824 0.258204 0.0814909 0.215787 0.65529 -0.288069 0.48572 -0.204361 -1.43213 -0.0876108 0.879013 -0.96611 0.280733 0.0831417 -0.352433 1.62898 -0.666979 1.10082 2.93841 0.983724 0.0666068 0.271312 -0.127492 0.00239504 -0.889856 -0.692153 -0.131364 -0.190791 0.269199 -0.084715 -0.696617 0.140971 0.312877 0.801602 -0.931404 -0.426887 0.175684 0.446852 0.578479 0.932872
julia> mp = reflectinmodel(BagNode(U, b), d -> Dense(d, 3), SegmentedMean)BagModel ↦ SegmentedMean(3) ↦ Dense(3 => 3) # 3 arrays, 15 params, 180 bytes ╰── ArrayModel(Dense(4 => 3)) # 2 arrays, 15 params, 140 bytes

Notice that BagNode(U, b) now essentially encodes vertex features as well as the adjacency matrix. This also means that one step of message passing algorithm can be realized as:

julia> Y = mp(BagNode(U, b))3×10 Matrix{Float32}:
 -0.0082195  -0.120252  -0.101056  -0.0294421  -0.0679054  -0.144934  -0.180778  -0.102704  -0.0260114  -0.097558
  0.419286   -0.447769  -0.782418  -0.842742   -0.164005   -0.117499  -0.9737    -0.871548  -0.266853    0.244904
  0.0970756  -0.180819  -0.33871   -0.30544    -0.122763   -0.126487  -0.441643  -0.362073  -0.195841    0.0180091

and it is differentiable, which can be verified by executing:

julia> gradient(m -> sum(sin.(m(BagNode(U, b)))), mp)((im = (m = (weight = Float32[1.0392458 6.510017 -3.7478795 2.4142144; 0.08109954 -0.010272092 -0.03964295 0.1283999; -0.07554098 0.14852655 -0.03163761 -0.10566347], bias = Float32[18.271505, 0.40853584, -0.10218148], σ = nothing),), a = (ψ = Float32[0.0, 0.0, 0.0],), bm = (weight = Float32[-3.1607635 -2.3542614 1.4167144; -2.2538667 -1.481414 0.82637876; -3.0010657 -2.1960394 1.314742], bias = Float32[9.947762, 8.309763, 9.68031], σ = nothing)),)

If we put everything together, the GNN implementation is implemented in the following 16 lines:

struct GNN{L, M, R}
    lift::L
    mp::M
    m::R
end

Flux.@functor GNN

function mpstep(m::GNN, U, bags, n)
    n == 0 && return(U)
    mpstep(m, m.mp(BagNode(U, bags)), bags, n - 1)
end

function (m::GNN)(g, X, n)
    U = m.lift(X)
    bags = Mill.ScatteredBags(g.fadjlist)
    o = mpstep(m, U, bags, n)
    m.m(vcat(mean(o, dims = 2), maximum(o, dims = 2)))
end

As it is the case with whole Mill, even this graph neural network is properly integrated with Flux.jl ecosystem and suports automatic differentiation:

zd = 4
f(d) = Chain(Dense(d, zd, relu), Dense(zd, zd))
agg = SegmentedMeanMax
gnn = GNN(reflectinmodel(X, f, agg),
          BagModel(f(zd), agg(zd), f(2zd)),
          f(2zd))
julia> gnn(g, X, 5)4×1 Matrix{Float32}:
 -0.007986622
  0.0060284752
 -0.054141466
  0.107666224
julia> gradient(m -> m(g, X, 5) |> sum, gnn)((lift = (m = (layers = ((weight = Float32[-0.026549693 -0.0202079 0.048458014; 0.002715659 -0.13651448 0.06966947; 0.014111388 -0.008991994 -0.019004177; -0.026339926 0.050892036 -0.0039073415], bias = Float32[-0.008255225, 0.08427074, 0.055815943, 0.0010151814], σ = nothing), (weight = Float32[-0.002248382 0.016910437 0.0074784313 0.012096357; 0.009800602 -0.0093543865 -0.041285932 0.06456998; -0.0066256425 0.0034387442 0.030442864 -0.051204596; -0.006306767 -0.021136036 0.014636796 -0.091573276], bias = Float32[0.025005637, -0.0717659, 0.050707463, 0.0151686715], σ = nothing)),),), mp = (im = (m = (layers = ((weight = Float32[0.0025906733 -0.05646565 -0.0017295398 -0.012856863; 0.026618699 -0.015067812 0.0023268894 0.0073023317; -0.040334925 -0.04906101 -0.02352902 -0.0034152204; -0.036234714 0.21536386 -0.10198096 0.031541757], bias = Float32[0.042300627, 0.07221058, -0.046473194, 1.6086328], σ = nothing), (weight = Float32[-0.04510419 -0.010275664 -0.23416512 -0.2068704; 0.039779283 0.009614375 0.05877488 0.07339244; 0.08261877 0.019420702 -0.010461925 0.021526186; -0.011292875 -0.004899059 0.22268918 0.22738567], bias = Float32[-1.7061087, 0.59313864, -0.07346799, 1.9785393], σ = nothing)),),), a = (fs = ((ψ = Float32[0.0, 0.0, 0.0, 0.0],), (ψ = Float32[0.0, 0.0, 0.0, 0.0],)),), bm = (layers = ((weight = Float32[0.12306693 -0.06842939 … 0.0070986785 0.034823626; -0.2140295 0.08474175 … 0.042847574 -0.08543894; -0.16144809 0.15957336 … -0.055416133 -0.02598799; -0.0028243416 0.0007603243 … 0.0009992825 6.847396f-5], bias = Float32[-1.5316496, 1.4613681, 2.0013745, 0.009669691], σ = nothing), (weight = Float32[0.0412966 -0.033584822 0.011519421 -0.00012691459; 0.14572217 0.09025797 -0.00036558858 -0.00019095119; -0.1489384 -0.2040917 -0.08113609 0.00018183977; -0.33841395 -0.25317866 -0.0887108 0.0005282514], bias = Float32[-0.43640125, -0.14298038, -2.2016888, -1.8105818], σ = nothing)),)), m = (layers = ((weight = Float32[-0.06176482 0.036445152 … -0.07472322 -0.010564245; 0.0 0.0 … 0.0 0.0; 0.0 0.0 … 0.0 0.0; 0.036752846 -0.021686504 … 0.04446368 0.006286201], bias = Float32[1.4930656, -0.0, -0.0, -0.88844115], σ = nothing), (weight = Float32[0.071569055 0.0 0.0 0.06223336; 0.071569055 0.0 0.0 0.06223336; 0.071569055 0.0 0.0 0.06223336; 0.071569055 0.0 0.0 0.06223336], bias = Fill(1.0f0, 4), σ = nothing)),)),)

The above implementation is surprisingly general, as it supports an arbitrarily rich description of vertices. For simplicity, we used only vectors in X, however, any Mill hierarchy is applicable.

To put different weights on edges, one can use Weighted aggregation.