@@ -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+
2835class 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 ] = []
0 commit comments