Skip to content

graph_coloring

tissue_simulator.graph_coloring

Graph coloring module for assigning cell types based on network statistics.

This module integrates simulated annealing-based graph coloring to assign cell types to tissue networks based on target statistical properties.

GraphColorizer

GraphColorizer(source_graph: Graph = None, target_graph: Graph = None, colors: list = None, target_statistics: Dict = None, seed: Optional[int] = None)

Imposes network statistics from target data onto a graph by finding an optimal node coloring using Simulated Annealing.

The statistics matched are: 1. Node color counts. 2. Pairwise edge counts between all combinations of colors. 3. Neighbor color distribution (average number of neighbors of color X for a node of color Y).

To accelerate repeated runs (e.g. generating tissue replicates), :meth:colorize supports warm-starting from a caller-supplied initial coloring (initial_coloring).

Initializes the GraphColorizer.

Parameters:

Name Type Description Default
source_graph Graph

Graph with 'color' attribute (optional if target_statistics provided)

None
target_graph Graph

Graph to be colored

None
colors list

List of possible color strings (e.g., ['cancer', 'immune', 'stroma'])

None
target_statistics Dict

Pre-calculated target statistics (optional)

None
seed Optional[int]

Optional integer seed for the simulated-annealing RNG. When provided, the colorizer uses an instance-bound random.Random(seed) for all stochastic choices (initial-coloring shuffle, swap proposals, acceptance draws), making :meth:colorize bit-reproducible. When None (default), behavior is unchanged: the unseeded stdlib random module is used.

None
Source code in tissue_simulator/graph_coloring.py
def __init__(self, source_graph: nx.Graph = None,
             target_graph: nx.Graph = None,
             colors: list = None,
             target_statistics: Dict = None,
             seed: Optional[int] = None):
    """
    Initializes the GraphColorizer.

    Args:
        source_graph: Graph with 'color' attribute (optional if target_statistics provided)
        target_graph: Graph to be colored
        colors: List of possible color strings (e.g., ['cancer', 'immune', 'stroma'])
        target_statistics: Pre-calculated target statistics (optional)
        seed: Optional integer seed for the simulated-annealing RNG. When
            provided, the colorizer uses an instance-bound
            ``random.Random(seed)`` for all stochastic choices
            (initial-coloring shuffle, swap proposals, acceptance draws),
            making :meth:`colorize` bit-reproducible. When ``None``
            (default), behavior is unchanged: the unseeded stdlib
            ``random`` module is used.
    """
    if not NETWORKX_AVAILABLE:
        raise ImportError("NetworkX is required. Install with: pip install networkx")

    self.seed = seed
    self._rng = random.Random(seed) if seed is not None else random

    if target_graph is None:
        raise ValueError("Target graph must be provided.")

    self.target_graph = target_graph
    # Ensure target_graph has no self-loops as they complicate edge counting
    self.target_graph.remove_edges_from(nx.selfloop_edges(self.target_graph))

    self.nodes = list(self.target_graph.nodes())
    self.colors = colors if colors is not None else []
    self.color_map = {color: i for i, color in enumerate(self.colors)}

    # Optional spatial-composition target (density-aware replicates): the
    # expected number of nodes of each color in each spatial bin.
    spatial = (target_statistics or {}).get('spatial_composition')
    self._node_bin = dict(spatial['node_bin']) if spatial else None
    self._expected_bins = ({b: dict(v) for b, v in spatial['expected'].items()}
                           if spatial else {})
    self._spatial_weight = float(spatial.get('weight', 1.0)) if spatial else 0.0

    # Get target statistics either from source graph or directly
    if target_statistics is not None:
        print("Using provided target statistics...")
        self.target_stats = (target_statistics if spatial is None else
                             {k: v for k, v in target_statistics.items()
                              if k != 'spatial_composition'})
    elif source_graph is not None:
        if not nx.get_node_attributes(source_graph, 'color'):
            raise ValueError("Source graph nodes must have a 'color' attribute.")
        print("Calculating target statistics from source graph...")
        source_coloring = nx.get_node_attributes(source_graph, 'color')
        self.target_stats, _ = self._calculate_statistics(source_graph, source_coloring)
    else:
        raise ValueError("Either source_graph or target_statistics must be provided.")

    print("Target Statistics:", self.target_stats)

    # Internal state for incremental updates
    self.neighbor_counts = None

cost_terms

cost_terms(stats: dict) -> Dict[str, float]

Unweighted squared-error components of the cost for stats.

Keys: edge (edge counts), neighbor (mean neighbor counts) and spatial (per-bin color counts; 0 without a spatial target).

Source code in tissue_simulator/graph_coloring.py
def cost_terms(self, stats: dict) -> Dict[str, float]:
    """Unweighted squared-error components of the cost for ``stats``.

    Keys: ``edge`` (edge counts), ``neighbor`` (mean neighbor counts) and
    ``spatial`` (per-bin color counts; 0 without a spatial target).
    """
    edge = sum((stats['edge_counts'].get(key, 0) - target) ** 2
               for key, target in self.target_stats['edge_counts'].items())
    neighbor = sum((stats['neighbor_dist'].get(c1, {}).get(c2, 0) - target) ** 2
                   for c1, dist in self.target_stats['neighbor_dist'].items()
                   for c2, target in dist.items())
    return {'edge': float(edge), 'neighbor': float(neighbor),
            'spatial': float(stats.get('spatial_sse', 0.0))}

colorize

colorize(initial_temp=100.0, final_temp=0.1, cooling_rate=0.995, max_iterations=100000, verbose=True, initial_coloring=None, patience=None, min_delta=1e-09, return_history=False)

Performs the simulated annealing process to find the optimal coloring.

Parameters:

Name Type Description Default
initial_temp

Starting temperature

100.0
final_temp

Temperature at which to stop

0.1
cooling_rate

Rate at which temperature decreases (e.g., 0.99 -> slow, 0.9 -> fast)

0.995
max_iterations

Maximum number of iterations

100000
verbose

If True, prints progress updates

True
initial_coloring

Optional dict mapping nodes to colors to use as the starting (warm-start) coloring instead of the random node-counts-based shuffle. Every value must be one of self.colors. Nodes missing from the dict are filled with the most-frequent target color; keys not in the target graph are ignored. When None (default) the random-shuffle initial coloring is used, byte-for-byte identical to the original implementation.

None
patience

Optional adaptive-stopping budget. When set, the search stops early once best_cost has not improved by more than min_delta for patience consecutive iterations (a plateau). When None (default) the loop runs to max_iterations / final_temp exactly as before.

None
min_delta

Minimum best_cost improvement that counts as progress for the patience plateau check.

1e-09
return_history

When True, return (best_coloring, cost_history) where cost_history is the per-iteration best_cost series (suitable for convergence.find_convergence_time). When False (default) only best_coloring is returned, preserving the original return type.

False

Returns:

Name Type Description
dict

Best coloring found for the target graph (or

(dict, list[float]) when return_history=True).

Source code in tissue_simulator/graph_coloring.py
def colorize(self, initial_temp=100.0, final_temp=0.1, cooling_rate=0.995,
             max_iterations=100000, verbose=True,
             initial_coloring=None,
             patience=None, min_delta=1e-9, return_history=False):
    """
    Performs the simulated annealing process to find the optimal coloring.

    Args:
        initial_temp: Starting temperature
        final_temp: Temperature at which to stop
        cooling_rate: Rate at which temperature decreases (e.g., 0.99 -> slow, 0.9 -> fast)
        max_iterations: Maximum number of iterations
        verbose: If True, prints progress updates
        initial_coloring: Optional dict mapping nodes to colors to use as the
            starting (warm-start) coloring instead of the random
            node-counts-based shuffle. Every value must be one of
            ``self.colors``. Nodes missing from the dict are filled with the
            most-frequent target color; keys not in the target graph are
            ignored. When ``None`` (default) the random-shuffle initial
            coloring is used, byte-for-byte identical to the original
            implementation.
        patience: Optional adaptive-stopping budget. When set, the search
            stops early once ``best_cost`` has not improved by more than
            ``min_delta`` for ``patience`` consecutive iterations (a plateau).
            When ``None`` (default) the loop runs to ``max_iterations`` /
            ``final_temp`` exactly as before.
        min_delta: Minimum ``best_cost`` improvement that counts as progress
            for the ``patience`` plateau check.
        return_history: When True, return ``(best_coloring, cost_history)``
            where ``cost_history`` is the per-iteration ``best_cost`` series
            (suitable for ``convergence.find_convergence_time``). When False
            (default) only ``best_coloring`` is returned, preserving the
            original return type.

    Returns:
        dict: Best coloring found for the target graph (or
        ``(dict, list[float])`` when ``return_history=True``).
    """
    if initial_coloring is None:
        # Initial coloring: match the node counts from the target statistics
        color_list = []
        for color, count in self.target_stats['node_counts'].items():
            color_list.extend([color] * count)

        # Ensure we have a color for every node in the target graph
        if len(color_list) < len(self.nodes):
            # Fill remaining nodes with the most frequent color
            most_frequent_color = max(self.target_stats['node_counts'],
                                     key=self.target_stats['node_counts'].get)
            color_list.extend([most_frequent_color] * (len(self.nodes) - len(color_list)))

        self._rng.shuffle(color_list)
        current_coloring = {node: color for node, color in zip(self.nodes, color_list[:len(self.nodes)])}
    else:
        # Warm-start: use the caller-supplied coloring.
        for node, color in initial_coloring.items():
            if color not in self.colors:
                raise ValueError(
                    f"initial_coloring assigns invalid color {color!r} to node {node!r}; "
                    f"valid colors are {self.colors}."
                )

        current_coloring = {node: initial_coloring[node]
                            for node in self.nodes if node in initial_coloring}

        # Fill any nodes missing from initial_coloring with the most frequent color.
        missing_nodes = [node for node in self.nodes if node not in current_coloring]
        if missing_nodes:
            most_frequent_color = max(self.target_stats['node_counts'],
                                     key=self.target_stats['node_counts'].get)
            for node in missing_nodes:
                current_coloring[node] = most_frequent_color
            if verbose:
                print(f"Warm-start: filled {len(missing_nodes)} node(s) "
                      f"with most-frequent color '{most_frequent_color}'.")

    best_coloring = current_coloring.copy()

    current_stats, self.neighbor_counts = self._calculate_statistics(self.target_graph, current_coloring)
    current_cost = self._calculate_cost(current_stats)
    best_cost = current_cost

    temperature = initial_temp

    cost_history = []
    iters_since_improvement = 0

    i = -1
    for i in range(max_iterations):
        if temperature < final_temp:
            if verbose:
                print("Temperature fell below final threshold. Stopping.")
            break

        # Propose a new state by swapping colors of two random nodes
        node1, node2 = self._rng.sample(self.nodes, 2)

        # Incremental update instead of full recalculation
        new_stats, new_neighbor_counts = self._update_statistics_incremental(
            node1, node2, current_coloring, current_stats, self.neighbor_counts
        )
        new_cost = self._calculate_cost(new_stats)

        delta_cost = new_cost - current_cost

        # Acceptance criteria
        if delta_cost < 0 or self._rng.random() < math.exp(-delta_cost / temperature):
            # Update state
            c1, c2 = current_coloring[node1], current_coloring[node2]
            current_coloring[node1], current_coloring[node2] = c2, c1

            current_cost = new_cost
            current_stats = new_stats
            self.neighbor_counts = new_neighbor_counts

            if current_cost < best_cost:
                if (best_cost - current_cost) > min_delta:
                    iters_since_improvement = -1  # reset (becomes 0 below)
                best_coloring = current_coloring.copy()
                best_cost = current_cost

        # Cool down
        temperature *= cooling_rate

        # Occasionally do a full recalculation to avoid floating point drift
        if i > 0 and i % 5000 == 0:
            current_stats, self.neighbor_counts = self._calculate_statistics(self.target_graph, current_coloring)
            current_cost = self._calculate_cost(current_stats)

        if return_history:
            cost_history.append(best_cost)

        # Adaptive stopping: break once best_cost has plateaued.
        iters_since_improvement += 1
        if patience is not None and iters_since_improvement >= patience:
            if verbose:
                print(f"Best cost plateaued for {patience} iterations. Stopping early.")
            break

        if verbose and i % 500 == 0:
            print(f"Iter {i}: Temp={temperature:.2f}, Cost={current_cost:.4f}, Best Cost={best_cost:.4f}")

    if verbose:
        print(f"\nFinished after {i+1} iterations.")
        print(f"Final best cost: {best_cost}")
    if return_history:
        return best_coloring, cost_history
    return best_coloring

color_graph_to_targets

color_graph_to_targets(target_graph: Graph, colors: List[str], target_statistics: Dict, seed: Optional[int] = None, initial_coloring: Optional[Dict] = None, return_cost: bool = False, **colorize_kwargs)

Colorize target_graph to match target_statistics via simulated annealing.

Thin shared wrapper around :class:GraphColorizer so callers (ReplicateGenerator, TissueNetworkWorkflow) use a single code path.

Parameters:

Name Type Description Default
target_graph Graph

NetworkX graph to color.

required
colors List[str]

List of color / cell-type names.

required
target_statistics Dict

GraphColorizer-format target dict (node_counts / edge_counts / neighbor_dist).

required
seed Optional[int]

Optional RNG seed for bit-reproducible annealing.

None
initial_coloring Optional[Dict]

Optional warm-start coloring passed to colorize.

None
return_cost bool

When True, also return the final cost of the returned coloring (useful for picking the best of several restarts).

False
**colorize_kwargs

Forwarded to :meth:GraphColorizer.colorize (e.g. initial_temp, final_temp, cooling_rate, max_iterations, verbose).

{}

Returns:

Type Description

dict mapping node -> color (the best coloring found), or

(coloring, cost) when return_cost=True.

Source code in tissue_simulator/graph_coloring.py
def color_graph_to_targets(target_graph: nx.Graph,
                           colors: List[str],
                           target_statistics: Dict,
                           seed: Optional[int] = None,
                           initial_coloring: Optional[Dict] = None,
                           return_cost: bool = False,
                           **colorize_kwargs):
    """
    Colorize ``target_graph`` to match ``target_statistics`` via simulated annealing.

    Thin shared wrapper around :class:`GraphColorizer` so callers
    (``ReplicateGenerator``, ``TissueNetworkWorkflow``) use a single code path.

    Args:
        target_graph: NetworkX graph to color.
        colors: List of color / cell-type names.
        target_statistics: GraphColorizer-format target dict
            (``node_counts`` / ``edge_counts`` / ``neighbor_dist``).
        seed: Optional RNG seed for bit-reproducible annealing.
        initial_coloring: Optional warm-start coloring passed to ``colorize``.
        return_cost: When True, also return the final cost of the returned
            coloring (useful for picking the best of several restarts).
        **colorize_kwargs: Forwarded to :meth:`GraphColorizer.colorize`
            (e.g. ``initial_temp``, ``final_temp``, ``cooling_rate``,
            ``max_iterations``, ``verbose``).

    Returns:
        dict mapping node -> color (the best coloring found), or
        ``(coloring, cost)`` when ``return_cost=True``.
    """
    colorizer = GraphColorizer(
        target_graph=target_graph,
        colors=list(colors),
        target_statistics=target_statistics,
        seed=seed,
    )
    coloring = colorizer.colorize(initial_coloring=initial_coloring, **colorize_kwargs)
    if return_cost:
        stats, _ = colorizer._calculate_statistics(colorizer.target_graph, coloring)
        return coloring, colorizer._calculate_cost(stats)
    return coloring

calculate_graph_statistics

calculate_graph_statistics(graph: Graph, colors_map: Dict, color_names: List[str]) -> Dict

Calculate node and edge count statistics for a colored graph.

Parameters:

Name Type Description Default
graph Graph

NetworkX graph

required
colors_map Dict

Dictionary mapping node IDs to colors

required
color_names List[str]

List of all possible color names

required

Returns:

Type Description
Dict

Dictionary of statistics

Source code in tissue_simulator/graph_coloring.py
def calculate_graph_statistics(graph: nx.Graph, colors_map: Dict, color_names: List[str]) -> Dict:
    """
    Calculate node and edge count statistics for a colored graph.

    Args:
        graph: NetworkX graph
        colors_map: Dictionary mapping node IDs to colors
        color_names: List of all possible color names

    Returns:
        Dictionary of statistics
    """
    stats = {}

    # Node counts
    node_counts = Counter(colors_map.values())
    for color in color_names:
        stats[f'nodes_{color}'] = node_counts.get(color, 0)

    # Edge counts
    edge_counts = Counter()
    for u, v in graph.edges():
        c1, c2 = sorted((colors_map[u], colors_map[v]))
        edge_counts[(c1, c2)] += 1

    for i in range(len(color_names)):
        for j in range(i, len(color_names)):
            c1, c2 = color_names[i], color_names[j]
            key = tuple(sorted((c1, c2)))
            stats[f'edges_{c1}-{c2}'] = edge_counts.get(key, 0)

    return stats

compare_graph_statistics

compare_graph_statistics(source_stats: Dict, target_stats: Dict, verbose: bool = True) -> Dict

Calculate and optionally print the percent difference between two sets of statistics.

Parameters:

Name Type Description Default
source_stats Dict

Statistics from source/target graph

required
target_stats Dict

Statistics from generated graph

required
verbose bool

Whether to print comparison details

True

Returns:

Type Description
Dict

Dictionary of differences for each statistic

Source code in tissue_simulator/graph_coloring.py
def compare_graph_statistics(source_stats: Dict, target_stats: Dict, verbose: bool = True) -> Dict:
    """
    Calculate and optionally print the percent difference between two sets of statistics.

    Args:
        source_stats: Statistics from source/target graph
        target_stats: Statistics from generated graph
        verbose: Whether to print comparison details

    Returns:
        Dictionary of differences for each statistic
    """
    if verbose:
        print("--- Statistics Comparison ---")
    differences = {}

    edge_keys = [k for k in source_stats if k.startswith('edges_')]

    for key in edge_keys:
        source_val = source_stats.get(key, 0)
        target_val = target_stats.get(key, 0)

        if source_val == 0 and target_val == 0:
            diff = 0.0
        elif source_val == 0:
            diff = float('inf')  # Or handle as a special case
        else:
            diff = (abs(target_val - source_val) / source_val) * 100

        differences[key] = diff
        if verbose:
            print(f"{key}:")
            print(f"  Source: {source_val}, Target: {target_val}")
            print(f"  Percent Difference: {diff:.2f}%")

    avg_diff = sum(differences.values()) / len(differences) if differences else 0
    if verbose:
        print(f"\nAverage Percent Difference (Edges): {avg_diff:.2f}%")
    return differences

load_target_statistics_from_csv

load_target_statistics_from_csv(filepath: str, color_names: List[str]) -> Dict

Load target statistics from a CSV file.

Parameters:

Name Type Description Default
filepath str

Path to CSV file with statistics

required
color_names List[str]

List of color names to use

required

Returns:

Type Description
Dict

Dictionary with target statistics in GraphColorizer format

Source code in tissue_simulator/graph_coloring.py
def load_target_statistics_from_csv(filepath: str, color_names: List[str]) -> Dict:
    """
    Load target statistics from a CSV file.

    Args:
        filepath: Path to CSV file with statistics
        color_names: List of color names to use

    Returns:
        Dictionary with target statistics in GraphColorizer format
    """
    import pandas as pd

    # Read statistics
    df = pd.read_csv(filepath)

    if len(df) == 0:
        raise ValueError("CSV file is empty")

    # Extract statistics from first row
    row = df.iloc[0]

    # Build target statistics structure
    target_stats = {
        'node_counts': {},
        'edge_counts': {},
        'neighbor_dist': defaultdict(lambda: defaultdict(float))
    }

    # Extract node counts
    for color in color_names:
        node_key = f'nodes_{color}'
        if node_key in row:
            target_stats['node_counts'][color] = int(row[node_key])

    # Extract edge counts
    for i in range(len(color_names)):
        for j in range(i, len(color_names)):
            c1, c2 = color_names[i], color_names[j]
            edge_key = f'edges_{c1}-{c2}'
            if edge_key in row:
                key = '-'.join(sorted([c1, c2]))
                target_stats['edge_counts'][key] = int(row[edge_key])

    return target_stats

export_colored_graph_statistics

export_colored_graph_statistics(graph: Graph, colors_map: Dict, color_names: List[str], filename: str)

Export graph statistics to CSV file.

Parameters:

Name Type Description Default
graph Graph

NetworkX graph

required
colors_map Dict

Dictionary mapping node IDs to colors

required
color_names List[str]

List of color names

required
filename str

Output CSV filename

required
Source code in tissue_simulator/graph_coloring.py
def export_colored_graph_statistics(graph: nx.Graph, colors_map: Dict, 
                                    color_names: List[str], filename: str):
    """
    Export graph statistics to CSV file.

    Args:
        graph: NetworkX graph
        colors_map: Dictionary mapping node IDs to colors
        color_names: List of color names
        filename: Output CSV filename
    """
    import pandas as pd

    stats = calculate_graph_statistics(graph, colors_map, color_names)
    pd.DataFrame([stats]).to_csv(filename, index=False)
    print(f"Saved statistics to {filename}")

visualize_colored_graph

visualize_colored_graph(graph: Graph, colors_map: Dict, color_palette: Dict = None, layout: str = 'spring', title: str = 'Colored Graph', save_path: str = None, figsize: Tuple[int, int] = (12, 10))

Visualize a colored graph.

Parameters:

Name Type Description Default
graph Graph

NetworkX graph

required
colors_map Dict

Dictionary mapping node IDs to colors

required
color_palette Dict

Dictionary mapping color names to RGB values

None
layout str

Layout algorithm ("spring", "kamada_kawai", "circular")

'spring'
title str

Plot title

'Colored Graph'
save_path str

If provided, save figure to this path

None
figsize Tuple[int, int]

Figure size

(12, 10)
Source code in tissue_simulator/graph_coloring.py
def visualize_colored_graph(graph: nx.Graph, 
                           colors_map: Dict,
                           color_palette: Dict = None,
                           layout: str = "spring",
                           title: str = "Colored Graph",
                           save_path: str = None,
                           figsize: Tuple[int, int] = (12, 10)):
    """
    Visualize a colored graph.

    Args:
        graph: NetworkX graph
        colors_map: Dictionary mapping node IDs to colors
        color_palette: Dictionary mapping color names to RGB values
        layout: Layout algorithm ("spring", "kamada_kawai", "circular")
        title: Plot title
        save_path: If provided, save figure to this path
        figsize: Figure size
    """
    import matplotlib.pyplot as plt
    from ._viz_utils import make_color_map

    # Default color palette if not provided (sorted for deterministic assignment)
    if color_palette is None:
        color_palette = make_color_map(colors_map.values())

    # Compute layout
    if layout == "spring":
        pos = nx.spring_layout(graph, seed=42)
    elif layout == "kamada_kawai":
        pos = nx.kamada_kawai_layout(graph)
    elif layout == "circular":
        pos = nx.circular_layout(graph)
    else:
        raise ValueError(f"Unknown layout: {layout}")

    # Create figure
    plt.figure(figsize=figsize)

    # Draw graph
    node_colors = [color_palette.get(colors_map[node], 'gray') for node in graph.nodes()]

    nx.draw_networkx_nodes(graph, pos, node_color=node_colors,
                           node_size=500, alpha=0.8)
    nx.draw_networkx_edges(graph, pos, alpha=0.3, width=1.0)
    nx.draw_networkx_labels(graph, pos, font_size=8, font_color='white')

    # Add legend
    unique_colors = sorted(set(colors_map.values()))
    legend_elements = [
        plt.Line2D([0], [0], marker='o', color='w',
                  markerfacecolor=color_palette.get(ct, 'gray'), 
                  markersize=10, label=ct)
        for ct in unique_colors
    ]
    plt.legend(handles=legend_elements, loc='best')

    plt.title(title)
    plt.axis('off')
    plt.tight_layout()

    if save_path:
        plt.savefig(save_path, dpi=150, bbox_inches='tight')
        print(f"Saved visualization to {save_path}")

    plt.show()

visualize_graph_comparison

visualize_graph_comparison(source_graph: Graph, source_colors: Dict, target_graph: Graph, target_colors: Dict, color_palette: Dict = None, save_path: str = 'graph_comparison.png', figsize: Tuple[int, int] = (16, 8))

Visualize source and target graphs side by side.

Parameters:

Name Type Description Default
source_graph Graph

Source graph with target statistics

required
source_colors Dict

Color mapping for source graph

required
target_graph Graph

Target graph after coloring

required
target_colors Dict

Color mapping for target graph

required
color_palette Dict

Dictionary mapping color names to RGB values

None
save_path str

Path to save visualization

'graph_comparison.png'
figsize Tuple[int, int]

Figure size

(16, 8)
Source code in tissue_simulator/graph_coloring.py
def visualize_graph_comparison(source_graph: nx.Graph,
                               source_colors: Dict,
                               target_graph: nx.Graph,
                               target_colors: Dict,
                               color_palette: Dict = None,
                               save_path: str = "graph_comparison.png",
                               figsize: Tuple[int, int] = (16, 8)):
    """
    Visualize source and target graphs side by side.

    Args:
        source_graph: Source graph with target statistics
        source_colors: Color mapping for source graph
        target_graph: Target graph after coloring
        target_colors: Color mapping for target graph
        color_palette: Dictionary mapping color names to RGB values
        save_path: Path to save visualization
        figsize: Figure size
    """
    import matplotlib.pyplot as plt
    from ._viz_utils import make_color_map

    # Default color palette if not provided (sorted for deterministic assignment)
    if color_palette is None:
        color_palette = make_color_map(
            list(source_colors.values()) + list(target_colors.values())
        )

    plt.figure(figsize=figsize)

    # Source Graph
    plt.subplot(1, 2, 1)
    source_node_colors = [color_palette.get(source_colors[node], 'gray') 
                         for node in source_graph.nodes()]
    pos_source = nx.spring_layout(source_graph, seed=42)
    nx.draw(source_graph, pos_source, with_labels=True, 
           node_color=source_node_colors, node_size=500, font_color='white')
    plt.title("Source Graph (Target Statistics)")

    # Target Graph
    plt.subplot(1, 2, 2)
    target_node_colors = [color_palette.get(target_colors[node], 'gray') 
                         for node in target_graph.nodes()]
    pos_target = nx.spring_layout(target_graph, seed=42)
    nx.draw(target_graph, pos_target, with_labels=True, 
           node_color=target_node_colors, node_size=500, font_color='white')
    plt.title("Target Graph with Imposed Statistics")

    plt.tight_layout()
    plt.savefig(save_path, dpi=150, bbox_inches='tight')
    print(f"\nSaved comparison visualization to {save_path}")
    plt.show()