#!/usr/bin/env python3 """ visualization.py — Publication-quality figure generation for academic fraud detection reports. Part of the Geng Skill academic fraud detection project. Generates visualizations for statistical tests including last-digit analysis, Benford's Law, fixed-ratio detection, decimal pattern analysis, and comprehensive dashboards. Usage (CLI): python3 visualization.py --input report.json --output figures/ Usage (API): from visualization import plot_last_digit, plot_benford, plot_fixed_ratio fig_path = plot_last_digit(test_result, output_dir="figures/") """ import json import argparse import os import sys from pathlib import Path from typing import Dict, Any, Optional, List, Tuple import numpy as np import matplotlib matplotlib.use("Agg") # Non-interactive backend for server environments import matplotlib.pyplot as plt import matplotlib.patches as mpatches from matplotlib.patches import FancyBboxPatch, Arc, Wedge from matplotlib.colors import LinearSegmentedColormap import matplotlib.ticker as mticker import seaborn as sns # --------------------------------------------------------------------------- # Font Configuration for CJK (Chinese) + English support # --------------------------------------------------------------------------- def _configure_fonts(): """Configure matplotlib to support CJK characters with fallback.""" # Try common CJK fonts available on Linux/macOS/Windows cjk_fonts = [ "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Noto Sans CJK SC", "SimHei", "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "DejaVu Sans", ] available_fonts = set( f.name for f in matplotlib.font_manager.fontManager.ttflist ) chosen_fonts = [] for font in cjk_fonts: if font in available_fonts: chosen_fonts.append(font) # Fallback: always include DejaVu Sans for guaranteed rendering if "DejaVu Sans" not in chosen_fonts: chosen_fonts.append("DejaVu Sans") plt.rcParams["font.sans-serif"] = chosen_fonts + plt.rcParams.get("font.sans-serif", []) plt.rcParams["axes.unicode_minus"] = False plt.rcParams["font.family"] = "sans-serif" _configure_fonts() # --------------------------------------------------------------------------- # Style Configuration # --------------------------------------------------------------------------- # Publication-quality defaults STYLE_CONFIG = { "figure.dpi": 300, "savefig.dpi": 300, "savefig.bbox": "tight", "axes.spines.top": False, "axes.spines.right": False, "axes.labelsize": 11, "axes.titlesize": 13, "xtick.labelsize": 9, "ytick.labelsize": 9, "legend.fontsize": 9, "figure.facecolor": "white", "axes.facecolor": "white", "axes.grid": False, } plt.rcParams.update(STYLE_CONFIG) # Color palette COLORS = { "observed": "#2196F3", # Blue "expected": "#FF9800", # Orange "highlight": "#F44336", # Red (anomaly) "normal": "#4CAF50", # Green (normal) "neutral": "#9E9E9E", # Gray "regression": "#E91E63", # Pink "scatter": "#3F51B5", # Indigo "risk_low": "#4CAF50", # Green "risk_medium": "#FFC107", # Amber "risk_high": "#FF5722", # Deep Orange "risk_critical": "#D32F2F", # Dark Red } # Risk level thresholds RISK_THRESHOLDS = { "low": (0, 25), "medium": (25, 50), "high": (50, 75), "critical": (75, 100), } # --------------------------------------------------------------------------- # Utility Functions # --------------------------------------------------------------------------- def _ensure_output_dir(output_dir: str) -> Path: """Create output directory if it doesn't exist.""" path = Path(output_dir) path.mkdir(parents=True, exist_ok=True) return path def _save_figure(fig: plt.Figure, filepath: str, dpi: int = 300) -> str: """Save figure to file and close it. Args: fig: Matplotlib figure object. filepath: Output file path. dpi: Resolution in dots per inch. Returns: Absolute path of the saved figure. """ fig.savefig(filepath, dpi=dpi, bbox_inches="tight", facecolor="white") plt.close(fig) return str(Path(filepath).resolve()) def _annotate_pvalue(ax: plt.Axes, p_value: float, x: float = 0.95, y: float = 0.95): """Add p-value annotation to axes with significance stars.""" if p_value < 0.001: stars = "***" color = COLORS["highlight"] elif p_value < 0.01: stars = "**" color = COLORS["risk_high"] elif p_value < 0.05: stars = "*" color = COLORS["risk_medium"] else: stars = "ns" color = COLORS["normal"] text = f"p = {p_value:.4f} {stars}" ax.annotate( text, xy=(x, y), xycoords="axes fraction", fontsize=10, fontweight="bold", color=color, ha="right", va="top", bbox=dict(boxstyle="round,pad=0.3", facecolor="lightyellow", alpha=0.8), ) # --------------------------------------------------------------------------- # 1. Last Digit Distribution Plot # --------------------------------------------------------------------------- def plot_last_digit( result: Dict[str, Any], output_dir: str = "figures/", filename: str = "last_digit_distribution.png", dpi: int = 300, ) -> str: """Generate a bar chart of last digit distribution vs. expected uniform distribution. Shows observed frequencies of last digits (0-9) compared to the expected uniform distribution (10% each), with chi-square test p-value annotation. Args: result: Output dict from last-digit test module, expected keys: - observed_freq (list/dict): Observed frequencies for digits 0-9. - expected_freq (list/dict, optional): Expected frequencies. - chi_square (float): Chi-square statistic. - p_value (float): P-value from chi-square test. - n_samples (int, optional): Total sample count. - column_name (str, optional): Name of the analyzed column. output_dir: Directory to save the figure. filename: Output filename. dpi: Resolution. Returns: Path to the saved figure file. """ output_path = _ensure_output_dir(output_dir) filepath = str(output_path / filename) # Extract data observed = result.get("observed_freq", result.get("observed", [])) if isinstance(observed, dict): digits = sorted(observed.keys(), key=lambda x: int(x)) obs_values = [observed[d] for d in digits] digits = [int(d) for d in digits] else: obs_values = list(observed) digits = list(range(len(obs_values))) # Normalize to proportions if raw counts obs_array = np.array(obs_values, dtype=float) if obs_array.sum() > 1.1: # Raw counts, convert to proportions obs_array = obs_array / obs_array.sum() n_digits = len(digits) expected_prop = 1.0 / n_digits # Uniform expectation chi_sq = result.get("chi_square", result.get("chi2", 0.0)) p_value = result.get("p_value", result.get("pvalue", 1.0)) n_samples = result.get("n_samples", result.get("n", "N/A")) col_name = result.get("column_name", result.get("column", "")) # Create figure fig, ax = plt.subplots(figsize=(8, 5)) x = np.arange(n_digits) width = 0.35 # Bar chart bars_obs = ax.bar( x - width / 2, obs_array, width, label="观测频率 Observed", color=COLORS["observed"], alpha=0.85, edgecolor="white" ) bars_exp = ax.bar( x + width / 2, [expected_prop] * n_digits, width, label=f"期望频率 Expected ({expected_prop:.1%})", color=COLORS["expected"], alpha=0.6, edgecolor="white" ) # Reference line ax.axhline(y=expected_prop, color=COLORS["neutral"], linestyle="--", linewidth=0.8, alpha=0.6) # Highlight anomalous digits (>2 standard deviations from expected) std_threshold = 2.0 * np.sqrt(expected_prop * (1 - expected_prop) / max(n_samples if isinstance(n_samples, (int, float)) else 100, 1)) for i, (obs_val, bar) in enumerate(zip(obs_array, bars_obs)): if abs(obs_val - expected_prop) > std_threshold: bar.set_edgecolor(COLORS["highlight"]) bar.set_linewidth(2) # Labels and title title = "末位数字分布检验 Last Digit Distribution Test" if col_name: title += f"\n[{col_name}]" ax.set_title(title, fontsize=13, fontweight="bold", pad=15) ax.set_xlabel("末位数字 Last Digit", fontsize=11) ax.set_ylabel("频率 Frequency", fontsize=11) ax.set_xticks(x) ax.set_xticklabels(digits) ax.set_ylim(0, max(obs_array.max(), expected_prop) * 1.35) ax.legend(loc="upper left", framealpha=0.9) # Annotate statistics _annotate_pvalue(ax, p_value) stats_text = f"χ² = {chi_sq:.2f}\nn = {n_samples}" ax.text( 0.95, 0.80, stats_text, transform=ax.transAxes, fontsize=9, ha="right", va="top", color="#555555", ) return _save_figure(fig, filepath, dpi) # --------------------------------------------------------------------------- # 2. Benford's Law Plot # --------------------------------------------------------------------------- def plot_benford( result: Dict[str, Any], output_dir: str = "figures/", filename: str = "benford_law.png", dpi: int = 300, ) -> str: """Generate a bar chart comparing observed first-digit frequencies to Benford's Law. Shows observed vs. theoretical Benford distribution with MAD (Mean Absolute Deviation) annotation and conformity assessment. Args: result: Output dict from Benford's Law test module, expected keys: - observed_freq (list/dict): Observed proportions for digits 1-9. - benford_freq (list/dict, optional): Theoretical Benford proportions. - mad (float): Mean Absolute Deviation from Benford's Law. - conformity (str, optional): Conformity level (e.g., "close", "acceptable"). - chi_square (float, optional): Chi-square statistic. - p_value (float, optional): P-value. - column_name (str, optional): Name of the analyzed column. output_dir: Directory to save the figure. filename: Output filename. dpi: Resolution. Returns: Path to the saved figure file. """ output_path = _ensure_output_dir(output_dir) filepath = str(output_path / filename) # Benford's theoretical distribution benford_theoretical = { d: np.log10(1 + 1 / d) for d in range(1, 10) } # Extract observed data observed = result.get("observed_freq", result.get("observed", {})) if isinstance(observed, (list, np.ndarray)): obs_values = list(observed) digits = list(range(1, len(obs_values) + 1)) else: digits = sorted([int(k) for k in observed.keys()]) obs_values = [observed[str(d)] if str(d) in observed else observed.get(d, 0) for d in digits] obs_array = np.array(obs_values, dtype=float) if obs_array.sum() > 1.1: obs_array = obs_array / obs_array.sum() # Benford expected benford_expected = np.array([benford_theoretical.get(d, 0) for d in digits]) mad = result.get("mad", result.get("MAD", np.mean(np.abs(obs_array - benford_expected)))) conformity = result.get("conformity", result.get("level", "")) p_value = result.get("p_value", result.get("pvalue", None)) col_name = result.get("column_name", result.get("column", "")) # Create figure fig, ax = plt.subplots(figsize=(9, 5.5)) x = np.arange(len(digits)) width = 0.35 # Bars ax.bar( x - width / 2, obs_array, width, label="观测频率 Observed", color=COLORS["observed"], alpha=0.85, edgecolor="white" ) ax.bar( x + width / 2, benford_expected, width, label="Benford 理论值 Expected", color=COLORS["expected"], alpha=0.7, edgecolor="white" ) # Benford curve overlay ax.plot(x, benford_expected, "o-", color=COLORS["expected"], alpha=0.9, linewidth=1.5, markersize=4) # Title and labels title = "Benford 定律检验 Benford's Law Test" if col_name: title += f"\n[{col_name}]" ax.set_title(title, fontsize=13, fontweight="bold", pad=15) ax.set_xlabel("首位数字 First Digit", fontsize=11) ax.set_ylabel("频率 Frequency", fontsize=11) ax.set_xticks(x) ax.set_xticklabels(digits) ax.set_ylim(0, max(obs_array.max(), benford_expected.max()) * 1.35) ax.legend(loc="upper right", framealpha=0.9) # MAD annotation with color coding # MAD thresholds (Nigrini 2012): <0.006 close, <0.012 acceptable, <0.015 marginally acceptable if mad < 0.006: mad_color = COLORS["normal"] mad_label = "Close conformity" elif mad < 0.012: mad_color = COLORS["risk_medium"] mad_label = "Acceptable conformity" elif mad < 0.015: mad_color = COLORS["risk_high"] mad_label = "Marginally acceptable" else: mad_color = COLORS["highlight"] mad_label = "Non-conformity" if conformity: mad_label = conformity mad_text = f"MAD = {mad:.4f}\n({mad_label})" ax.annotate( mad_text, xy=(0.02, 0.95), xycoords="axes fraction", fontsize=10, fontweight="bold", color=mad_color, ha="left", va="top", bbox=dict(boxstyle="round,pad=0.4", facecolor="lightyellow", edgecolor=mad_color, alpha=0.9), ) # P-value if available if p_value is not None: _annotate_pvalue(ax, p_value, x=0.98, y=0.95) return _save_figure(fig, filepath, dpi) # --------------------------------------------------------------------------- # 3. Fixed Ratio Scatter Plot # --------------------------------------------------------------------------- def plot_fixed_ratio( result: Dict[str, Any], output_dir: str = "figures/", filename: str = "fixed_ratio_scatter.png", dpi: int = 300, ) -> str: """Generate a scatter plot with regression line for fixed-ratio detection. Visualizes the relationship between two numeric columns, annotating R², slope, and whether the ratio appears suspiciously fixed (unnaturally high R²). Args: result: Output dict from fixed-ratio test module, expected keys: - x_values (list): Values of the first column. - y_values (list): Values of the second column. - r_squared (float): R² value of linear fit. - slope (float): Slope of regression line. - intercept (float): Intercept of regression line. - is_fixed (bool): Whether ratio is deemed suspiciously fixed. - threshold (float, optional): R² threshold used for detection. - x_column (str, optional): Name of x column. - y_column (str, optional): Name of y column. - p_value (float, optional): P-value for the regression. output_dir: Directory to save the figure. filename: Output filename. dpi: Resolution. Returns: Path to the saved figure file. """ output_path = _ensure_output_dir(output_dir) filepath = str(output_path / filename) # Extract data x_vals = np.array(result.get("x_values", result.get("x", [])), dtype=float) y_vals = np.array(result.get("y_values", result.get("y", [])), dtype=float) r_squared = result.get("r_squared", result.get("r2", 0.0)) slope = result.get("slope", 0.0) intercept = result.get("intercept", 0.0) is_fixed = result.get("is_fixed", result.get("fixed", False)) x_col = result.get("x_column", result.get("x_col", "X")) y_col = result.get("y_column", result.get("y_col", "Y")) threshold = result.get("threshold", 0.99) # Create figure fig, ax = plt.subplots(figsize=(7, 6)) # Scatter plot scatter_color = COLORS["highlight"] if is_fixed else COLORS["scatter"] ax.scatter( x_vals, y_vals, c=scatter_color, alpha=0.5, s=30, edgecolors="white", linewidth=0.3, label="数据点 Data points" ) # Regression line if len(x_vals) > 1: x_fit = np.linspace(x_vals.min(), x_vals.max(), 100) y_fit = slope * x_fit + intercept ax.plot( x_fit, y_fit, color=COLORS["regression"], linewidth=2, linestyle="-", label=f"回归线 y = {slope:.4f}x + {intercept:.4f}" ) # Title status_str = "⚠️ 比值固定 FIXED" if is_fixed else "✓ 正常 NORMAL" title = f"固定比值检测 Fixed Ratio Detection\n{status_str}" title_color = COLORS["highlight"] if is_fixed else COLORS["normal"] ax.set_title(title, fontsize=13, fontweight="bold", pad=15, color=title_color) ax.set_xlabel(f"{x_col}", fontsize=11) ax.set_ylabel(f"{y_col}", fontsize=11) ax.legend(loc="lower right", framealpha=0.9) # Stats annotation box stats_lines = [ f"R² = {r_squared:.6f}", f"Slope = {slope:.4f}", f"Intercept = {intercept:.4f}", f"Threshold = {threshold}", f"判定 Verdict: {'固定 Fixed' if is_fixed else '正常 Normal'}", ] stats_text = "\n".join(stats_lines) box_color = "#FFEBEE" if is_fixed else "#E8F5E9" border_color = COLORS["highlight"] if is_fixed else COLORS["normal"] ax.text( 0.03, 0.97, stats_text, transform=ax.transAxes, fontsize=9, verticalalignment="top", fontfamily="monospace", bbox=dict(boxstyle="round,pad=0.5", facecolor=box_color, edgecolor=border_color, alpha=0.9), ) return _save_figure(fig, filepath, dpi) # --------------------------------------------------------------------------- # 4. Decimal Pattern Heatmap # --------------------------------------------------------------------------- def plot_decimal_heatmap( result: Dict[str, Any], output_dir: str = "figures/", filename: str = "decimal_pattern_heatmap.png", dpi: int = 300, ) -> str: """Generate a heatmap showing digit frequency at each decimal position. Visualizes the distribution of digits (0-9) at each decimal place, highlighting positions with anomalously uniform or non-uniform patterns. Args: result: Output dict from decimal pattern test module, expected keys: - frequency_matrix (list[list] or dict): Digit frequencies per position. Shape: (n_positions, 10) where columns are digits 0-9. - positions (list, optional): Labels for decimal positions. - anomalous_positions (list, optional): Positions flagged as anomalous. - column_name (str, optional): Name of analyzed column. - p_values (list, optional): Per-position p-values. output_dir: Directory to save the figure. filename: Output filename. dpi: Resolution. Returns: Path to the saved figure file. """ output_path = _ensure_output_dir(output_dir) filepath = str(output_path / filename) # Extract frequency matrix freq_matrix = result.get("frequency_matrix", result.get("matrix", [])) if isinstance(freq_matrix, dict): positions = sorted(freq_matrix.keys(), key=lambda x: int(x) if str(x).isdigit() else 0) matrix = np.array([freq_matrix[p] for p in positions], dtype=float) else: matrix = np.array(freq_matrix, dtype=float) positions = result.get("positions", [f"Pos {i+1}" for i in range(matrix.shape[0])]) # Normalize rows to proportions row_sums = matrix.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # Avoid division by zero matrix_prop = matrix / row_sums anomalous = set(result.get("anomalous_positions", result.get("anomalous", []))) col_name = result.get("column_name", result.get("column", "")) # Create figure n_positions = matrix_prop.shape[0] fig_height = max(4, n_positions * 0.5 + 2) fig, ax = plt.subplots(figsize=(10, fig_height)) # Heatmap with diverging colormap centered on 0.1 (uniform expectation) vmin = 0.0 vmax = max(0.25, matrix_prop.max()) # Custom colormap: green (under) -> white (expected=0.1) -> red (over) cmap = sns.diverging_palette(145, 10, s=80, l=55, as_cmap=True) # Plot heatmap with deviation from expected (0.1) deviation = matrix_prop - 0.1 sns.heatmap( deviation, ax=ax, cmap=cmap, center=0, vmin=-0.15, vmax=0.15, annot=matrix_prop, fmt=".3f", linewidths=0.5, linecolor="white", xticklabels=[str(d) for d in range(10)], yticklabels=[str(p) for p in positions], cbar_kws={"label": "偏差 Deviation from expected (0.1)", "shrink": 0.8}, ) # Highlight anomalous rows for i, pos in enumerate(positions): if pos in anomalous or i in anomalous or str(pos) in [str(a) for a in anomalous]: ax.add_patch(plt.Rectangle((0, i), 10, 1, fill=False, edgecolor=COLORS["highlight"], linewidth=2.5)) # Title and labels title = "小数位数字模式热图 Decimal Pattern Heatmap" if col_name: title += f"\n[{col_name}]" ax.set_title(title, fontsize=13, fontweight="bold", pad=15) ax.set_xlabel("数字 Digit", fontsize=11) ax.set_ylabel("小数位置 Decimal Position", fontsize=11) # Annotation for anomalous positions if anomalous: ax.text( 1.02, 0.02, f"⚠ 异常位置: {len(anomalous)}", transform=ax.transAxes, fontsize=9, color=COLORS["highlight"], fontweight="bold", va="bottom", ) plt.tight_layout() return _save_figure(fig, filepath, dpi) # --------------------------------------------------------------------------- # 5. Comprehensive Dashboard # --------------------------------------------------------------------------- def plot_dashboard( report: Dict[str, Any], output_dir: str = "figures/", filename: str = "comprehensive_dashboard.png", dpi: int = 300, ) -> str: """Generate a multi-panel comprehensive dashboard combining all tests. Creates a publication-ready figure with subplots for each statistical test, plus an overall risk assessment panel. Suitable for report inclusion. Args: report: Full report dict containing results from all tests, expected keys: - last_digit (dict): Last digit test results. - benford (dict): Benford's Law test results. - fixed_ratio (dict, optional): Fixed ratio test results. - decimal_pattern (dict, optional): Decimal pattern test results. - risk_score (float): Overall risk score 0-100. - summary (dict, optional): Summary statistics. - dataset_name (str, optional): Name of the dataset. output_dir: Directory to save the figure. filename: Output filename. dpi: Resolution. Returns: Path to the saved figure file. """ output_path = _ensure_output_dir(output_dir) filepath = str(output_path / filename) # Determine layout based on available tests has_last_digit = "last_digit" in report has_benford = "benford" in report has_fixed_ratio = "fixed_ratio" in report has_decimal = "decimal_pattern" in report has_risk = "risk_score" in report # Create figure with GridSpec fig = plt.figure(figsize=(16, 12)) gs = fig.add_gridspec(3, 3, hspace=0.4, wspace=0.35) dataset_name = report.get("dataset_name", report.get("name", "Unknown Dataset")) fig.suptitle( f"学术数据异常检测综合报告 Fraud Detection Dashboard\n{dataset_name}", fontsize=15, fontweight="bold", y=0.98, ) # Panel 1: Last Digit Distribution (top-left) if has_last_digit: ax1 = fig.add_subplot(gs[0, 0]) _draw_last_digit_panel(ax1, report["last_digit"]) # Panel 2: Benford's Law (top-center) if has_benford: ax2 = fig.add_subplot(gs[0, 1]) _draw_benford_panel(ax2, report["benford"]) # Panel 3: Risk Score Gauge (top-right) if has_risk: ax3 = fig.add_subplot(gs[0, 2]) _draw_risk_gauge_panel(ax3, report["risk_score"]) # Panel 4: Fixed Ratio (middle-left) if has_fixed_ratio: ax4 = fig.add_subplot(gs[1, 0]) _draw_fixed_ratio_panel(ax4, report["fixed_ratio"]) # Panel 5: Decimal Pattern (middle-center + right) if has_decimal: ax5 = fig.add_subplot(gs[1, 1:]) _draw_decimal_panel(ax5, report["decimal_pattern"]) # Panel 6: Summary table (bottom row) ax6 = fig.add_subplot(gs[2, :]) _draw_summary_panel(ax6, report) return _save_figure(fig, filepath, dpi) def _draw_last_digit_panel(ax: plt.Axes, data: Dict): """Draw last digit distribution as a mini panel.""" observed = data.get("observed_freq", data.get("observed", [])) if isinstance(observed, dict): digits = sorted(observed.keys(), key=lambda x: int(x)) obs_values = np.array([observed[d] for d in digits], dtype=float) else: obs_values = np.array(observed, dtype=float) digits = list(range(len(obs_values))) if obs_values.sum() > 1.1: obs_values = obs_values / obs_values.sum() n = len(digits) expected = 1.0 / n colors = [COLORS["highlight"] if abs(v - expected) > 0.05 else COLORS["observed"] for v in obs_values] ax.bar(range(n), obs_values, color=colors, alpha=0.8, edgecolor="white") ax.axhline(expected, color=COLORS["expected"], linestyle="--", linewidth=1) ax.set_title("末位数字 Last Digit", fontsize=10, fontweight="bold") ax.set_xlabel("Digit", fontsize=8) ax.set_ylabel("Freq", fontsize=8) ax.set_xticks(range(n)) ax.set_xticklabels(digits, fontsize=7) p_val = data.get("p_value", data.get("pvalue", None)) if p_val is not None: color = COLORS["highlight"] if p_val < 0.05 else COLORS["normal"] ax.text(0.95, 0.9, f"p={p_val:.3f}", transform=ax.transAxes, fontsize=8, ha="right", color=color, fontweight="bold") def _draw_benford_panel(ax: plt.Axes, data: Dict): """Draw Benford's Law comparison as a mini panel.""" observed = data.get("observed_freq", data.get("observed", {})) if isinstance(observed, (list, np.ndarray)): obs_values = np.array(observed, dtype=float) else: obs_values = np.array([observed.get(str(d), observed.get(d, 0)) for d in range(1, 10)], dtype=float) if obs_values.sum() > 1.1: obs_values = obs_values / obs_values.sum() benford = np.array([np.log10(1 + 1/d) for d in range(1, 10)]) x = np.arange(9) ax.bar(x - 0.15, obs_values, 0.3, label="Obs", color=COLORS["observed"], alpha=0.8) ax.bar(x + 0.15, benford, 0.3, label="Benford", color=COLORS["expected"], alpha=0.6) ax.set_title("Benford 定律", fontsize=10, fontweight="bold") ax.set_xlabel("First Digit", fontsize=8) ax.set_xticks(x) ax.set_xticklabels(range(1, 10), fontsize=7) ax.legend(fontsize=7, loc="upper right") mad = data.get("mad", data.get("MAD", 0)) color = COLORS["highlight"] if mad > 0.015 else COLORS["normal"] ax.text(0.95, 0.9, f"MAD={mad:.4f}", transform=ax.transAxes, fontsize=8, ha="right", color=color, fontweight="bold") def _draw_risk_gauge_panel(ax: plt.Axes, risk_score: float): """Draw a mini risk gauge.""" ax.set_xlim(-1.2, 1.2) ax.set_ylim(-0.3, 1.2) ax.set_aspect("equal") ax.axis("off") # Draw gauge arc segments angles = np.linspace(180, 0, 100) for i in range(len(angles) - 1): frac = i / (len(angles) - 1) if frac < 0.25: color = COLORS["risk_low"] elif frac < 0.5: color = COLORS["risk_medium"] elif frac < 0.75: color = COLORS["risk_high"] else: color = COLORS["risk_critical"] theta1 = angles[i + 1] theta2 = angles[i] wedge = Wedge((0, 0), 1.0, theta1, theta2, width=0.3, facecolor=color, alpha=0.7) ax.add_patch(wedge) # Needle needle_angle = 180 - (risk_score / 100) * 180 needle_rad = np.radians(needle_angle) needle_x = 0.75 * np.cos(needle_rad) needle_y = 0.75 * np.sin(needle_rad) ax.annotate( "", xy=(needle_x, needle_y), xytext=(0, 0), arrowprops=dict(arrowstyle="-|>", color="#333333", lw=2), ) ax.plot(0, 0, "o", color="#333333", markersize=6) # Score text if risk_score >= 75: score_color = COLORS["risk_critical"] elif risk_score >= 50: score_color = COLORS["risk_high"] elif risk_score >= 25: score_color = COLORS["risk_medium"] else: score_color = COLORS["risk_low"] ax.text(0, -0.2, f"{risk_score:.0f}", fontsize=20, fontweight="bold", ha="center", color=score_color) ax.set_title("风险评分 Risk Score", fontsize=10, fontweight="bold", pad=5) def _draw_fixed_ratio_panel(ax: plt.Axes, data: Dict): """Draw fixed ratio scatter as a mini panel.""" x_vals = np.array(data.get("x_values", data.get("x", [])), dtype=float) y_vals = np.array(data.get("y_values", data.get("y", [])), dtype=float) r_sq = data.get("r_squared", data.get("r2", 0)) is_fixed = data.get("is_fixed", data.get("fixed", False)) color = COLORS["highlight"] if is_fixed else COLORS["scatter"] if len(x_vals) > 0 and len(y_vals) > 0: ax.scatter(x_vals, y_vals, c=color, alpha=0.4, s=15, edgecolors="none") # Regression line if len(x_vals) > 1: slope = data.get("slope", 0) intercept = data.get("intercept", 0) x_fit = np.linspace(x_vals.min(), x_vals.max(), 50) ax.plot(x_fit, slope * x_fit + intercept, color=COLORS["regression"], linewidth=1.5) status = "⚠ FIXED" if is_fixed else "✓ Normal" ax.set_title(f"固定比值 {status}", fontsize=10, fontweight="bold", color=color) ax.text(0.05, 0.9, f"R²={r_sq:.4f}", transform=ax.transAxes, fontsize=8, fontweight="bold") def _draw_decimal_panel(ax: plt.Axes, data: Dict): """Draw decimal pattern heatmap as a mini panel.""" freq_matrix = data.get("frequency_matrix", data.get("matrix", [])) if isinstance(freq_matrix, dict): positions = sorted(freq_matrix.keys(), key=lambda x: int(x) if str(x).isdigit() else 0) matrix = np.array([freq_matrix[p] for p in positions], dtype=float) else: matrix = np.array(freq_matrix, dtype=float) positions = data.get("positions", [f"P{i+1}" for i in range(matrix.shape[0])]) if matrix.size == 0: ax.text(0.5, 0.5, "No decimal data", ha="center", va="center", transform=ax.transAxes) ax.set_title("小数模式 Decimal Pattern", fontsize=10, fontweight="bold") return row_sums = matrix.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 matrix_prop = matrix / row_sums sns.heatmap( matrix_prop, ax=ax, cmap="YlOrRd", annot=True if matrix_prop.shape[0] <= 5 else False, fmt=".2f", linewidths=0.3, xticklabels=[str(d) for d in range(10)], yticklabels=[str(p) for p in positions], cbar_kws={"shrink": 0.7}, ) ax.set_title("小数模式 Decimal Pattern", fontsize=10, fontweight="bold") ax.set_xlabel("Digit", fontsize=8) ax.set_ylabel("Position", fontsize=8) def _draw_summary_panel(ax: plt.Axes, report: Dict): """Draw a summary table panel.""" ax.axis("off") # Build summary rows rows = [] headers = ["检测项目 Test", "结果 Result", "指标 Metric", "判定 Verdict"] if "last_digit" in report: ld = report["last_digit"] p_val = ld.get("p_value", ld.get("pvalue", "N/A")) verdict = "⚠ 异常" if (isinstance(p_val, (int, float)) and p_val < 0.05) else "✓ 正常" rows.append(["末位数字 Last Digit", f"χ²={ld.get('chi_square', ld.get('chi2', 'N/A')):.2f}" if isinstance(ld.get('chi_square', ld.get('chi2')), (int, float)) else "N/A", f"p={p_val:.4f}" if isinstance(p_val, (int, float)) else str(p_val), verdict]) if "benford" in report: bf = report["benford"] mad = bf.get("mad", bf.get("MAD", "N/A")) verdict = "⚠ 异常" if (isinstance(mad, (int, float)) and mad > 0.015) else "✓ 正常" rows.append(["Benford 定律", f"MAD={mad:.4f}" if isinstance(mad, (int, float)) else str(mad), bf.get("conformity", ""), verdict]) if "fixed_ratio" in report: fr = report["fixed_ratio"] is_fixed = fr.get("is_fixed", fr.get("fixed", False)) r2 = fr.get("r_squared", fr.get("r2", "N/A")) verdict = "⚠ 固定" if is_fixed else "✓ 正常" rows.append(["固定比值 Fixed Ratio", f"R²={r2:.6f}" if isinstance(r2, (int, float)) else str(r2), f"slope={fr.get('slope', 'N/A')}", verdict]) if "decimal_pattern" in report: dp = report["decimal_pattern"] n_anomalous = len(dp.get("anomalous_positions", dp.get("anomalous", []))) verdict = f"⚠ {n_anomalous}处异常" if n_anomalous > 0 else "✓ 正常" rows.append(["小数模式 Decimal", f"异常位置: {n_anomalous}", "", verdict]) if rows: table = ax.table( cellText=rows, colLabels=headers, cellLoc="center", loc="center", colWidths=[0.25, 0.25, 0.25, 0.25], ) table.auto_set_font_size(False) table.set_fontsize(9) table.scale(1.0, 1.5) # Style header for j in range(len(headers)): table[0, j].set_facecolor("#1976D2") table[0, j].set_text_props(color="white", fontweight="bold") # Color verdict cells (last column = index 3) n_cols = len(headers) for i, row in enumerate(rows): if "⚠" in row[-1]: table[i + 1, n_cols - 1].set_facecolor("#FFEBEE") else: table[i + 1, n_cols - 1].set_facecolor("#E8F5E9") risk = report.get("risk_score", None) if risk is not None: ax.set_title( f"综合评估 Overall Assessment | 风险评分 Risk Score: {risk:.0f}/100", fontsize=11, fontweight="bold", pad=10, ) # --------------------------------------------------------------------------- # 6. Risk Score Gauge # --------------------------------------------------------------------------- def plot_risk_gauge( risk_score: float, output_dir: str = "figures/", filename: str = "risk_score_gauge.png", dpi: int = 300, label: str = "", ) -> str: """Generate a semi-circular gauge showing overall risk score (0-100). Creates a visually informative gauge with color gradient from green (low risk) through yellow/orange to red (high risk), with a needle indicating the score. Args: risk_score: Overall risk score between 0 and 100. output_dir: Directory to save the figure. filename: Output filename. dpi: Resolution. label: Optional label/dataset name to display. Returns: Path to the saved figure file. """ output_path = _ensure_output_dir(output_dir) filepath = str(output_path / filename) risk_score = float(np.clip(risk_score, 0, 100)) fig, ax = plt.subplots(figsize=(8, 5)) ax.set_xlim(-1.5, 1.5) ax.set_ylim(-0.5, 1.5) ax.set_aspect("equal") ax.axis("off") # Draw gauge background arc with color gradient n_segments = 200 angles = np.linspace(180, 0, n_segments + 1) for i in range(n_segments): frac = i / n_segments # Color interpolation: green -> yellow -> orange -> red if frac < 0.25: r, g, b = 0.30, 0.69, 0.31 # Green f = frac / 0.25 r = r + f * (1.0 - r) g = g + f * (0.76 - g) b = b + f * (0.03 - b) elif frac < 0.5: f = (frac - 0.25) / 0.25 r, g, b = 1.0, 0.76 - f * 0.13, 0.03 elif frac < 0.75: f = (frac - 0.5) / 0.25 r, g, b = 1.0 - f * 0.04, 0.63 - f * 0.29, 0.03 + f * 0.10 else: f = (frac - 0.75) / 0.25 r, g, b = 0.96 - f * 0.13, 0.34 - f * 0.15, 0.13 + f * 0.06 theta1 = angles[i + 1] theta2 = angles[i] wedge = Wedge((0, 0), 1.2, theta1, theta2, width=0.35, facecolor=(r, g, b), alpha=0.85) ax.add_patch(wedge) # Inner white circle for clean look inner_circle = plt.Circle((0, 0), 0.82, color="white", zorder=2) ax.add_patch(inner_circle) # Tick marks and labels tick_values = [0, 25, 50, 75, 100] tick_labels = ["0\n安全", "25\n低风险", "50\n中风险", "75\n高风险", "100\n极高"] for val, lbl in zip(tick_values, tick_labels): angle_rad = np.radians(180 - val / 100 * 180) # Outer tick x_outer = 1.28 * np.cos(angle_rad) y_outer = 1.28 * np.sin(angle_rad) x_inner = 1.18 * np.cos(angle_rad) y_inner = 1.18 * np.sin(angle_rad) ax.plot([x_inner, x_outer], [y_inner, y_outer], color="#333", linewidth=1.5) # Label x_label = 1.42 * np.cos(angle_rad) y_label = 1.42 * np.sin(angle_rad) ax.text(x_label, y_label, lbl, ha="center", va="center", fontsize=7, color="#555") # Needle needle_angle = np.radians(180 - (risk_score / 100) * 180) needle_length = 0.78 needle_x = needle_length * np.cos(needle_angle) needle_y = needle_length * np.sin(needle_angle) # Needle triangle (wider base) base_angle1 = needle_angle + np.pi / 2 base_angle2 = needle_angle - np.pi / 2 base_r = 0.04 triangle = plt.Polygon([ [needle_x, needle_y], [base_r * np.cos(base_angle1), base_r * np.sin(base_angle1)], [base_r * np.cos(base_angle2), base_r * np.sin(base_angle2)], ], closed=True, facecolor="#333333", zorder=5) ax.add_patch(triangle) # Center dot center_circle = plt.Circle((0, 0), 0.06, color="#333333", zorder=6) ax.add_patch(center_circle) # Score display if risk_score >= 75: score_color = COLORS["risk_critical"] risk_label = "极高风险 Critical Risk" elif risk_score >= 50: score_color = COLORS["risk_high"] risk_label = "高风险 High Risk" elif risk_score >= 25: score_color = COLORS["risk_medium"] risk_label = "中等风险 Medium Risk" else: score_color = COLORS["risk_low"] risk_label = "低风险 Low Risk" ax.text(0, -0.15, f"{risk_score:.0f}", fontsize=32, fontweight="bold", ha="center", va="center", color=score_color, zorder=7) ax.text(0, -0.35, risk_label, fontsize=11, ha="center", va="center", color=score_color, fontweight="bold") # Title title = "学术数据风险评分 Academic Data Risk Score" if label: title += f"\n{label}" ax.set_title(title, fontsize=13, fontweight="bold", pad=20, y=1.0) return _save_figure(fig, filepath, dpi) # --------------------------------------------------------------------------- # CLI Interface # --------------------------------------------------------------------------- def generate_all_figures(report: Dict[str, Any], output_dir: str = "figures/") -> Dict[str, str]: """Generate all available figures from a complete report. Args: report: Full report dict containing results from all tests. output_dir: Directory to save all figures. Returns: Dictionary mapping figure type to saved file path. """ figures = {} if "last_digit" in report: try: path = plot_last_digit(report["last_digit"], output_dir=output_dir) figures["last_digit"] = path print(f" ✓ Last digit plot: {path}") except Exception as e: print(f" ✗ Last digit plot failed: {e}", file=sys.stderr) if "benford" in report: try: path = plot_benford(report["benford"], output_dir=output_dir) figures["benford"] = path print(f" ✓ Benford plot: {path}") except Exception as e: print(f" ✗ Benford plot failed: {e}", file=sys.stderr) if "fixed_ratio" in report: try: path = plot_fixed_ratio(report["fixed_ratio"], output_dir=output_dir) figures["fixed_ratio"] = path print(f" ✓ Fixed ratio plot: {path}") except Exception as e: print(f" ✗ Fixed ratio plot failed: {e}", file=sys.stderr) if "decimal_pattern" in report: try: path = plot_decimal_heatmap(report["decimal_pattern"], output_dir=output_dir) figures["decimal_heatmap"] = path print(f" ✓ Decimal heatmap: {path}") except Exception as e: print(f" ✗ Decimal heatmap failed: {e}", file=sys.stderr) if "risk_score" in report: try: label = report.get("dataset_name", "") path = plot_risk_gauge(report["risk_score"], output_dir=output_dir, label=label) figures["risk_gauge"] = path print(f" ✓ Risk gauge: {path}") except Exception as e: print(f" ✗ Risk gauge failed: {e}", file=sys.stderr) # Comprehensive dashboard (needs at least 2 test results) n_tests = sum(1 for k in ["last_digit", "benford", "fixed_ratio", "decimal_pattern"] if k in report) if n_tests >= 2: try: path = plot_dashboard(report, output_dir=output_dir) figures["dashboard"] = path print(f" ✓ Dashboard: {path}") except Exception as e: print(f" ✗ Dashboard failed: {e}", file=sys.stderr) return figures def main(): """CLI entry point for batch figure generation.""" parser = argparse.ArgumentParser( description="Generate publication-quality figures for academic fraud detection reports.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python3 visualization.py --input report.json --output figures/ python3 visualization.py --input report.json --output figures/ --dpi 600 python3 visualization.py --input report.json --type benford --output figures/ """, ) parser.add_argument( "--input", "-i", required=True, help="Path to JSON report file (output from fraud detection pipeline)." ) parser.add_argument( "--output", "-o", default="figures/", help="Output directory for generated figures (default: figures/)." ) parser.add_argument( "--dpi", type=int, default=300, help="Figure resolution in DPI (default: 300)." ) parser.add_argument( "--type", "-t", choices=["all", "last_digit", "benford", "fixed_ratio", "decimal", "gauge", "dashboard"], default="all", help="Type of figure to generate (default: all)." ) args = parser.parse_args() # Load report input_path = Path(args.input) if not input_path.exists(): print(f"Error: Input file not found: {args.input}", file=sys.stderr) sys.exit(1) with open(input_path, "r", encoding="utf-8") as f: report = json.load(f) print(f"📊 Generating figures from: {args.input}") print(f" Output directory: {args.output}") print(f" DPI: {args.dpi}") print(f" Type: {args.type}") print("-" * 50) # Update global DPI plt.rcParams["savefig.dpi"] = args.dpi if args.type == "all": figures = generate_all_figures(report, output_dir=args.output) elif args.type == "last_digit" and "last_digit" in report: path = plot_last_digit(report["last_digit"], output_dir=args.output, dpi=args.dpi) figures = {"last_digit": path} print(f" ✓ Last digit plot: {path}") elif args.type == "benford" and "benford" in report: path = plot_benford(report["benford"], output_dir=args.output, dpi=args.dpi) figures = {"benford": path} print(f" ✓ Benford plot: {path}") elif args.type == "fixed_ratio" and "fixed_ratio" in report: path = plot_fixed_ratio(report["fixed_ratio"], output_dir=args.output, dpi=args.dpi) figures = {"fixed_ratio": path} print(f" ✓ Fixed ratio plot: {path}") elif args.type == "decimal" and "decimal_pattern" in report: path = plot_decimal_heatmap(report["decimal_pattern"], output_dir=args.output, dpi=args.dpi) figures = {"decimal_heatmap": path} print(f" ✓ Decimal heatmap: {path}") elif args.type == "gauge" and "risk_score" in report: label = report.get("dataset_name", "") path = plot_risk_gauge(report["risk_score"], output_dir=args.output, dpi=args.dpi, label=label) figures = {"risk_gauge": path} print(f" ✓ Risk gauge: {path}") elif args.type == "dashboard": path = plot_dashboard(report, output_dir=args.output, dpi=args.dpi) figures = {"dashboard": path} print(f" ✓ Dashboard: {path}") else: print(f"Warning: No data available for type '{args.type}' in the report.", file=sys.stderr) figures = {} print("-" * 50) print(f"✅ Generated {len(figures)} figure(s).") if figures: # Save figure manifest manifest_path = Path(args.output) / "figures_manifest.json" with open(manifest_path, "w", encoding="utf-8") as f: json.dump(figures, f, indent=2, ensure_ascii=False) print(f"📋 Manifest saved: {manifest_path}") if __name__ == "__main__": main()