Skip to content

Commit 2f1208e

Browse files
committed
feat: add support for softmax activation and cross-entropy loss in MLP
1 parent 3034036 commit 2f1208e

4 files changed

Lines changed: 116 additions & 14 deletions

File tree

bs.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ This document explains the full journey of building neural models from first pri
3030
Given inputs $x \in \mathbb{R}^n$, weights $w \in \mathbb{R}^n$, bias $b$:
3131

3232
$$
33-
z = \sum_i w_i x_i + b,\quad
33+
z = \sum_{i=1}^{n} w_i x_i + b,\quad
3434
\hat y = H(z) = \begin{cases}
3535
1 & z \ge 0 \\
3636
0 & \text{otherwise}
@@ -58,7 +58,7 @@ Only misclassified samples update parameters (classic perceptron rule, not gradi
5858
Layer sizes: $L_0$ (input) … $L_k$ (output). For neuron $j$ in layer $L$:
5959

6060
$$
61-
z_j^{(L)} = \sum_i w_{j,i}^{(L)}\, a_i^{(L-1)} + b_j^{(L)},\quad
61+
z_j^{(L)} = \sum_{i=1}^{n_{L-1}} w_{j,i}^{(L)}\, a_i^{(L-1)} + b_j^{(L)},\quad
6262
a_j^{(L)} = \sigma\big(z_j^{(L)}\big) = \frac{1}{1 + e^{-z_j^{(L)}}}.
6363
$$
6464

@@ -71,7 +71,7 @@ Internal storage:
7171
### Loss (Per Sample, MSE)
7272

7373
$$
74-
\mathcal{L} = \sum_j \tfrac{1}{2}\,\big(a_j^{(\text{out})} - y_j\big)^2.
74+
\mathcal{L} = \sum_{j=1}^{n_{\text{out}}} \tfrac{1}{2}\,\big(a_j^{(\text{out})} - y_j\big)^2.
7575
$$
7676

7777
Using MSE for simplicity (cross‑entropy would be more suitable for classification but requires softmax modifications).
@@ -89,7 +89,7 @@ $$
8989
Hidden layer deltas (chain rule):
9090

9191
$$
92-
\delta_i^{(L)} = \left( \sum_j w_{j,i}^{(L+1)}\, \delta_j^{(L+1)} \right)\, \sigma'\!\big(a_i^{(L)}\big).
92+
\delta_i^{(L)} = \left( \sum_{j=1}^{n_{L+1}} w_{j,i}^{(L+1)}\, \delta_j^{(L+1)} \right)\, \sigma'\!\big(a_i^{(L)}\big).
9393
$$
9494

9595
Weight & bias gradients:

bs/mlp.py

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,44 @@ def sigmoid_derivative(y: float) -> float:
2525
return y * (1.0 - y)
2626

2727

28+
def softmax(vec: List[float]) -> List[float]:
29+
m = max(vec)
30+
exps = [math.exp(v - m) for v in vec]
31+
s = sum(exps)
32+
return [e / s for e in exps]
33+
34+
2835
class MLP:
2936
def __init__(
30-
self, layer_sizes: List[int], lr: float = 0.5, seed: int | None = None
37+
self,
38+
layer_sizes: List[int],
39+
lr: float = 0.5,
40+
seed: int | None = None,
41+
output_activation: str = "sigmoid", # or 'softmax'
42+
loss: str = "mse", # 'mse' or 'ce' (cross-entropy only with softmax)
43+
class_weights: List[float] | None = None,
3144
):
3245
if len(layer_sizes) < 2:
3346
raise ValueError("Need at least input and output layer")
3447
if any(s <= 0 for s in layer_sizes):
3548
raise ValueError("All layer sizes must be > 0")
3649
self.layer_sizes = layer_sizes
3750
self.lr = lr
51+
if output_activation not in ("sigmoid", "softmax"):
52+
raise ValueError(
53+
"output_activation must be 'sigmoid' or 'softmax'"
54+
)
55+
if loss not in ("mse", "ce"):
56+
raise ValueError("loss must be 'mse' or 'ce'")
57+
if output_activation == "softmax" and layer_sizes[-1] == 1:
58+
raise ValueError("softmax output requires >1 output neurons")
59+
if loss == "ce" and output_activation != "softmax":
60+
raise ValueError(
61+
"cross-entropy currently only supported with softmax output"
62+
)
63+
self.output_activation = output_activation
64+
self.loss = loss
65+
self.class_weights = class_weights
3866
if seed is not None:
3967
random.seed(seed)
4068
# Weights: list of matrices (next_layer_size x current_layer_size)
@@ -61,17 +89,25 @@ def to_dict(self) -> dict:
6189
"learning_rate": self.lr,
6290
"weights": self.weights,
6391
"biases": self.biases,
92+
"output_activation": self.output_activation,
93+
"loss": self.loss,
94+
"class_weights": self.class_weights,
6495
}
6596

6697
@staticmethod
6798
def from_dict(data: dict) -> "MLP":
6899
required = {"layer_sizes", "learning_rate", "weights", "biases"}
69100
if not required.issubset(data):
70101
raise ValueError("Invalid MLP model file")
102+
output_activation = data.get("output_activation", "sigmoid")
103+
loss = data.get("loss", "mse")
71104
mlp = MLP(
72-
data["layer_sizes"], lr=data["learning_rate"]
105+
data["layer_sizes"],
106+
lr=data["learning_rate"],
107+
output_activation=output_activation,
108+
loss=loss,
109+
class_weights=data.get("class_weights"),
73110
) # initializes sizes
74-
# Replace weights/biases with stored values (shape consistency assumed)
75111
if len(mlp.weights) != len(data["weights"]):
76112
raise ValueError("Weights shape mismatch")
77113
mlp.weights = data["weights"]
@@ -90,15 +126,21 @@ def forward(
90126
w_mat = self.weights[layer_idx]
91127
b_vec = self.biases[layer_idx]
92128
z_layer: List[float] = []
93-
a_next: List[float] = []
94129
for neuron_idx in range(len(w_mat)):
95130
w = w_mat[neuron_idx]
96131
z = (
97132
sum(w_j * a_j for w_j, a_j in zip(w, a))
98133
+ b_vec[neuron_idx]
99134
)
100135
z_layer.append(z)
101-
a_next.append(sigmoid(z))
136+
# Activation choice: last layer may use softmax
137+
if (
138+
layer_idx == len(self.weights) - 1
139+
and self.output_activation == "softmax"
140+
):
141+
a_next = softmax(z_layer)
142+
else:
143+
a_next = [sigmoid(z) for z in z_layer]
102144
zs.append(z_layer)
103145
activations.append(a_next)
104146
a = a_next
@@ -112,7 +154,22 @@ def _backprop_sample(
112154
self, activations: List[List[float]], target: List[float]
113155
) -> Tuple[List[List[List[float]]], List[List[float]], float, bool]:
114156
output = activations[-1]
115-
loss = sum(0.5 * (o - t) ** 2 for o, t in zip(output, target))
157+
if self.loss == "mse":
158+
loss = sum(0.5 * (o - t) ** 2 for o, t in zip(output, target))
159+
else: # cross-entropy with softmax output
160+
# Add small epsilon for numerical stability
161+
eps = 1e-12
162+
base = -sum(t * math.log(o + eps) for o, t in zip(output, target))
163+
if self.class_weights:
164+
# weight by true class
165+
w_true = 0.0
166+
for i, t in enumerate(target):
167+
if t > 0.0:
168+
w_true = self.class_weights[i]
169+
break
170+
loss = w_true * base
171+
else:
172+
loss = base
116173
is_correct = False
117174
if len(target) == 1:
118175
pred_bin = 1 if output[0] >= 0.5 else 0
@@ -126,8 +183,18 @@ def _backprop_sample(
126183
out_acts = activations[-1]
127184
delta_out: List[float] = []
128185
for i in range(len(out_acts)):
129-
error = out_acts[i] - target[i]
130-
delta_out.append(error * sigmoid_derivative(out_acts[i]))
186+
if self.output_activation == "softmax" and self.loss == "ce":
187+
# Softmax + cross-entropy simplifies gradient; apply class weighting if provided
188+
scale = 1.0
189+
if self.class_weights:
190+
for k, t in enumerate(target):
191+
if t > 0.0:
192+
scale = self.class_weights[k]
193+
break
194+
delta_out.append(scale * (out_acts[i] - target[i]))
195+
else:
196+
error = out_acts[i] - target[i]
197+
delta_out.append(error * sigmoid_derivative(out_acts[i]))
131198
deltas[last_layer_idx] = delta_out
132199
for layer_idx in range(last_layer_idx - 1, -1, -1):
133200
layer_deltas: List[float] = []

scripts/infer_mlp.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def main():
3939
mlp = MLP.from_dict(data)
4040
x = args.input
4141
out = mlp.predict(x)
42-
print(f"Input={x}\nRaw Output={out}")
42+
print(
43+
f"Input={x}\nRaw Output={out}\nOutputActivation={getattr(mlp,'output_activation','sigmoid')}"
44+
)
4345
if len(out) == 1:
4446
print(f"Binary Thresholded={(1 if out[0] >= 0.5 else 0)}")
4547
else:

scripts/train_tictactoe_mlp.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ def main():
5656
p.add_argument("--mode", choices=["binary", "multi"], default="binary")
5757
p.add_argument("--hidden", type=int, default=27, help="Hidden layer size")
5858
p.add_argument("--epochs", type=int, default=4000)
59+
p.add_argument(
60+
"--softmax",
61+
action="store_true",
62+
help="Use softmax + cross-entropy for multi-class (ignored in binary)",
63+
)
64+
p.add_argument(
65+
"--no-class-weights",
66+
action="store_true",
67+
help="Disable class-weighted loss for multi-class (softmax)",
68+
)
5969
p.add_argument("--lr", type=float, default=0.3)
6070
p.add_argument(
6171
"--batch", type=int, default=64, help="Mini-batch size (0=full batch)"
@@ -86,16 +96,39 @@ def main():
8696
if args.mode == "binary":
8797
data = build_binary_dataset()
8898
output_size = 1
99+
output_activation = "sigmoid"
100+
loss = "mse"
89101
else:
90102
data = build_multiclass_dataset()
91103
output_size = 3
104+
output_activation = "softmax" if args.softmax else "sigmoid"
105+
loss = "ce" if args.softmax else "mse"
106+
class_weights = None
107+
if args.softmax and not args.no_class_weights:
108+
# Compute inverse-frequency normalized weights: N / (K * count_c)
109+
counts = [0, 0, 0]
110+
for _, y in data:
111+
idx = max(range(len(y)), key=lambda i: y[i])
112+
counts[idx] += 1
113+
N = len(data)
114+
K = 3
115+
class_weights = [N / (K * c) if c > 0 else 0.0 for c in counts]
116+
print(
117+
f"Class counts={counts} -> weights={','.join(f'{w:.3f}' for w in class_weights)}"
118+
)
92119

93120
train_set, val_set = split_dataset(data, val_ratio=args.val)
94121
print(
95122
f"Dataset size: total={len(data)} train={len(train_set)} val={len(val_set)} mode={args.mode}"
96123
)
97124

98-
mlp = MLP([9, args.hidden, output_size], lr=args.lr)
125+
mlp = MLP(
126+
[9, args.hidden, output_size],
127+
lr=args.lr,
128+
output_activation=output_activation,
129+
loss=loss,
130+
class_weights=(class_weights if args.mode == "multi" else None),
131+
)
99132
history = mlp.train(
100133
train_set,
101134
epochs=args.epochs,

0 commit comments

Comments
 (0)