{"spec_id":"ks-test-comparison","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nks-test-comparison: Kolmogorov-Smirnov Plot for Distribution Comparison\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-29\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nfrom scipy import stats\n\n\n# Drop script directory from sys.path so `altair` resolves the package, not this file\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\nalt = importlib.import_module(\"altair\")\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint palette — semantic coloring for credit quality\nCOLOR_GOOD = \"#009E73\"  # Imprint position 1: brand green, semantic good/pass\nCOLOR_BAD = \"#AE3030\"  # Imprint semantic anchor: matte red, bad/loss/error\nCOLOR_KS = \"#4467A3\"  # Imprint position 3: blue, neutral annotation\n\n# Data — credit scoring: Good vs Bad customer score distributions\nnp.random.seed(42)\ngood_scores = np.random.normal(loc=620, scale=80, size=300)\nbad_scores = np.random.normal(loc=480, scale=90, size=300)\n\n# K-S test\nks_stat, p_value = stats.ks_2samp(good_scores, bad_scores)\n\n# Compute ECDFs using sorted arrays and normalized ranks\ngood_sorted = np.sort(good_scores)\nbad_sorted = np.sort(bad_scores)\ngood_ecdf = np.arange(1, len(good_sorted) + 1) / len(good_sorted)\nbad_ecdf = np.arange(1, len(bad_sorted) + 1) / len(bad_sorted)\n\n# Find max divergence point by evaluating both ECDFs on a combined grid\nall_values = np.union1d(good_sorted, bad_sorted)\ngood_at_all = np.searchsorted(good_sorted, all_values, side=\"right\") / len(good_sorted)\nbad_at_all = np.searchsorted(bad_sorted, all_values, side=\"right\") / len(bad_sorted)\nmax_idx = np.argmax(np.abs(good_at_all - bad_at_all))\nks_x = all_values[max_idx]\nks_y_good = good_at_all[max_idx]\nks_y_bad = bad_at_all[max_idx]\n\n# Assemble DataFrames\ngood_df = pd.DataFrame({\"Score\": good_sorted, \"ECDF\": good_ecdf, \"Group\": \"Good Customers\"})\nbad_df = pd.DataFrame({\"Score\": bad_sorted, \"ECDF\": bad_ecdf, \"Group\": \"Bad Customers\"})\necdf_df = pd.concat([good_df, bad_df], ignore_index=True)\n\n# K-S distance vertical line endpoints\nks_line_df = pd.DataFrame({\"Score\": [ks_x, ks_x], \"ECDF\": [ks_y_bad, ks_y_good]})\n\n# Label just above the lower endpoint (ks_y_good), in the gap — extends left into clear whitespace\nks_label_y = ks_y_good + 0.05\nks_label_df = pd.DataFrame({\"Score\": [ks_x], \"ECDF\": [ks_label_y], \"label\": [f\"D = {ks_stat:.3f}\"]})\n\n# Color and dash scales\ncolor_scale = alt.Scale(domain=[\"Good Customers\", \"Bad Customers\"], range=[COLOR_GOOD, COLOR_BAD])\ndash_scale = alt.Scale(domain=[\"Good Customers\", \"Bad Customers\"], range=[[1, 0], [8, 4]])\n\n# Title — compute fontsize scaled to title length (floor: 11px)\ntitle = \"ks-test-comparison · python · altair · anyplot.ai\"\ntitle_fontsize = round(16 * 67 / len(title)) if len(title) > 67 else 16\n\np_text = \"p < 0.001\" if p_value < 0.001 else f\"p = {p_value:.4f}\"\nsubtitle_text = f\"K-S Statistic: {ks_stat:.3f}  ·  {p_text}  ·  Credit scoring Good vs. Bad customers\"\n\n# ECDF step lines with redundant dash encoding for colorblind safety\necdf_lines = (\n    alt.Chart(ecdf_df)\n    .mark_line(interpolate=\"step-after\", strokeWidth=3.5)\n    .encode(\n        x=alt.X(\"Score:Q\", title=\"Credit Score\", scale=alt.Scale(nice=True)),\n        y=alt.Y(\n            \"ECDF:Q\",\n            title=\"Cumulative Proportion\",\n            scale=alt.Scale(domain=[0, 1]),\n            axis=alt.Axis(values=[0, 0.2, 0.4, 0.6, 0.8, 1.0], format=\".1f\"),\n        ),\n        color=alt.Color(\"Group:N\", scale=color_scale, legend=alt.Legend(title=None)),\n        strokeDash=alt.StrokeDash(\"Group:N\", scale=dash_scale, legend=None),\n        tooltip=[\"Group:N\", alt.Tooltip(\"Score:Q\", format=\".0f\"), alt.Tooltip(\"ECDF:Q\", format=\".3f\")],\n    )\n)\n\n# K-S distance vertical line marking maximum divergence\nks_distance = (\n    alt.Chart(ks_line_df).mark_line(color=COLOR_KS, strokeWidth=2.5, strokeDash=[6, 4]).encode(x=\"Score:Q\", y=\"ECDF:Q\")\n)\n\n# Endpoint dots on the distance line (reuse ks_line_df)\nks_dots = (\n    alt.Chart(ks_line_df)\n    .mark_point(color=COLOR_KS, size=100, filled=True, stroke=PAGE_BG, strokeWidth=1.5)\n    .encode(x=\"Score:Q\", y=\"ECDF:Q\")\n)\n\n# K-S statistic label — right-aligned so text extends left of the KS line into clean whitespace\nks_label = (\n    alt.Chart(ks_label_df)\n    .mark_text(align=\"right\", dx=-8, fontSize=13, fontWeight=\"bold\", color=COLOR_KS, font=\"monospace\")\n    .encode(x=\"Score:Q\", y=\"ECDF:Q\", text=\"label:N\")\n)\n\n# Combine layers and apply theme-adaptive chrome\nchart = (\n    alt.layer(ecdf_lines, ks_distance, ks_dots, ks_label)\n    .properties(\n        width=680,\n        height=360,\n        background=PAGE_BG,\n        title=alt.Title(\n            title,\n            subtitle=subtitle_text,\n            fontSize=title_fontsize,\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            color=INK,\n            anchor=\"start\",\n            offset=10,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        titleColor=INK,\n        labelColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n    )\n    .configure_axisX(grid=False)\n    .configure_legend(\n        labelFontSize=10,\n        titleFontSize=10,\n        symbolSize=200,\n        symbolStrokeWidth=3.5,\n        orient=\"top-right\",\n        padding=10,\n        cornerRadius=4,\n        strokeColor=INK_SOFT,\n        fillColor=ELEVATED_BG,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n    )\n)\n\n# Save PNG — pad to exact 3200×1800 canvas (altair canvas rule)\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save HTML\nchart.save(f\"plot-{THEME}.html\")\n"}