Skip to content

TOPSIS

topsis

TOPSIS (Technique for Order Preference by Similarity to Ideal Solution) implementation.

TOPSIS is a multi-criteria decision making (MCDA) method that ranks alternatives based on their distance to the ideal solution and distance from the anti-ideal solution.

Classes

Functions

topsis

topsis(objectives: ndarray | DataFrame, weights: ndarray | list[float] | None = None, directions: ndarray | list[str] | list[OptimizationDirection] | None = None, normalize_method: str = 'vector', return_details: bool = False) -> ndarray | dict

Apply TOPSIS method to rank solutions.

TOPSIS ranks alternatives by calculating: 1. Distance to ideal solution (best values for each objective) 2. Distance to anti-ideal solution (worst values for each objective) 3. Closeness coefficient: distance_negative / (distance_positive + distance_negative)

Higher closeness coefficient = better solution.

Parameters:

Name Type Description Default
objectives ndarray | DataFrame

Array or DataFrame of objective values (n_solutions, n_objectives)

required
weights ndarray | list[float] | None

Weights for each objective (must sum to 1). If None, equal weights

None
directions ndarray | list[str] | list[OptimizationDirection] | None

Optimization direction for each objective ('min' or 'max') If None, assumes all minimization

None
normalize_method str

Normalization method ('vector' or 'minmax')

'vector'
return_details bool

If True, return detailed results including distances

False

Returns:

Type Description
ndarray | dict

If return_details=False: Array of closeness coefficients (scores)

ndarray | dict

If return_details=True: Dictionary with scores, ranks, and intermediate values

Source code in optiscope/analysis/topsis.py
def topsis(
    objectives: np.ndarray | pd.DataFrame,
    weights: np.ndarray | list[float] | None = None,
    directions: np.ndarray | list[str] | list[OptimizationDirection] | None = None,
    normalize_method: str = "vector",
    return_details: bool = False,
) -> np.ndarray | dict:
    """
    Apply TOPSIS method to rank solutions.

    TOPSIS ranks alternatives by calculating:
    1. Distance to ideal solution (best values for each objective)
    2. Distance to anti-ideal solution (worst values for each objective)
    3. Closeness coefficient: distance_negative / (distance_positive + distance_negative)

    Higher closeness coefficient = better solution.

    Args:
        objectives: Array or DataFrame of objective values (n_solutions, n_objectives)
        weights: Weights for each objective (must sum to 1). If None, equal weights
        directions: Optimization direction for each objective ('min' or 'max')
                   If None, assumes all minimization
        normalize_method: Normalization method ('vector' or 'minmax')
        return_details: If True, return detailed results including distances

    Returns:
        If return_details=False: Array of closeness coefficients (scores)
        If return_details=True: Dictionary with scores, ranks, and intermediate values
    """
    # Convert to numpy array
    if isinstance(objectives, pd.DataFrame):
        obj_array = objectives.values
        obj_names = objectives.columns.tolist()
    else:
        obj_array = np.asarray(objectives)
        obj_names = [f"f{i + 1}" for i in range(obj_array.shape[1])]

    n_solutions, n_objectives = obj_array.shape

    # Set default weights (equal)
    if weights is None:
        weights = np.ones(n_objectives) / n_objectives
    else:
        weights = np.asarray(weights)
        if len(weights) != n_objectives:
            raise ValueError(
                f"Number of weights ({len(weights)}) must match objectives ({n_objectives})"
            )
        if not np.isclose(weights.sum(), 1.0):
            # Normalize weights
            weights = weights / weights.sum()

    # Set default directions (all minimization)
    if directions is None:
        directions = np.array(["min"] * n_objectives)
    else:
        directions = np.asarray(directions)
        if len(directions) != n_objectives:
            raise ValueError("Number of directions must match objectives")

    # Convert objectives for maximization (negate them for uniform treatment)
    obj_normalized_dir = obj_array.copy()
    for i, direction in enumerate(directions):
        if direction in ["max", "maximize", OptimizationDirection.MAXIMIZE]:
            obj_normalized_dir[:, i] = -obj_normalized_dir[:, i]

    # Step 1: Normalize the decision matrix
    if normalize_method == "vector":
        # Vector normalization (divide by norm of each column)
        norms = np.linalg.norm(obj_normalized_dir, axis=0)
        norms[norms < 1e-10] = 1.0  # Avoid division by zero
        normalized_matrix = obj_normalized_dir / norms
    elif normalize_method == "minmax":
        # Min-max normalization to [0, 1]
        min_vals = obj_normalized_dir.min(axis=0)
        max_vals = obj_normalized_dir.max(axis=0)
        ranges = max_vals - min_vals
        ranges[ranges < 1e-10] = 1.0
        normalized_matrix = (obj_normalized_dir - min_vals) / ranges
    else:
        raise ValueError(f"Unknown normalization method: {normalize_method}")

    # Step 2: Calculate weighted normalized matrix
    weighted_matrix = normalized_matrix * weights

    # Step 3: Determine ideal and anti-ideal solutions
    # For minimization (after direction normalization), ideal is minimum
    ideal_solution = weighted_matrix.min(axis=0)
    anti_ideal_solution = weighted_matrix.max(axis=0)

    # Step 4: Calculate distances to ideal and anti-ideal
    distance_to_ideal = np.linalg.norm(weighted_matrix - ideal_solution, axis=1)
    distance_to_anti_ideal = np.linalg.norm(weighted_matrix - anti_ideal_solution, axis=1)

    # Step 5: Calculate closeness coefficient
    # Avoid division by zero
    total_distance = distance_to_ideal + distance_to_anti_ideal
    total_distance[total_distance < 1e-10] = 1e-10

    closeness_coefficient = distance_to_anti_ideal / total_distance

    # Step 6: Rank solutions (higher closeness = better)
    ranks = np.argsort(closeness_coefficient)[::-1]

    if return_details:
        return {
            "scores": closeness_coefficient,
            "ranks": ranks,
            "distance_to_ideal": distance_to_ideal,
            "distance_to_anti_ideal": distance_to_anti_ideal,
            "ideal_solution": ideal_solution,
            "anti_ideal_solution": anti_ideal_solution,
            "normalized_matrix": normalized_matrix,
            "weighted_matrix": weighted_matrix,
            "weights": weights,
            "objective_names": obj_names,
        }
    else:
        return closeness_coefficient

topsis_from_result

topsis_from_result(result: OptimizationResult, weights: dict[str, float] | None = None, subset_name: str | None = None, return_details: bool = False) -> ndarray | dict

Apply TOPSIS to OptimizationResult using metadata.

Automatically extracts optimization directions from metadata.

Parameters:

Name Type Description Default
result OptimizationResult

OptimizationResult object

required
weights dict[str, float] | None

Dictionary mapping objective names to weights

None
subset_name str | None

Name of result set to analyze (if None, use all points)

None
return_details bool

Return detailed results

False

Returns:

Type Description
ndarray | dict

TOPSIS scores or detailed results

Source code in optiscope/analysis/topsis.py
def topsis_from_result(
    result: OptimizationResult,
    weights: dict[str, float] | None = None,
    subset_name: str | None = None,
    return_details: bool = False,
) -> np.ndarray | dict:
    """
    Apply TOPSIS to OptimizationResult using metadata.

    Automatically extracts optimization directions from metadata.

    Args:
        result: OptimizationResult object
        weights: Dictionary mapping objective names to weights
        subset_name: Name of result set to analyze (if None, use all points)
        return_details: Return detailed results

    Returns:
        TOPSIS scores or detailed results
    """
    if result.objectives.empty:
        raise ValueError("No objectives found in result")

    # Get objectives data
    if subset_name and subset_name in result.sets:
        result_set = result.get_set(subset_name)
        objectives_data = result.objectives.iloc[result_set.indices]
    else:
        objectives_data = result.objectives

    obj_names = objectives_data.columns.tolist()
    len(obj_names)

    # Extract directions from metadata
    directions = []
    for obj_name in obj_names:
        meta = result.get_variable_metadata(obj_name)
        if meta and hasattr(meta, "direction"):
            directions.append(meta.direction)
        else:
            directions.append("min")  # Default to minimization

    # Process weights
    if weights is None:
        weight_array = None
    else:
        weight_array = np.array([weights.get(name, 1.0) for name in obj_names])

    # Apply TOPSIS
    topsis_result = topsis(
        objectives_data, weights=weight_array, directions=directions, return_details=return_details
    )

    if return_details:
        # Add indices mapping back to original result
        if subset_name and subset_name in result.sets:
            topsis_result["original_indices"] = result_set.indices
        else:
            topsis_result["original_indices"] = np.arange(result.n_points)

    return topsis_result

interactive_topsis

interactive_topsis(objectives: ndarray | DataFrame, initial_weights: ndarray | None = None, directions: ndarray | None = None, n_top: int = 10) -> dict

Prepare data for interactive TOPSIS weight adjustment.

Returns results structure that can be used with interactive widgets to adjust weights and see real-time ranking changes.

Parameters:

Name Type Description Default
objectives ndarray | DataFrame

Objective values

required
initial_weights ndarray | None

Initial weights

None
directions ndarray | None

Optimization directions

None
n_top int

Number of top solutions to track

10

Returns:

Type Description
dict

Dictionary with initial results and data for interactive updates

Source code in optiscope/analysis/topsis.py
def interactive_topsis(
    objectives: np.ndarray | pd.DataFrame,
    initial_weights: np.ndarray | None = None,
    directions: np.ndarray | None = None,
    n_top: int = 10,
) -> dict:
    """
    Prepare data for interactive TOPSIS weight adjustment.

    Returns results structure that can be used with interactive widgets
    to adjust weights and see real-time ranking changes.

    Args:
        objectives: Objective values
        initial_weights: Initial weights
        directions: Optimization directions
        n_top: Number of top solutions to track

    Returns:
        Dictionary with initial results and data for interactive updates
    """
    # Initial TOPSIS run
    initial_result = topsis(
        objectives, weights=initial_weights, directions=directions, return_details=True
    )

    top_indices = initial_result["ranks"][:n_top]

    return {
        "objectives": objectives,
        "directions": directions,
        "n_objectives": objectives.shape[1] if hasattr(objectives, "shape") else len(objectives[0]),
        "n_solutions": len(objectives),
        "initial_weights": initial_result["weights"],
        "initial_scores": initial_result["scores"],
        "initial_top_indices": top_indices,
        "objective_names": initial_result["objective_names"],
    }

sensitivity_analysis_topsis

sensitivity_analysis_topsis(objectives: ndarray | DataFrame, base_weights: ndarray, directions: ndarray | None = None, perturbation: float = 0.1, n_samples: int = 100) -> dict

Perform sensitivity analysis on TOPSIS results.

Varies weights around base values to see how rankings change.

Parameters:

Name Type Description Default
objectives ndarray | DataFrame

Objective values

required
base_weights ndarray

Base weight vector

required
directions ndarray | None

Optimization directions

None
perturbation float

Maximum perturbation as fraction (0.1 = ±10%)

0.1
n_samples int

Number of random weight samples to test

100

Returns:

Type Description
dict

Dictionary with sensitivity analysis results

Source code in optiscope/analysis/topsis.py
def sensitivity_analysis_topsis(
    objectives: np.ndarray | pd.DataFrame,
    base_weights: np.ndarray,
    directions: np.ndarray | None = None,
    perturbation: float = 0.1,
    n_samples: int = 100,
) -> dict:
    """
    Perform sensitivity analysis on TOPSIS results.

    Varies weights around base values to see how rankings change.

    Args:
        objectives: Objective values
        base_weights: Base weight vector
        directions: Optimization directions
        perturbation: Maximum perturbation as fraction (0.1 = ±10%)
        n_samples: Number of random weight samples to test

    Returns:
        Dictionary with sensitivity analysis results
    """
    if isinstance(objectives, pd.DataFrame):
        obj_array = objectives.values
    else:
        obj_array = np.asarray(objectives)

    n_solutions, n_objectives = obj_array.shape

    # Store rankings for each weight sample
    all_ranks = np.zeros((n_samples, n_solutions), dtype=int)
    all_scores = np.zeros((n_samples, n_solutions))
    all_weights = np.zeros((n_samples, n_objectives))

    # Base case
    base_scores = topsis(objectives, weights=base_weights, directions=directions)
    base_ranks = np.argsort(base_scores)[::-1]

    for i in range(n_samples):
        # Generate perturbed weights
        perturbations = np.random.uniform(-perturbation, perturbation, n_objectives)
        perturbed_weights = base_weights * (1 + perturbations)
        perturbed_weights = np.maximum(perturbed_weights, 0)  # Ensure non-negative
        perturbed_weights /= perturbed_weights.sum()  # Normalize

        # Calculate scores
        scores = topsis(objectives, weights=perturbed_weights, directions=directions)
        ranks = np.argsort(scores)[::-1]

        all_scores[i] = scores
        all_ranks[i] = ranks
        all_weights[i] = perturbed_weights

    # Analyze stability
    # For each solution, track how often it appears in top-k
    top_k_stability = {}
    for k in [1, 5, 10, 20]:
        if k > n_solutions:
            continue
        appearances = np.zeros(n_solutions)
        for ranks in all_ranks:
            top_k_indices = ranks[:k]
            appearances[top_k_indices] += 1
        top_k_stability[f"top_{k}"] = appearances / n_samples

    # Rank volatility (average change in rank position)
    rank_positions = np.zeros((n_samples, n_solutions))
    for i, ranks in enumerate(all_ranks):
        rank_positions[i, ranks] = np.arange(n_solutions)

    rank_std = rank_positions.std(axis=0)

    return {
        "base_scores": base_scores,
        "base_ranks": base_ranks,
        "all_scores": all_scores,
        "all_ranks": all_ranks,
        "all_weights": all_weights,
        "top_k_stability": top_k_stability,
        "rank_std": rank_std,
        "most_stable_solution": int(rank_std.argmin()),
        "least_stable_solution": int(rank_std.argmax()),
        "avg_rank_std": float(rank_std.mean()),
    }

compare_weight_scenarios

compare_weight_scenarios(objectives: ndarray | DataFrame, weight_scenarios: dict[str, ndarray], directions: ndarray | None = None) -> DataFrame

Compare TOPSIS results across different weight scenarios.

Parameters:

Name Type Description Default
objectives ndarray | DataFrame

Objective values

required
weight_scenarios dict[str, ndarray]

Dictionary mapping scenario names to weight vectors

required
directions ndarray | None

Optimization directions

None

Returns:

Type Description
DataFrame

DataFrame comparing top solutions across scenarios

Source code in optiscope/analysis/topsis.py
def compare_weight_scenarios(
    objectives: np.ndarray | pd.DataFrame,
    weight_scenarios: dict[str, np.ndarray],
    directions: np.ndarray | None = None,
) -> pd.DataFrame:
    """
    Compare TOPSIS results across different weight scenarios.

    Args:
        objectives: Objective values
        weight_scenarios: Dictionary mapping scenario names to weight vectors
        directions: Optimization directions

    Returns:
        DataFrame comparing top solutions across scenarios
    """
    results = {}

    for scenario_name, weights in weight_scenarios.items():
        scores = topsis(objectives, weights=weights, directions=directions)
        ranks = np.argsort(scores)[::-1]

        results[f"{scenario_name}_score"] = scores
        results[f"{scenario_name}_rank"] = np.argsort(ranks)  # Rank position

    df = pd.DataFrame(results)

    # Add summary statistics
    score_cols = [col for col in df.columns if col.endswith("_score")]
    df["avg_score"] = df[score_cols].mean(axis=1)
    df["std_score"] = df[score_cols].std(axis=1)

    rank_cols = [col for col in df.columns if col.endswith("_rank")]
    df["avg_rank"] = df[rank_cols].mean(axis=1)
    df["std_rank"] = df[rank_cols].std(axis=1)

    return df