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 , , 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 , output shape , 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
if , else . 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 with all independent, zero mean, , .
For one product, independence and zero means give , hence
The products are independent of each other (they involve disjoint independent variables), so their variances add:
Setting gives , so
A Monte Carlo check with and gives and . This is the "LeCun" initialisation; without it, each layer would multiply the variance by and a deep network's activations would explode or vanish geometrically with depth.
7.3 The linear module
is the number of inputs of the layer (in_features). The weight has PyTorch's usual shape (out, in), so the layer computes :
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 outputs 1 exactly on one side of the line . A triangle is the intersection of three such half-planes, one per edge. So we need:
- a hidden layer of 3 threshold units, one per edge, each equal to 1 on the triangle's side;
- an output unit that fires only when all three hidden units are 1 (a logical AND).
Finding each half-plane. For an edge from to , a normal vector is obtained by rotating by 90 degrees; then we choose the sign so that the third vertex gives a positive value.
| edge | line through the edge | value at the opposite vertex | inside condition |
|---|---|---|---|
| at : | |||
| at : | |||
| at : |
(Check each line passes through its two vertices: e.g. and .)
So the hidden layer is
The AND gate. exactly when the point satisfies all three tests. With weights and bias , the output is 1 for a sum of 3 and 0 for a sum of 2 or less. Any bias in works; sits in the middle of the gap, so it does not depend on how treats exactly 0.

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 output into the required , 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 ), 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 for inputs, OR with bias ). Any convex polygon with edges therefore needs 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 , the point is inside iff , and . It also checks the output shape and that no parameter requires a gradient.
Results
| check | outcome |
|---|---|
| 7.2: with (, ) | 9.01 (target 9) |
| 7.4: agreement with barycentric test, 200 000 random points in | 100 % |
| vertices, edge midpoints, centroid | all 1 |
| points 0.01 outside each vertex | all 0 |
output shape, requires_grad | , all False |
| a tempting wrong design: output bias | outputs 0 everywhere |
| a tempting wrong design: output bias | also 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 and .
- Variances of independent zero-mean products multiply, and sums add them: , hence initialise with .
- Mind the PyTorch plumbing:
nn.Parameterto register weights,torch.no_grad()to set them,squeezefor 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.