mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 00:48:12 +00:00
1229 lines
45 KiB
Python
1229 lines
45 KiB
Python
#!/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()
|