@@ -161,6 +161,190 @@ def linear_discriminant_analysis(
161161 raise AssertionError
162162
163163
164+ def t_distributed_stochastic_neighbor_embedding (
165+ features : np .ndarray ,
166+ dimensions : int = 2 ,
167+ perplexity : float = 30.0 ,
168+ learning_rate : float = 200.0 ,
169+ max_iterations : int = 1000 ,
170+ random_state : int = 42 ,
171+ ) -> np .ndarray :
172+ """
173+ t-Distributed Stochastic Neighbor Embedding (t-SNE) algorithm for
174+ dimensionality reduction.
175+
176+ t-SNE is a machine learning algorithm for visualization developed by
177+ Laurens van der Maaten and Geoffrey Hinton. It is a nonlinear
178+ dimensionality reduction technique particularly well suited for the
179+ visualization of high-dimensional datasets.
180+
181+ For more details, see:
182+ https://en.wikipedia.org/wiki/T-distributed_stochastic_neighbor_embedding
183+ Original paper:
184+ https://www.jmlr.org/papers/volume9/vandermaaten08a/vandermaaten08a.pdf
185+
186+ Parameters:
187+ * features: Input data matrix where each column represents a data point
188+ * dimensions: Number of dimensions for the output (typically 2 or 3)
189+ * perplexity: Controls the effective number of neighbors (typically 5-50)
190+ * learning_rate: Learning rate for gradient descent
191+ * max_iterations: Maximum number of optimization iterations
192+ * random_state: Random seed for reproducible results
193+
194+ Returns:
195+ * projected_data: Low-dimensional representation of the input data
196+
197+ >>> # Test with simple 3D to 2D reduction
198+ >>> features = np.array([[1, 2], [3, 4], [5, 6], [7, 8]], dtype=float).T
199+ >>> result = t_distributed_stochastic_neighbor_embedding(
200+ ... features, dimensions=2, max_iterations=10
201+ ... )
202+ >>> result.shape
203+ (2, 4)
204+
205+ >>> # Test with invalid dimensions
206+ >>> try:
207+ ... t_distributed_stochastic_neighbor_embedding(features, dimensions=0)
208+ ... except ValueError as e:
209+ ... print("ValueError raised for invalid dimensions")
210+ ValueError raised for invalid dimensions
211+ """
212+
213+ if not isinstance (features , np .ndarray ) or features .size == 0 :
214+ raise ValueError ("Features must be a non-empty numpy array" )
215+
216+ if dimensions <= 0 :
217+ raise ValueError ("Dimensions must be a positive integer" )
218+
219+ if perplexity <= 0 :
220+ raise ValueError ("Perplexity must be positive" )
221+
222+ if learning_rate <= 0 :
223+ raise ValueError ("Learning rate must be positive" )
224+
225+ if max_iterations <= 0 :
226+ raise ValueError ("Max iterations must be positive" )
227+
228+ rng = np .random .default_rng (random_state )
229+ _ , num_samples = features .shape
230+
231+ if num_samples < dimensions + 1 :
232+ min_samples = dimensions + 1
233+ msg = (
234+ f"Need at least { min_samples } samples for t-SNE with { dimensions } "
235+ f"dimensions, but got { num_samples } samples"
236+ )
237+ raise ValueError (msg )
238+
239+ # Compute pairwise squared Euclidean distances
240+ def compute_pairwise_distances (data : np .ndarray ) -> np .ndarray :
241+ """Compute pairwise squared Euclidean distances."""
242+ sum_data = np .sum (np .square (data ), axis = 0 )
243+ distances = sum_data + sum_data [:, np .newaxis ] - 2 * np .dot (data .T , data )
244+ return np .maximum (distances , 0 ) # Ensure non-negative
245+
246+ # Compute perplexity-based probabilities using binary search
247+ def compute_conditional_probabilities (
248+ distances : np .ndarray , target_perplexity : float
249+ ) -> np .ndarray :
250+ """Compute conditional probabilities with target perplexity."""
251+ num_points = distances .shape [0 ]
252+ probabilities = np .zeros ((num_points , num_points ))
253+
254+ for i in range (num_points ):
255+ # Binary search for optimal sigma
256+ beta_min , beta_max = - np .inf , np .inf
257+ beta = 1.0
258+
259+ for _ in range (50 ): # Max iterations for binary search
260+ # Compute probabilities
261+ exp_distances = np .exp (- distances [i ] * beta )
262+ exp_distances [i ] = 0 # Set self-similarity to 0
263+ sum_exp = np .sum (exp_distances )
264+
265+ if sum_exp == 0 :
266+ probabilities [i ] = 0
267+ break
268+
269+ current_probabilities = exp_distances / sum_exp
270+
271+ # Compute perplexity
272+ entropy = - np .sum (
273+ current_probabilities * np .log2 (current_probabilities + 1e-12 )
274+ )
275+ current_perplexity = 2 ** entropy
276+
277+ # Check if we're close enough
278+ if abs (current_perplexity - target_perplexity ) < 1e-5 :
279+ probabilities [i ] = current_probabilities
280+ break
281+
282+ # Adjust beta
283+ if current_perplexity > target_perplexity :
284+ beta_min = beta
285+ beta = beta * 2 if beta_max == np .inf else (beta + beta_max ) / 2
286+ else :
287+ beta_max = beta
288+ beta = beta / 2 if beta_min == - np .inf else (beta + beta_min ) / 2
289+ else :
290+ probabilities [i ] = current_probabilities
291+
292+ return probabilities
293+
294+ # Compute high-dimensional probabilities
295+ distances = compute_pairwise_distances (features )
296+ conditional_probs = compute_conditional_probabilities (distances , perplexity )
297+
298+ # Symmetrize probabilities
299+ high_dim_probs = (conditional_probs + conditional_probs .T ) / (2 * num_samples )
300+ high_dim_probs = np .maximum (high_dim_probs , 1e-12 )
301+
302+ # Initialize low-dimensional embedding
303+ projected_data = rng .normal (0 , 1e-4 , (dimensions , num_samples ))
304+
305+ # Gradient descent optimization
306+ momentum = np .zeros_like (projected_data )
307+
308+ for _ in range (max_iterations ):
309+ # Compute low-dimensional probabilities (Student-t distribution)
310+ low_dim_distances = compute_pairwise_distances (projected_data )
311+ low_dim_probs_denom = 1 + low_dim_distances
312+ low_dim_probs_denom [np .diag_indices_from (low_dim_probs_denom )] = np .inf
313+
314+ low_dim_probs = 1 / low_dim_probs_denom
315+ np .fill_diagonal (low_dim_probs , 0 )
316+ sum_low_dim = np .sum (low_dim_probs )
317+
318+ if sum_low_dim == 0 :
319+ low_dim_probs = np .ones_like (low_dim_probs ) / (
320+ num_samples * (num_samples - 1 )
321+ )
322+ else :
323+ low_dim_probs = low_dim_probs / sum_low_dim
324+
325+ low_dim_probs = np .maximum (low_dim_probs , 1e-12 )
326+
327+ # Compute gradient
328+ prob_diff = high_dim_probs - low_dim_probs
329+ gradient = np .zeros_like (projected_data )
330+
331+ for i in range (num_samples ):
332+ diff = projected_data [:, i : i + 1 ] - projected_data
333+ gradient [:, i ] = np .sum (
334+ (prob_diff [i ] * (1 / low_dim_probs_denom [i ])).reshape (1 , - 1 ) * diff ,
335+ axis = 1 ,
336+ )
337+
338+ gradient *= 4 # Factor from t-SNE gradient derivation
339+
340+ # Update with momentum
341+ momentum = 0.5 * momentum - learning_rate * gradient
342+ projected_data += momentum
343+
344+ logging .info ("t-SNE computation completed" )
345+ return projected_data
346+
347+
164348def test_linear_discriminant_analysis () -> None :
165349 # Create dummy dataset with 2 classes and 3 features
166350 features = np .array ([[1 , 2 , 3 , 4 , 5 ], [2 , 3 , 4 , 5 , 6 ], [3 , 4 , 5 , 6 , 7 ]])
@@ -192,6 +376,35 @@ def test_principal_component_analysis() -> None:
192376 assert error_info .type is AssertionError
193377
194378
379+ def test_t_distributed_stochastic_neighbor_embedding () -> None :
380+ """Test t-SNE algorithm with various input conditions."""
381+ # Test with valid input
382+ features = np .array ([[1 , 2 , 3 , 4 ], [5 , 6 , 7 , 8 ]], dtype = float )
383+ dimensions = 2
384+ max_iterations = 10
385+ result = t_distributed_stochastic_neighbor_embedding (
386+ features , dimensions = dimensions , max_iterations = max_iterations
387+ )
388+
389+ # Check the shape of the result
390+ assert result .shape == (2 , 4 ), f"Expected shape (2, 4), got { result .shape } "
391+
392+ # Test with empty array
393+ try :
394+ empty_features = np .array ([])
395+ t_distributed_stochastic_neighbor_embedding (empty_features )
396+ raise AssertionError ("Should raise ValueError for empty array" )
397+ except ValueError :
398+ pass
399+
400+ # Test with invalid dimensions
401+ try :
402+ t_distributed_stochastic_neighbor_embedding (features , dimensions = 0 )
403+ raise AssertionError ("Should raise ValueError for invalid dimensions" )
404+ except ValueError :
405+ pass
406+
407+
195408if __name__ == "__main__" :
196409 import doctest
197410
0 commit comments