← Back to the problem

USA USAAIO 2026 · Correction by IOAI Training

Hand-Built Threshold MLP for a Triangle

This page gives the full approach. Read it once you have searched.

Metric
functional correctness (30 points)
Evaluated on
written answers
Our score
100 % agreement on 200k random points
Expected level
exact inside/outside output for every point

Expected level reached

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

This problem asks you to build a tiny neural network by hand, without any training. You first write two PyTorch modules of your own: a step ("threshold") activation, and a linear layer whose random initialisation keeps the variance of the signal constant (which requires a short probability computation). Then, using only those two modules, you must assemble a multi-layer perceptron that outputs 1 for points inside the triangle with vertices (0,0)(0,0), (3,1)(3,1), (1,5)(1,5) and 0 outside. All weights are chosen by you, and gradients are switched off. The real lesson: what one neuron computes geometrically, and why two layers are enough to carve out a convex region.

How you are scored

Parts 7.1 to 7.3 are worth 5 points each, Part 7.4 is worth 15. The graders run your class on batches of points (input shape (B,2)(B, 2), output shape (B,)(B,), values 0 or 1) and check the module restrictions: only My_Threshold and My_Linear, hand-set weights, requires_grad = False. There is no partial metric; the network must be right.

Our network agrees with an independent barycentric point-in-triangle test on 200 000 random points (100 %), and on the vertices, edge midpoints and points just outside each vertex.

Question by question

7.1 The threshold module

Θ(x)=1\Theta(x) = 1 if x≥0x \ge 0, else 00. In PyTorch a comparison gives a boolean tensor, which we cast back to the input's dtype so it can flow into the next linear layer:

class My_Threshold(nn.Module):
    def forward(self, x):
        return (x >= 0).to(x.dtype)

(This function has zero gradient almost everywhere, which is exactly why nobody trains with it, and why this problem is inference only.)

7.2 The variance-preserving standard deviation

Let y=∑c=0C−1wcxcy = \sum_{c=0}^{C-1} w_c x_c with all wc,xcw_c, x_c independent, zero mean, Var⁡[wc]=σw2\operatorname{Var}[w_c] = \sigma_w^2, Var⁡[xc]=σ2\operatorname{Var}[x_c] = \sigma^2.

For one product, independence and zero means give E[wcxc]=E[wc] E[xc]=0\mathbb{E}[w_c x_c] = \mathbb{E}[w_c]\,\mathbb{E}[x_c] = 0, hence

Var⁡[wcxc]=E[wc2xc2]=E[wc2] E[xc2]=σw2σ2.\operatorname{Var}[w_c x_c] = \mathbb{E}[w_c^2 x_c^2] = \mathbb{E}[w_c^2]\,\mathbb{E}[x_c^2] = \sigma_w^2 \sigma^2 .

The CC products are independent of each other (they involve disjoint independent variables), so their variances add:

Var⁡[y]=∑c=0C−1Var⁡[wcxc]=C σw2 σ2.\operatorname{Var}[y] = \sum_{c=0}^{C-1} \operatorname{Var}[w_c x_c] = C\, \sigma_w^2\, \sigma^2 .

Setting Var⁡[y]=σ2\operatorname{Var}[y] = \sigma^2 gives Cσw2=1C\sigma_w^2 = 1, so

σw=1C.\boxed{\sigma_w = \frac{1}{\sqrt{C}}} .

A Monte Carlo check with C=64C = 64 and σ=3\sigma = 3 gives Var⁡[x]=9.00\operatorname{Var}[x] = 9.00 and Var⁡[y]=9.01\operatorname{Var}[y] = 9.01. This is the "LeCun" initialisation; without it, each layer would multiply the variance by Cσw2C\sigma_w^2 and a deep network's activations would explode or vanish geometrically with depth.

7.3 The linear module

CC is the number of inputs of the layer (in_features). The weight has PyTorch's usual shape (out, in), so the layer computes y=xW⊤+by = xW^\top + b:

class My_Linear(nn.Module):
    def __init__(self, in_features, out_features, bias=True):
        super().__init__()
        std = 1.0 / math.sqrt(in_features)
        self.weight = nn.Parameter(torch.randn(out_features, in_features) * std)
        self.bias = nn.Parameter(torch.zeros(out_features)) if bias else None

    def forward(self, x):
        y = x @ self.weight.T
        return y + self.bias if self.bias is not None else y

Wrapping tensors in nn.Parameter is what makes them show up in .parameters(), which we need in 7.4 to switch off their gradients.

7.4 The triangle detector

One neuron = one half-plane. A unit computing Θ(n1x+n2y+c)\Theta(n_1 x + n_2 y + c) outputs 1 exactly on one side of the line n1x+n2y+c=0n_1 x + n_2 y + c = 0. A triangle is the intersection of three such half-planes, one per edge. So we need:

  1. a hidden layer of 3 threshold units, one per edge, each equal to 1 on the triangle's side;
  2. an output unit that fires only when all three hidden units are 1 (a logical AND).

Finding each half-plane. For an edge from PP to QQ, a normal vector is obtained by rotating Q−PQ - P by 90 degrees; then we choose the sign so that the third vertex gives a positive value.

edgeline through the edgevalue at the opposite vertexinside condition
(0,0)→(3,1)(0,0)\to(3,1)x−3y=0x - 3y = 0at (1,5)(1,5): 1−15=−14<01 - 15 = -14 < 0−x+3y≥0-x + 3y \ge 0
(3,1)→(1,5)(3,1)\to(1,5)2x+y−7=02x + y - 7 = 0at (0,0)(0,0): −7<0-7 < 0−2x−y+7≥0-2x - y + 7 \ge 0
(1,5)→(0,0)(1,5)\to(0,0)5x−y=05x - y = 0at (3,1)(3,1): 15−1=14>015 - 1 = 14 > 05x−y≥05x - y \ge 0

(Check each line passes through its two vertices: e.g. 2⋅3+1−7=02\cdot3 + 1 - 7 = 0 and 2⋅1+5−7=02\cdot1 + 5 - 7 = 0.)

So the hidden layer is

W1=(−13−2−15−1),b1=(070),h=Θ(W1p+b1)∈{0,1}3.W_1 = \begin{pmatrix} -1 & 3 \\ -2 & -1 \\ 5 & -1 \end{pmatrix}, \qquad b_1 = \begin{pmatrix} 0 \\ 7 \\ 0 \end{pmatrix}, \qquad h = \Theta(W_1 p + b_1) \in \{0,1\}^3 .

The AND gate. h0+h1+h2=3h_0 + h_1 + h_2 = 3 exactly when the point satisfies all three tests. With weights (1,1,1)(1,1,1) and bias −2.5-2.5, the output Θ(h0+h1+h2−2.5)\Theta(h_0 + h_1 + h_2 - 2.5) is 1 for a sum of 3 and 0 for a sum of 2 or less. Any bias in (−3,−2](-3, -2] works; −2.5-2.5 sits in the middle of the gap, so it does not depend on how Θ\Theta treats exactly 0.

Left: the plane coloured by how many of the three hidden units output 1, with the three edge lines drawn; the count is 3 only inside the triangle and 1 or 2 everywhere else. Right: the network's output, equal to 1 exactly on the triangle.

The left panel shows a nice fact: no point of the plane fails all three tests (the count is never 0), and the regions beyond each vertex pass only one. The AND threshold is what turns this "vote count" into a sharp region.

The module.

class My_Triangle(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden, self.out, self.step = My_Linear(2, 3), My_Linear(3, 1), My_Threshold()
        with torch.no_grad():
            self.hidden.weight.copy_(torch.tensor([[-1., 3.], [-2., -1.], [5., -1.]]))
            self.hidden.bias.copy_(torch.tensor([0., 7., 0.]))
            self.out.weight.copy_(torch.tensor([[1., 1., 1.]]))
            self.out.bias.copy_(torch.tensor([-2.5]))
        for p in self.parameters():
            p.requires_grad = False

    def forward(self, x):                                  # (B, 2)
        h = self.step(self.hidden(x))                      # (B, 3)
        return self.step(self.out(h)).squeeze(-1)          # (B,)

my_triangle = My_Triangle()

Two details that cost points if missed: squeeze(-1) turns the (B,1)(B,1) output into the required (B,)(B,), and the weights are copied inside torch.no_grad() because in-place writes into a tensor that requires gradients are forbidden. Points on the boundary count as inside (the threshold uses ≥0\ge 0), and since all weights are small integers the boundary decision is exact in floating point for points with simple coordinates such as the vertices.

The key idea

A threshold neuron draws a straight line and answers "which side?". A first layer of such neurons cuts the plane by several lines; a second layer combines the answers with logic (AND with bias −(m−12)-(m - \tfrac12) for mm inputs, OR with bias −12-\tfrac12). Any convex polygon with mm edges therefore needs mm hidden units and one AND; a union of convex pieces needs one more layer of ORs. This is the constructive heart of the universal approximation theorem, and it explains what trained ReLU or sigmoid networks do softly: their first-layer units are oriented hyperplanes, and later layers build regions out of them.

The full solution

solution.py contains the four parts, a Monte Carlo check of 7.2, and the verification of 7.4 against an independent barycentric test: writing p−v0=λ1(v1−v0)+λ2(v2−v0)p - v_0 = \lambda_1 (v_1 - v_0) + \lambda_2 (v_2 - v_0), the point is inside iff λ1≥0\lambda_1 \ge 0, λ2≥0\lambda_2 \ge 0 and λ1+λ2≤1\lambda_1 + \lambda_2 \le 1. It also checks the output shape (B,)(B,) and that no parameter requires a gradient.

Results

checkoutcome
7.2: Var⁡[y]\operatorname{Var}[y] with σw=1/C\sigma_w = 1/\sqrt{C} (C=64C = 64, σ2=9\sigma^2 = 9)9.01 (target 9)
7.4: agreement with barycentric test, 200 000 random points in [−1,4]×[−1,6][-1,4]\times[-1,6]100 %
vertices, edge midpoints, centroidall 1
points 0.01 outside each vertexall 0
output shape, requires_grad(B,)(B,), all False
a tempting wrong design: output bias −3.5-3.5outputs 0 everywhere
a tempting wrong design: output bias −1.5-1.5also fires on the 2-test regions (wrong outside)

What to remember

  • A threshold (or ReLU) unit is a hyperplane plus a side. Get the side right by plugging in a point you know is inside.
  • Two layers carve convex polygons: one hidden unit per edge, then an AND whose bias sits between m−1m-1 and mm.
  • Variances of independent zero-mean products multiply, and sums add them: Var⁡[y]=Cσw2σ2\operatorname{Var}[y] = C\sigma_w^2\sigma^2, hence initialise with σw=1/fan-in\sigma_w = 1/\sqrt{\text{fan-in}}.
  • Mind the PyTorch plumbing: nn.Parameter to register weights, torch.no_grad() to set them, squeeze for the output shape.
  • Verify a hand-built model against an independent method on many random points; it is the fastest way to catch a sign error on one edge.
Full code (solution.py)
"""USAAIO 2026 Round 1, Problem 7: threshold activation, variance-preserving
init, a custom linear layer, and a hand-built MLP that detects points inside
the triangle (0,0), (3,1), (1,5).
Run:  python solution.py
"""
import math
import numpy as np
import torch
import torch.nn as nn
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

torch.set_num_threads(4)

# ------------------------------------------------------------ Part 7.1
class My_Threshold(nn.Module):
    """Heaviside step: 1 if x >= 0 else 0 (same dtype as the input)."""
    def forward(self, x):
        return (x >= 0).to(x.dtype)

# ------------------------------------------------------------ Part 7.2
# Var(y) = sum_c Var(w_c x_c) = C * sigma_w^2 * sigma^2  ->  sigma_w = 1/sqrt(C)
torch.manual_seed(0)
C, n = 64, 200_000
xs = torch.randn(n, C) * 3.0                     # sigma = 3
ws = torch.randn(n, C) / math.sqrt(C)
print("7.2 check: Var(x) = %.3f, Var(y) = %.3f" % (xs.var(), (ws * xs).sum(1).var()))

# ------------------------------------------------------------ Part 7.3
class My_Linear(nn.Module):
    """y = x W^T + b, W ~ N(0, 1/in_features), b = 0 (optional)."""
    def __init__(self, in_features, out_features, bias=True):
        super().__init__()
        std = 1.0 / math.sqrt(in_features)
        self.weight = nn.Parameter(torch.randn(out_features, in_features) * std)
        self.bias = nn.Parameter(torch.zeros(out_features)) if bias else None

    def forward(self, x):
        y = x @ self.weight.T
        return y + self.bias if self.bias is not None else y

# ------------------------------------------------------------ Part 7.4
# Each edge gives a half-plane  n . p + c >= 0  that contains the triangle.
#  edge (0,0)-(3,1):  -x + 3y      >= 0
#  edge (3,1)-(1,5):  -2x - y + 7  >= 0
#  edge (1,5)-(0,0):   5x - y      >= 0
# Hidden layer = 3 half-plane tests, output = AND (all three equal 1).
class My_Triangle(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden = My_Linear(2, 3)
        self.out = My_Linear(3, 1)
        self.step = My_Threshold()
        with torch.no_grad():
            self.hidden.weight.copy_(torch.tensor([[-1., 3.], [-2., -1.], [5., -1.]]))
            self.hidden.bias.copy_(torch.tensor([0., 7., 0.]))
            self.out.weight.copy_(torch.tensor([[1., 1., 1.]]))
            self.out.bias.copy_(torch.tensor([-2.5]))   # sum >= 2.5  <=>  all three are 1
        for p in self.parameters():
            p.requires_grad = False

    def forward(self, x):                  # x: (B, 2)
        h = self.step(self.hidden(x))      # (B, 3) half-plane indicators
        return self.step(self.out(h)).squeeze(-1)   # (B,)

my_triangle = My_Triangle()
assert all(not p.requires_grad for p in my_triangle.parameters())

# ------------------------------------------------------------ verification
V = np.array([[0, 0], [3, 1], [1, 5]], dtype=float)

def inside_reference(P):
    """Barycentric test, independent of the network (boundary counts as inside)."""
    T = np.array([V[1] - V[0], V[2] - V[0]]).T
    lam = np.linalg.solve(T, (P - V[0]).T).T
    l1, l2 = lam[:, 0], lam[:, 1]
    return ((l1 >= -1e-12) & (l2 >= -1e-12) & (l1 + l2 <= 1 + 1e-12)).astype(int)

rng = np.random.default_rng(0)
P = rng.uniform([-1, -1], [4, 6], size=(200_000, 2))
pred = my_triangle(torch.tensor(P, dtype=torch.float64).float()).numpy().astype(int)
print("random points: agreement = %.6f" % (pred == inside_reference(P)).mean())

special = torch.tensor([[0, 0], [3, 1], [1, 5], [1.5, 0.5], [2, 3], [0.5, 2.5],
                        [4 / 3, 2], [1, 1], [-0.01, 0], [3.01, 1], [1, 5.01]])
print("vertices, edge midpoints, centroid, outside:", my_triangle(special).tolist())
out = my_triangle(torch.zeros(7, 2))
print("output shape for B=7:", tuple(out.shape), out.dtype)

# ------------------------------------------------------------ figure
plt.rcParams.update({"font.size": 12})
fig, axes = plt.subplots(1, 2, figsize=(12, 5.6))
gx, gy = np.meshgrid(np.linspace(-1, 4, 400), np.linspace(-1, 6, 560))
G = torch.tensor(np.c_[gx.ravel(), gy.ravel()], dtype=torch.float32)
H = my_triangle.step(my_triangle.hidden(G)).numpy()
names = ["$-x+3y\\geq 0$", "$-2x-y+7\\geq 0$", "$5x-y\\geq 0$"]
cols = ["#1f77b4", "#2ca02c", "#d62728"]
ax = axes[0]
cnt = H.sum(1).reshape(gx.shape)                 # how many half-plane tests pass
from matplotlib.colors import ListedColormap
cm = ListedColormap(["#ffffff", "#deebf7", "#9ecae1", "#3182bd"])
im = ax.contourf(gx, gy, cnt, levels=[-0.5, 0.5, 1.5, 2.5, 3.5], cmap=cm)
for k in range(3):
    ax.contour(gx, gy, H[:, k].reshape(gx.shape), levels=[0.5], colors=[cols[k]], linewidths=2)
    ax.plot([], [], color=cols[k], lw=2, label=names[k])
cb = fig.colorbar(im, ax=ax, ticks=[0, 1, 2, 3], fraction=0.05)
cb.set_label("sum of hidden units")
ax.set_title("Hidden layer: how many edge tests pass")
ax.legend(loc="upper right", fontsize=10); ax.set_aspect("equal")
ax.set_xlabel("$x$"); ax.set_ylabel("$y$")
ax = axes[1]
Z = my_triangle(G).numpy().reshape(gx.shape)
ax.contourf(gx, gy, Z, levels=[-0.5, 0.5, 1.5], colors=["#f2f2f2", "#ffbb78"])
ax.plot(*np.vstack([V, V[:1]]).T, "k-", lw=1.5)
for (a, b) in V:
    ax.annotate(f"({a:g}, {b:g})", (a, b), textcoords="offset points", xytext=(6, 4))
ax.set_title("Output: 1 iff the sum is 3 (orange)")
ax.set_aspect("equal"); ax.set_xlabel("$x$"); ax.set_ylabel("$y$")
fig.tight_layout()
fig.savefig("figures/triangle.png", dpi=150)