Skip to contents

The backend swap is easier to appreciate when you can see it. This article builds two two-dimensional PU problems — a linear boundary and a nonlinear ring — labels only a fraction of the positives under SCAR, and compares the probability surface each backend learns from the same labeled positives plus a sea of unlabeled points.

library(xplus)
library(ggplot2)

sim_lin <- simulate_pu_data(n = 900, p = 2, boundary = "linear",
                            prevalence = 0.35, label_propensity = 0.35,
                            n_informative = 2, sharpness = 6, seed = 7)
sim_rad <- simulate_pu_data(n = 900, p = 2, boundary = "nonlinear",
                            prevalence = 0.35, label_propensity = 0.35,
                            n_informative = 2, sharpness = 6, seed = 7)

Each simulation returns the observed PU labels y, the hidden truth z (used only for evaluation), and the feature matrix. About a third of the true positives are labeled — everything else, including the remaining positives, sits in the unlabeled cloud:

points_df <- function(sim) {
  data.frame(
    x1 = sim$x[, 1], x2 = sim$x[, 2],
    truth = factor(sim$z, levels = 0:1, c("negative", "positive")),
    observed = factor(sim$y, levels = 0:1, c("unlabeled", "labeled positive"))
  )
}

plot_observed <- function(sim, boundary) {
  d <- points_df(sim)
  ggplot(d, aes(x1, x2)) +
    geom_point(aes(color = truth, shape = truth, alpha = truth), size = 1.3) +
    geom_point(data = subset(d, observed == "labeled positive"),
               color = "black", shape = 21, size = 2.4, stroke = 1.1) +
    scale_color_manual(values = c(negative = "grey70", positive = "#C0392B")) +
    scale_alpha_manual(values = c(negative = 0.45, positive = 0.85)) +
    labs(title = paste(boundary, "boundary — what the algorithm sees"),
         subtitle = sprintf("%d labeled positives among %d unlabeled",
                            sum(sim$y), sum(sim$y == 0)),
         color = "true class", shape = "true class", alpha = "true class",
         x = "x1", y = "x2") +
    theme_bw(base_size = 11) +
    theme(legend.position = "bottom")
}

Linear boundary

Circled points are the only supervision the model gets — labeled positives drawn from the true positive class; the ring below is never revealed:

plot_observed(sim_lin, "Linear")

Now we fit xplus() with two backends and project each fitted model over a dense grid. Blue depth is the predicted P(y = 1); orange points are the labeled positives, grey ones the unlabeled pool:

surface_df <- function(sim, backend, res = 80) {
  fit <- xplus(sim$x, sim$y, backend = backend, max_iter = 20,
               seed = 1, nfolds = 5)
  gx <- seq(min(sim$x[, 1]), max(sim$x[, 1]), length.out = res)
  gy <- seq(min(sim$x[, 2]), max(sim$x[, 2]), length.out = res)
  g <- expand.grid(gx, gy)
  names(g) <- colnames(sim$x)
  g$prob <- as.numeric(predict(fit, newx = as.matrix(g[, colnames(sim$x)])))
  names(g)[1:2] <- c("V1", "V2")
  list(fit = fit, grid = g)
}

plot_surface <- function(sur, sim, backend, boundary) {
  d <- points_df(sim)
  unl <- sim$y == 0
  score <- auc(sim$z[unl], as.numeric(predict(sur$fit))[unl])
  ggplot(sur$grid, aes(V1, V2)) +
    geom_raster(aes(fill = prob), alpha = 0.9, interpolate = TRUE) +
    geom_point(data = d, aes(x1, x2, color = observed), size = 1) +
    scale_fill_gradient(low = "#F4F6F7", high = "#2E86C1", name = "P(y=1)") +
    scale_color_manual(values = c(unlabeled = "grey25",
                                  `labeled positive` = "#F39C12")) +
    labs(title = sprintf("xplus + %s on %s data", backend, boundary),
         subtitle = sprintf("honest cutoff %.3f — unlabeled-truth AUC %.3f",
                            sur$fit$cutoff, score),
         color = "observed", x = "x1", y = "x2") +
    theme_bw(base_size = 11) +
    theme(legend.position = "bottom")
}
sur_lin_glm <- surface_df(sim_lin, "glmnet")
plot_surface(sur_lin_glm, sim_lin, "glmnet", "linear")

sur_lin_ran <- surface_df(sim_lin, "ranger")
plot_surface(sur_lin_ran, sim_lin, "ranger", "linear")

Both backends recover the sloped boundary — glmnet a crisp linear ramp, ranger a blockier forest version — from the same 125 labeled positives.

Nonlinear boundary

The ring problem is the opposite regime: positives surround a negative core, so no linear separator exists.

plot_observed(sim_rad, "Nonlinear (radial)")

sur_rad_glm <- surface_df(sim_rad, "glmnet")
plot_surface(sur_rad_glm, sim_rad, "glmnet", "nonlinear")

glmnet returns an essentially flat map — AUC 0.500, no better than chance — because a logistic plane cannot express a ring. The PU wrapper does not rescue a learner that cannot represent the boundary.

sur_rad_ran <- surface_df(sim_rad, "ranger")
plot_surface(sur_rad_ran, sim_rad, "ranger", "nonlinear")

ranger paints the donut back in: a darker low-probability core inside a brighter positive ring, reaching unlabeled-truth AUC ≈ 0.97 from the same sparse supervision.

Takeaway

Same PU problem, same labels — the only thing that changed is the learner. That is the entire point of the multimodel API: PU performance is bounded by the geometry the backend can express, so the learner should be chosen empirically (xplus_grid() / pu_score()) rather than assumed. The honest stored predictions make that comparison valid without touching the hidden negatives.