{"spec_id":"biplot-pca","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nbiplot-pca: PCA Biplot with Scores and Loading Vectors\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-17\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script directory from sys.path to avoid importing this file as the altair module\nscript_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != script_dir]\n\nimport altair as alt\nimport pandas as pd\nfrom sklearn.datasets import load_iris\nfrom sklearn.decomposition import PCA\nfrom sklearn.preprocessing import StandardScaler\n\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# Okabe-Ito palette\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Data - using Iris dataset for multivariate analysis\niris = load_iris()\nX = iris.data\ny = iris.target\nfeature_names = [\"Sepal Length\", \"Sepal Width\", \"Petal Length\", \"Petal Width\"]\ntarget_names = iris.target_names\n\n# Standardize features for PCA\nscaler = StandardScaler()\nX_scaled = scaler.fit_transform(X)\n\n# Perform PCA with random_state for reproducibility\npca = PCA(n_components=2, random_state=42)\nscores = pca.fit_transform(X_scaled)\nloadings = pca.components_.T\n\n# Variance explained\nvar_explained = pca.explained_variance_ratio_ * 100\n\n# Create DataFrame for observation scores\nscores_df = pd.DataFrame({\"PC1\": scores[:, 0], \"PC2\": scores[:, 1], \"Species\": [target_names[i] for i in y]})\n\n# Create DataFrame for loading arrows\nloading_scale = 2.5\nloadings_df = pd.DataFrame(\n    {\"feature\": feature_names, \"PC1\": loadings[:, 0] * loading_scale, \"PC2\": loadings[:, 1] * loading_scale}\n)\n\n# Prepare arrow line data (from origin to loading point)\narrow_lines = []\nfor _, row in loadings_df.iterrows():\n    arrow_lines.append({\"x\": 0, \"y\": 0, \"feature\": row[\"feature\"], \"order\": 0})\n    arrow_lines.append({\"x\": row[\"PC1\"], \"y\": row[\"PC2\"], \"feature\": row[\"feature\"], \"order\": 1})\narrow_df = pd.DataFrame(arrow_lines)\n\n# Create scatter plot for observation scores\nscatter = (\n    alt.Chart(scores_df)\n    .mark_point(size=120, opacity=0.8, filled=True)\n    .encode(\n        x=alt.X(\"PC1:Q\", title=f\"PC1 ({var_explained[0]:.1f}%)\", scale=alt.Scale(domain=[-4, 4])),\n        y=alt.Y(\"PC2:Q\", title=f\"PC2 ({var_explained[1]:.1f}%)\", scale=alt.Scale(domain=[-3, 3])),\n        color=alt.Color(\n            \"Species:N\",\n            scale=alt.Scale(range=IMPRINT),\n            legend=alt.Legend(title=\"Species\", titleFontSize=18, labelFontSize=16, symbolSize=200, orient=\"right\"),\n        ),\n        tooltip=[\"Species:N\", alt.Tooltip(\"PC1:Q\", format=\".2f\"), alt.Tooltip(\"PC2:Q\", format=\".2f\")],\n    )\n)\n\n# Create loading arrows as lines\narrows = (\n    alt.Chart(arrow_df)\n    .mark_line(color=INK_SOFT, strokeWidth=2.5, opacity=0.9)\n    .encode(x=alt.X(\"x:Q\"), y=alt.Y(\"y:Q\"), detail=\"feature:N\", order=\"order:O\")\n)\n\n# Create arrowheads at the end of loading vectors\narrowheads = (\n    alt.Chart(loadings_df)\n    .mark_point(shape=\"triangle\", size=150, color=INK_SOFT, opacity=0.9, filled=True)\n    .encode(x=alt.X(\"PC1:Q\"), y=alt.Y(\"PC2:Q\"))\n    .transform_calculate(angle=\"atan2(datum.PC2, datum.PC1) * 180 / PI + 90\")\n    .encode(angle=\"angle:Q\")\n)\n\n# Create labels for loading vectors\nlabel_offset = 1.15\nloading_labels_df = loadings_df.copy()\nloading_labels_df[\"label_x\"] = loading_labels_df[\"PC1\"] * label_offset\nloading_labels_df[\"label_y\"] = loading_labels_df[\"PC2\"] * label_offset\n\n# Add manual y-offsets to separate \"Petal Length\" and \"Petal Width\" labels\ny_adjustments = [0, 0, -0.15, 0.15]\nloading_labels_df[\"label_y\"] = loading_labels_df[\"label_y\"] + y_adjustments\n\nloading_labels = (\n    alt.Chart(loading_labels_df)\n    .mark_text(fontSize=14, fontWeight=\"bold\", color=INK, align=\"left\", dx=10)\n    .encode(x=alt.X(\"label_x:Q\"), y=alt.Y(\"label_y:Q\"), text=\"feature:N\")\n)\n\n# Create origin marker\norigin_df = pd.DataFrame({\"x\": [0], \"y\": [0]})\norigin = alt.Chart(origin_df).mark_point(size=80, color=INK_SOFT, shape=\"cross\", strokeWidth=2).encode(x=\"x:Q\", y=\"y:Q\")\n\n# Combine all layers with theme-adaptive configuration\nchart = (\n    alt.layer(scatter, arrows, arrowheads, loading_labels, origin)\n    .properties(\n        width=1600,\n        height=900,\n        background=PAGE_BG,\n        title=alt.Title(text=\"biplot-pca · altair · anyplot.ai\", fontSize=28, anchor=\"middle\", color=INK),\n    )\n    .configure_view(fill=PAGE_BG, stroke=INK_SOFT)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.10,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=18,\n        titleFontSize=22,\n    )\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        titleFontSize=18,\n        labelFontSize=16,\n    )\n    .interactive()\n)\n\n# Save outputs\nchart.save(f\"plot-{THEME}.png\", scale_factor=3.0)\nchart.save(f\"plot-{THEME}.html\")\n"}