{"spec_id":"biplot-pca","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nbiplot-pca: PCA Biplot with Scores and Loading Vectors\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-17\n\"\"\"\n\nimport math\nimport os\nimport sys\nfrom importlib import import_module\n\nfrom sklearn.datasets import load_iris\nfrom sklearn.decomposition import PCA\nfrom sklearn.preprocessing import StandardScaler\n\n\n# Ensure we import the installed pygal package, not the local script\nsite_packages = [p for p in sys.path if \"site-packages\" in p or \"dist-packages\" in p]\nif site_packages:\n    sys.path = site_packages + [p for p in sys.path if \"site-packages\" not in p and \"dist-packages\" not in p]\n\npygal = import_module(\"pygal\")\nStyle = import_module(\"pygal.style\").Style\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Okabe-Ito palette: first series always #009E73 (brand), then position 2 and 3\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Load and prepare data\niris = load_iris()\nX = iris.data\ny = iris.target\ntarget_names = iris.target_names\n\n# Standardize features\nX_scaled = StandardScaler().fit_transform(X)\n\n# Perform PCA\npca = PCA(n_components=2)\nscores = pca.fit_transform(X_scaled)\nloadings = pca.components_.T\nvariance_explained = pca.explained_variance_ratio_ * 100\n\n# Use correlation biplot scaling\nscore_range = max(scores[:, 0].max() - scores[:, 0].min(), scores[:, 1].max() - scores[:, 1].min())\nloading_scale = score_range * 0.4\n\n# Custom style for theme-adaptive rendering\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT,\n    title_font_size=28,\n    label_font_size=22,\n    major_label_font_size=18,\n    legend_font_size=16,\n    value_font_size=14,\n    stroke_width=2,\n)\n\n# Create XY chart for biplot\nchart = pygal.XY(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"biplot-pca · pygal · anyplot.ai\",\n    x_title=f\"PC1 ({variance_explained[0]:.1f}%)\",\n    y_title=f\"PC2 ({variance_explained[1]:.1f}%)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    dots_size=10,\n    stroke=False,\n    show_x_guides=True,\n    show_y_guides=True,\n    truncate_legend=0,\n    explicit_size=True,\n)\n\n# Add score points for each species (class)\nfor i, name in enumerate(target_names):\n    mask = y == i\n    points = [(float(scores[j, 0]), float(scores[j, 1])) for j in range(len(y)) if mask[j]]\n    chart.add(name.capitalize(), points, stroke=False, dots_size=10)\n\n# Full feature names for legend clarity\nfull_names = [\"Sepal Length\", \"Sepal Width\", \"Petal Length\", \"Petal Width\"]\n\n# Add each loading vector as a thin line arrow\nfor i, full_name in enumerate(full_names):\n    tip_x = float(loadings[i, 0] * loading_scale)\n    tip_y = float(loadings[i, 1] * loading_scale)\n\n    dx, dy = tip_x, tip_y\n    length = math.sqrt(dx * dx + dy * dy)\n    ux = dx / length if length > 0 else 0\n    uy = dy / length if length > 0 else 0\n    px, py = -uy, ux  # Perpendicular vector\n\n    head_len = 0.06 * loading_scale\n    head_wid = 0.03 * loading_scale\n    hb_x = tip_x - ux * head_len\n    hb_y = tip_y - uy * head_len\n\n    arrow_line = [\n        (0.0, 0.0),\n        (tip_x, tip_y),\n        (hb_x + px * head_wid, hb_y + py * head_wid),\n        (tip_x, tip_y),\n        (hb_x - px * head_wid, hb_y - py * head_wid),\n        (tip_x, tip_y),\n    ]\n    chart.add(full_name, arrow_line, stroke=True, show_dots=False, fill=False, stroke_style={\"width\": 2})\n\n# Save outputs\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}