Skip to content

Commit 7202e13

Browse files
Add t-SNE implementation and tests for dimensionality reduction (#13337)
* Add t-SNE implementation and tests for dimensionality reduction Implemented the t-distributed stochastic neighbor embedding (t-SNE) algorithm in dimensionality_reduction.py, including input validation and a test function. * Fix Ruff linting errors E501 and EM102 in t-SNE implementation Resolve line length violation (E501) and f-string literal in exception (EM102) by splitting error message and using variable assignment. --------- Co-authored-by: Christian Clauss <cclauss@me.com>
1 parent 140d410 commit 7202e13

1 file changed

Lines changed: 213 additions & 0 deletions

File tree

machine_learning/dimensionality_reduction.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
164348
def 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+
195408
if __name__ == "__main__":
196409
import doctest
197410

0 commit comments

Comments
 (0)