ggmlR provides a high-level Keras-like interface for building and
training neural networks on top of the ggml C tensor library. This
vignette walks through the main APIs using the built-in
iris and mtcars datasets.
The sequential API mirrors keras.Sequential: stack
layers with |>, compile, fit, evaluate, predict.
data(iris)
set.seed(42)
x <- as.matrix(iris[, 1:4])
x <- scale(x) # standardise
# one-hot encode species
y <- model.matrix(~ Species - 1, iris) # [150 x 3]
idx <- sample(nrow(x))
n_tr <- 120L
x_tr <- x[idx[1:n_tr], ]
y_tr <- y[idx[1:n_tr], ]
x_val <- x[idx[(n_tr+1):150], ]
y_val <- y[idx[(n_tr+1):150], ]model <- ggml_model_sequential() |>
ggml_layer_dense(32L, activation = "relu", input_shape = 4L) |>
ggml_layer_batch_norm() |>
ggml_layer_dropout(0.3, stochastic = TRUE) |>
ggml_layer_dense(16L, activation = "relu") |>
ggml_layer_dense(3L, activation = "softmax") |>
ggml_compile(
optimizer = "adam",
loss = "categorical_crossentropy",
metrics = c("accuracy")
)
print(model)Available losses:
loss = |
Notes |
|---|---|
"categorical_crossentropy" |
Multi-class; expects a softmax output |
"mse" |
Regression, squared error |
"mae" |
Regression, absolute error — robust to outliers |
"huber" |
Quadratic near zero, linear beyond delta = 1 |
"binary_crossentropy" |
Independent per-output labels; expects a sigmoid
output |
"mean_squared_error",
"mean_absolute_error", "huber_loss" and
"binary_cross_entropy" are accepted as aliases.
MAE and Huber matter when the targets contain outliers: MSE squares the error, so one wild value dominates the gradient, while MAE weights every point equally and Huber is quadratic only near zero. Binary cross-entropy treats each output as its own Bernoulli, which is what makes it the loss for multi-label targets rather than for one-of-N classification.
score <- ggml_evaluate(model, x_val, y_val, batch_size = 16L)
cat(sprintf("Val loss: %.4f Val accuracy: %.4f\n", score$loss, score$accuracy))
probs <- ggml_predict(model, x_val, batch_size = 16L)
classes <- apply(probs, 1, which.max)
true <- apply(y_val, 1, which.max)
cat("Confusion matrix:\n")
print(table(true = true, predicted = classes))path <- tempfile(fileext = ".rds")
ggml_save_model(model, path)
cat(sprintf("Saved: %s (%.1f KB)\n", path, file.size(path) / 1024))
model2 <- ggml_load_model(path)
score2 <- ggml_evaluate(model2, x_val, y_val, batch_size = 16L)
cat(sprintf("Reloaded model accuracy: %.4f\n", score2$accuracy))The functional API lets you build DAGs with multiple
inputs/branches/outputs. Use ggml_input() to declare
inputs, pipe through layers, then call ggml_model().
inp <- ggml_input(shape = 4L)
h <- inp |> ggml_layer_dense(32L, activation = "relu") |> ggml_layer_batch_norm()
h <- h |> ggml_layer_dense(16L, activation = "relu")
out <- h |> ggml_layer_dense(3L, activation = "softmax")
model_fn <- ggml_model(inputs = inp, outputs = out) |>
ggml_compile(optimizer = "adam", loss = "categorical_crossentropy",
metrics = c("accuracy"))
model_fn <- ggml_fit(model_fn, x_tr, y_tr,
epochs = 100L, batch_size = 32L, verbose = 0L)
score_fn <- ggml_evaluate(model_fn, x_val, y_val, batch_size = 16L)
cat(sprintf("Functional model accuracy: %.4f\n", score_fn$accuracy))Predict mpg from two input groups: engine features and
transmission features.
data(mtcars)
set.seed(7)
# Input group 1: engine (disp, hp, wt)
# Input group 2: transmission / gearbox (cyl, gear, carb, am)
engine <- as.matrix(scale(mtcars[, c("disp","hp","wt")]))
trans <- as.matrix(scale(mtcars[, c("cyl","gear","carb","am")]))
y_mpg <- matrix(scale(mtcars$mpg), ncol = 1L) # [32 x 1]
# small dataset — use all for training, evaluate on same data for demo
x1 <- engine; x2 <- trans
inp1 <- ggml_input(shape = 3L, name = "engine")
inp2 <- ggml_input(shape = 4L, name = "transmission")
branch1 <- inp1 |> ggml_layer_dense(16L, activation = "relu")
branch2 <- inp2 |> ggml_layer_dense(16L, activation = "relu")
merged <- ggml_layer_add(list(branch1, branch2)) # element-wise add
out_reg <- merged |>
ggml_layer_dense(8L, activation = "relu") |>
ggml_layer_dense(1L)
model_reg <- ggml_model(inputs = list(inp1, inp2), outputs = out_reg) |>
ggml_compile(optimizer = "adam", loss = "mse")
model_reg <- ggml_fit(model_reg,
x = list(x1, x2), y = y_mpg,
epochs = 200L, batch_size = 16L, verbose = 0L)
preds <- ggml_predict(model_reg, x = list(x1, x2), batch_size = 16L)
cat(sprintf("Pearson r (scaled mpg): %.4f\n", cor(preds, y_mpg)))ggml_layer_add() requires the branches to have the same
width. ggml_layer_concatenate() does not — it stacks them
along an axis, keeping each branch’s features distinct instead of
summing them. Both are trainable: gradients flow back into every
branch.
br1 <- inp1 |> ggml_layer_dense(8L, activation = "relu")
br2 <- inp2 |> ggml_layer_dense(12L, activation = "relu") # widths differ
out_cat <- ggml_layer_concatenate(list(br1, br2), axis = 0L) |>
ggml_layer_dense(1L)
model_cat <- ggml_model(inputs = list(inp1, inp2), outputs = out_cat) |>
ggml_compile(optimizer = "adam", loss = "mse")
model_cat <- ggml_fit(model_cat, x = list(x1, x2), y = y_mpg,
epochs = 200L, batch_size = 16L, verbose = 0L)
p_cat <- ggml_predict(model_cat, x = list(x1, x2), batch_size = 16L)
cat(sprintf("Concatenate variant, Pearson r: %.4f\n", cor(p_cat, y_mpg)))A model with several outputs trains all of them. Each head
gets its own loss and weight, and the optimizer minimizes
sum(loss_weights[i] * loss_i). Losses may be mixed: below a
classification head and a regression head share one trunk.
Pass y as a list, one matrix per head,
matched by output name.
set.seed(11)
# Two targets from the same cars: efficient or not (class), and mpg (value).
y_cls <- as.integer(mtcars$mpg > median(mtcars$mpg))
y_cls <- cbind(1 - y_cls, y_cls) * 1.0 # one-hot [32 x 2]
x_all <- as.matrix(scale(mtcars[, c("disp","hp","wt","cyl")]))
inp_mo <- ggml_input(shape = 4L, name = "features")
trunk <- inp_mo |> ggml_layer_dense(16L, activation = "relu", name = "trunk")
head_c <- trunk |> ggml_layer_dense(2L, activation = "softmax", name = "class")
head_v <- trunk |> ggml_layer_dense(1L, name = "value")
model_mo <- ggml_model(inputs = inp_mo, outputs = list(head_c, head_v)) |>
ggml_compile(optimizer = "adam",
loss = list(class = "categorical_crossentropy",
value = "mse"),
loss_weights = c(class = 1.0, value = 0.5))
model_mo <- ggml_fit(model_mo, x_all,
y = list(class = y_cls, value = y_mpg),
epochs = 150L, batch_size = 16L, verbose = 0L)
# Per-head losses, so a head that stops learning is visible on its own
# rather than hidden inside the total.
h <- model_mo$history
cat(sprintf("class loss: %.4f -> %.4f\n",
h$train_class_loss[1], tail(h$train_class_loss, 1)))
cat(sprintf("value loss: %.4f -> %.4f\n",
h$train_value_loss[1], tail(h$train_value_loss, 1)))ggml_predict() returns a list, one matrix per head, and
ggml_evaluate() reports a <output>_loss
for each.
ggml_layer_attention() is multi-head scaled dot-product
attention. It takes and returns a sequence node of shape
c(seq_len, d_model), so blocks stack and a residual merge
needs no reshaping.
time_distributed = TRUE applies one dense kernel at
every position, sharing weights across the sequence — the position-wise
feed-forward sublayer that completes a transformer block. (Without it a
dense layer would flatten the sequence into a single vector.)
set.seed(13)
n_seq <- 128L; S <- 6L; D <- 8L
# A task that needs mixing across positions: is the sequence sum positive?
xs <- array(runif(n_seq * S * D, -1, 1), dim = c(n_seq, S, D))
lab <- as.integer(apply(xs, 1, mean) > 0)
y_sq <- cbind(1 - lab, lab) * 1.0
inp_t <- ggml_input(shape = c(S, D), name = "seq")
# Attention sublayer + residual
att <- inp_t |> ggml_layer_attention(d_model = D, n_heads = 2L, name = "mha")
h1 <- ggml_layer_add(list(inp_t, att))
# Position-wise feed-forward sublayer + residual
ff <- h1 |> ggml_layer_dense(D * 2L, activation = "relu",
time_distributed = TRUE, name = "ff1")
ff <- ff |> ggml_layer_dense(D, time_distributed = TRUE, name = "ff2")
h2 <- ggml_layer_add(list(h1, ff))
out_t <- h2 |> ggml_layer_flatten() |>
ggml_layer_dense(2L, activation = "softmax", name = "cls")
model_t <- ggml_model(inputs = inp_t, outputs = out_t) |>
ggml_compile(optimizer = "adam", loss = "categorical_crossentropy")
model_t <- ggml_fit(model_t, xs, y_sq, epochs = 30L, batch_size = 32L,
verbose = 0L)
cat(sprintf("transformer block accuracy: %.4f\n",
tail(model_t$history$train_accuracy, 1)))causal = TRUE masks keys after the query, making the
layer autoregressive (GPT-style). Cross-attention takes queries from one
node and keys/values from another, which may have a different
length:
dec <- inp_t |> ggml_layer_attention(D, n_heads = 2L, causal = TRUE)
ctx_seq <- ggml_input(shape = c(10L, D))
x_cross <- ggml_apply(list(dec, ctx_seq), ggml_attention(D, n_heads = 2L))ggml_attention() returns a reusable layer object:
applying it to several nodes shares one set of projections, which is how
an encoder block is reused across a stack. All heads are computed in one
batched pass, so the graph does not grow with n_heads.
Callbacks plug into ggml_fit() via the
callbacks argument.
cb_stop <- ggml_callback_early_stopping(
monitor = "val_loss",
patience = 15L,
min_delta = 1e-4
)
model_cb <- ggml_model_sequential() |>
ggml_layer_dense(32L, activation = "relu", input_shape = 4L) |>
ggml_layer_dense(3L, activation = "softmax") |>
ggml_compile(optimizer = "adam", loss = "categorical_crossentropy")
model_cb <- ggml_fit(model_cb, x_tr, y_tr,
epochs = 300L,
batch_size = 32L,
validation_split = 0.1,
callbacks = list(cb_stop),
verbose = 0L)# Cosine annealing
cb_cosine <- ggml_schedule_cosine_decay(T_max = 100L, eta_min = 1e-5)
# Step decay: halve LR every 30 epochs
cb_step <- ggml_schedule_step_decay(step_size = 30L, gamma = 0.5)
# Reduce on plateau
cb_plateau <- ggml_schedule_reduce_on_plateau(
monitor = "val_loss",
factor = 0.5,
patience = 10L,
min_lr = 1e-6
)
model_lr <- ggml_model_sequential() |>
ggml_layer_dense(32L, activation = "relu", input_shape = 4L) |>
ggml_layer_dense(3L, activation = "softmax") |>
ggml_compile(optimizer = "adam", loss = "categorical_crossentropy")
model_lr <- ggml_fit(model_lr, x_tr, y_tr,
epochs = 150L,
batch_size = 32L,
validation_split = 0.1,
callbacks = list(cb_cosine),
verbose = 0L)ag_*)The autograd engine provides PyTorch-style dynamic computation
graphs. Tensors are column-major: shape is
[features, batch].
ag_sequentialBS <- 32L
n <- ncol(x_tr_ag)
ag_train(ag_mod)
set.seed(42)
for (ep in seq_len(150L)) {
perm <- sample(n)
for (b in seq_len(ceiling(n / BS))) {
idx <- perm[seq((b-1L)*BS + 1L, min(b*BS, n))]
xb <- ag_tensor(x_tr_ag[, idx, drop = FALSE])
yb <- y_tr_ag[, idx, drop = FALSE]
with_grad_tape({
loss <- ag_softmax_cross_entropy_loss(ag_mod$forward(xb), yb)
})
grads <- backward(loss)
opt$step(grads)
opt$zero_grad()
}
if (ep %% 50L == 0L)
cat(sprintf("epoch %d loss %.4f\n", ep, loss$data[1]))
}ag_eval(ag_mod)
# forward in chunks, apply softmax manually
ag_predict_cm <- function(model, x_cm, chunk = 64L) {
n <- ncol(x_cm)
out <- matrix(0.0, nrow(model$forward(ag_tensor(x_cm[,1,drop=FALSE]))$data), n)
for (s in seq(1L, n, by = chunk)) {
e <- min(s + chunk - 1L, n)
lg <- model$forward(ag_tensor(x_cm[, s:e, drop = FALSE]))$data
ev <- exp(lg - apply(lg, 2, max))
out[, s:e] <- ev / colSums(ev)
}
out
}
probs_ag <- ag_predict_cm(ag_mod, x_val_ag) # [3, 30]
pred_ag <- apply(probs_ag, 2, which.max)
true_ag <- apply(y_val_ag, 1, which.max) # col-major: rows = classes
acc_ag <- mean(pred_ag == true_ag)
cat(sprintf("Autograd val accuracy: %.4f\n", acc_ag))ag_mod2 <- ag_sequential(
ag_linear(4L, 64L, activation = "relu"),
ag_batch_norm(64L),
ag_dropout(0.3),
ag_linear(64L, 32L, activation = "relu"),
ag_linear(32L, 3L)
)
params2 <- ag_mod2$parameters()
opt2 <- optimizer_adam(params2, lr = 1e-3)
sch2 <- lr_scheduler_cosine(opt2, T_max = 150L, lr_min = 1e-5)
ag_train(ag_mod2)
set.seed(42)
for (ep in seq_len(150L)) {
perm <- sample(n)
for (b in seq_len(ceiling(n / BS))) {
idx <- perm[seq((b-1L)*BS + 1L, min(b*BS, n))]
xb <- ag_tensor(x_tr_ag[, idx, drop = FALSE])
yb <- y_tr_ag[, idx, drop = FALSE]
with_grad_tape({
loss2 <- ag_softmax_cross_entropy_loss(ag_mod2$forward(xb), yb)
})
grads2 <- backward(loss2)
clip_grad_norm(params2, grads2, max_norm = 5.0)
opt2$step(grads2)
opt2$zero_grad()
}
sch2$step()
}
ag_eval(ag_mod2)
probs2 <- ag_predict_cm(ag_mod2, x_val_ag)
acc2 <- mean(apply(probs2, 2, which.max) == true_ag)
cat(sprintf("ag + cosine + clip val accuracy: %.4f\n", acc2))ag_paramFor full control, build a network directly from parameters:
make_net <- function(n_in, n_hidden, n_out) {
W1 <- ag_param(matrix(rnorm(n_hidden * n_in) * sqrt(2/n_in), n_hidden, n_in))
b1 <- ag_param(matrix(0.0, n_hidden, 1L))
W2 <- ag_param(matrix(rnorm(n_out * n_hidden) * sqrt(2/n_hidden), n_out, n_hidden))
b2 <- ag_param(matrix(0.0, n_out, 1L))
list(
forward = function(x)
ag_add(ag_matmul(W2, ag_relu(ag_add(ag_matmul(W1, x), b1))), b2),
parameters = function() list(W1=W1, b1=b1, W2=W2, b2=b2)
)
}
set.seed(1)
net <- make_net(4L, 32L, 3L)
opt_r <- optimizer_adam(net$parameters(), lr = 1e-3)
for (ep in seq_len(200L)) {
perm <- sample(n)
for (b in seq_len(ceiling(n / BS))) {
idx <- perm[seq((b-1L)*BS+1L, min(b*BS, n))]
xb <- ag_tensor(x_tr_ag[, idx, drop = FALSE])
yb <- y_tr_ag[, idx, drop = FALSE]
with_grad_tape({ loss_r <- ag_softmax_cross_entropy_loss(net$forward(xb), yb) })
gr <- backward(loss_r)
opt_r$step(gr); opt_r$zero_grad()
}
}
probs_r <- ag_predict_cm(net, x_val_ag)
acc_r <- mean(apply(probs_r, 2, which.max) == true_ag)
cat(sprintf("Raw ag_param val accuracy: %.4f\n", acc_r))# Use Vulkan GPU if available, fall back to CPU
device <- tryCatch({
ag_device("gpu")
"gpu"
}, error = function(e) "cpu")
cat("Running on:", device, "\n")
# Mixed precision (f16 on GPU, f32 on CPU)
ag_dtype(if (device == "gpu") "f16" else "f32")After calling ag_device() and ag_dtype(),
all subsequent ag_param and ag_tensor calls
use the selected device and dtype. The sequential API
(ggml_fit) picks up the backend automatically.
| Task | Sequential / Functional | Autograd |
|---|---|---|
| Build model | ggml_model_sequential() +
ggml_layer_* |
ag_sequential() or raw ag_param |
| Functional DAG | ggml_input() → ggml_model() |
— |
| Multi-output | outputs = list(...), loss = per head |
manual |
| Attention | ggml_layer_attention(),
ggml_attention() |
ag_multihead_attention() |
| Compile | ggml_compile() |
manual optimizer |
| Train | ggml_fit() |
with_grad_tape + backward +
opt$step |
| Evaluate | ggml_evaluate() |
manual loop |
| Predict | ggml_predict() |
manual forward |
| Save / load | ggml_save_model() / ggml_load_model() |
— |
| Callbacks | ggml_callback_early_stopping(),
ggml_schedule_* |
lr_scheduler_*, clip_grad_norm |
| Device | automatic | ag_device() / ag_dtype() |