From 18bb586691d0c621ba16e10f44a0dbf7b8bb9124 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:48:53 +0000 Subject: [PATCH 01/25] Initial plan From a756d9d6a7e7a5101c1d2761e480f040038fc5b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:52:36 +0000 Subject: [PATCH 02/25] Add node_modules and package-lock.json to .gitignore Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .gitignore | 4 +++- package.json | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 6ce008ac1..d6cd31930 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ _build/ lectures/_build/ .ipynb_checkpoints/ -.virtual_documents/ \ No newline at end of file +.virtual_documents/ +node_modules/ +package-lock.json \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 000000000..c4a7932c4 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "lecture-python.myst", + "version": "1.0.0", + "description": "This website presents a set of lectures on quantitative economic modeling.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "netlify-cli": "^23.5.1" + } +} From 9e45559c4014d44e88841bfd9ccf50c70efdcab1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:54:53 +0000 Subject: [PATCH 03/25] Replace nwtgck/actions-netlify@v3 with netlify-cli in CI workflow Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++------ package.json | 15 --------------- 2 files changed, 24 insertions(+), 21 deletions(-) delete mode 100644 package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 258e56ee1..b0569e161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,13 +78,31 @@ jobs: with: name: execution-reports path: _build/html/reports + - name: Install Netlify CLI + shell: bash -l {0} + run: npm install -g netlify-cli - name: Preview Deploy to Netlify - uses: nwtgck/actions-netlify@v3 - with: - publish-dir: '_build/html/' - production-branch: main - github-token: ${{ secrets.GITHUB_TOKEN }} - deploy-message: "Preview Deploy from GitHub Actions" + shell: bash -l {0} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + netlify deploy \ + --dir _build/html/ \ + --site ${{ secrets.NETLIFY_SITE_ID }} \ + --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ + --alias pr-${{ github.event.pull_request.number }} \ + --context deploy-preview \ + --message "Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" \ + --json + else + netlify deploy \ + --dir _build/html/ \ + --site ${{ secrets.NETLIFY_SITE_ID }} \ + --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ + --alias manual-${{ github.run_id }} \ + --context dev \ + --message "Manual Deploy from GitHub Actions (commit: ${{ github.sha }})" \ + --json + fi env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} diff --git a/package.json b/package.json deleted file mode 100644 index c4a7932c4..000000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "lecture-python.myst", - "version": "1.0.0", - "description": "This website presents a set of lectures on quantitative economic modeling.", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "devDependencies": { - "netlify-cli": "^23.5.1" - } -} From 1b82b7d792b20d4a7d7df78613ab1e5618ee7544 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 02:56:28 +0000 Subject: [PATCH 04/25] Update collab.yml workflow to use netlify-cli for consistency Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/collab.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/collab.yml b/.github/workflows/collab.yml index 5cd54307e..726c617ba 100644 --- a/.github/workflows/collab.yml +++ b/.github/workflows/collab.yml @@ -46,13 +46,23 @@ jobs: with: name: execution-reports path: _build/html/reports + - name: Install Node.js and Netlify CLI + shell: bash -l {0} + run: | + curl -fsSL https://deb.nodesource.com/setup_18.x | bash - + apt-get install -y nodejs + npm install -g netlify-cli - name: Preview Deploy to Netlify - uses: nwtgck/actions-netlify@v3 - with: - publish-dir: '_build/html/' - production-branch: main - github-token: ${{ secrets.GITHUB_TOKEN }} - deploy-message: "Preview Deploy from GitHub Actions" + shell: bash -l {0} + run: | + netlify deploy \ + --dir _build/html/ \ + --site ${{ secrets.NETLIFY_SITE_ID }} \ + --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ + --alias collab-pr-${{ github.event.pull_request.number }} \ + --context deploy-preview \ + --message "Collab Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" \ + --json env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} From c5990886db74a2078f7a3ada7bcf9de902eee985 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 03:03:48 +0000 Subject: [PATCH 05/25] Remove Netlify preview deployment from collab.yml workflow Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/collab.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/collab.yml b/.github/workflows/collab.yml index 726c617ba..cbd3d4119 100644 --- a/.github/workflows/collab.yml +++ b/.github/workflows/collab.yml @@ -46,23 +46,3 @@ jobs: with: name: execution-reports path: _build/html/reports - - name: Install Node.js and Netlify CLI - shell: bash -l {0} - run: | - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - - apt-get install -y nodejs - npm install -g netlify-cli - - name: Preview Deploy to Netlify - shell: bash -l {0} - run: | - netlify deploy \ - --dir _build/html/ \ - --site ${{ secrets.NETLIFY_SITE_ID }} \ - --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ - --alias collab-pr-${{ github.event.pull_request.number }} \ - --context deploy-preview \ - --message "Collab Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" \ - --json - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} From b5b588e07e0b31e9e33b67fb31ce251d98ffc2d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 03:20:16 +0000 Subject: [PATCH 06/25] Fix npm command not found error by installing Node.js via conda Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0569e161..d6c86b188 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,9 +78,11 @@ jobs: with: name: execution-reports path: _build/html/reports - - name: Install Netlify CLI + - name: Install Node.js and Netlify CLI shell: bash -l {0} - run: npm install -g netlify-cli + run: | + conda install -c conda-forge nodejs npm -y + npm install -g netlify-cli - name: Preview Deploy to Netlify shell: bash -l {0} run: | From eab53315700295a79d28766196a01e6594161beb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 03:50:45 +0000 Subject: [PATCH 07/25] Use apt to install Node.js and npm instead of conda-forge Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c86b188..7adee59b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,9 @@ jobs: - name: Install Node.js and Netlify CLI shell: bash -l {0} run: | - conda install -c conda-forge nodejs npm -y + # Install Node.js via system package manager since conda-forge doesn't have npm + sudo apt-get update + sudo apt-get install -y nodejs npm npm install -g netlify-cli - name: Preview Deploy to Netlify shell: bash -l {0} From e849ac891a95014699c74ca048ef9dfd522ad68f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 22:46:42 +0000 Subject: [PATCH 08/25] Fix npm global install permissions by adding sudo to netlify-cli installation Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7adee59b2..49df49f08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: # Install Node.js via system package manager since conda-forge doesn't have npm sudo apt-get update sudo apt-get install -y nodejs npm - npm install -g netlify-cli + sudo npm install -g netlify-cli - name: Preview Deploy to Netlify shell: bash -l {0} run: | From 3c82602272e78dd40a2ae39b95a1ad708d932485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:18:45 +0000 Subject: [PATCH 09/25] Use Netlify's default deploy-preview URL structure by removing custom alias Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49df49f08..220959426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,6 @@ jobs: --dir _build/html/ \ --site ${{ secrets.NETLIFY_SITE_ID }} \ --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ - --alias pr-${{ github.event.pull_request.number }} \ --context deploy-preview \ --message "Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" \ --json From 0d40ee59e445f848346b25edaca5cd72b339ebf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:24:58 +0000 Subject: [PATCH 10/25] Add direct page preview functionality to Netlify deployments - Add workflow input to manually specify preview page (e.g., aiyagari.html) - Auto-detect changed lecture files and generate direct preview URLs - Include direct links in deployment messages and action output - Support both PR and manual deployment workflows Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 93 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 220959426..bf1b6a181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,11 @@ name: Build Project [using jupyter-book] on: pull_request: workflow_dispatch: + inputs: + preview_page: + description: 'Specific page to preview (e.g., aiyagari.html)' + required: false + type: string jobs: preview: runs-on: "runs-on=${{ github.run_id }}/family=g4dn.2xlarge/image=quantecon_ubuntu2404/disk=large" @@ -85,26 +90,110 @@ jobs: sudo apt-get update sudo apt-get install -y nodejs npm sudo npm install -g netlify-cli + - name: Detect Changed Lecture Files + id: detect-changes + shell: bash -l {0} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "Detecting changed lecture files..." + + # Get changed files in the lectures directory + changed_files=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | grep '^lectures/.*\.md$' | grep -v '^lectures/_' || true) + + if [ ! -z "$changed_files" ]; then + echo "Changed lecture files:" + echo "$changed_files" + + # Convert .md files to .html and create preview URLs + preview_urls="" + for file in $changed_files; do + # Extract filename without path and extension + basename=$(basename "$file" .md) + html_file="${basename}.html" + preview_urls="${preview_urls}https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/${html_file}\n" + done + + echo "preview_urls<> $GITHUB_OUTPUT + echo -e "$preview_urls" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "changed_files<> $GITHUB_OUTPUT + echo "$changed_files" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "No lecture files changed" + echo "preview_urls=" >> $GITHUB_OUTPUT + echo "changed_files=" >> $GITHUB_OUTPUT + fi + else + echo "Not a PR, skipping change detection" + echo "preview_urls=" >> $GITHUB_OUTPUT + echo "changed_files=" >> $GITHUB_OUTPUT + fi - name: Preview Deploy to Netlify shell: bash -l {0} run: | if [ "${{ github.event_name }}" = "pull_request" ]; then + # Construct deployment message with preview URLs + deploy_message="Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" + + # Add manual preview page if specified + if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then + deploy_message="${deploy_message}\n\n🎯 Manual Preview Page: https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" + fi + + # Add changed lecture pages + if [ ! -z "${{ steps.detect-changes.outputs.changed_files }}" ]; then + deploy_message="${deploy_message}\n\n📚 Changed Lecture Pages:" + deploy_message="${deploy_message}\n${{ steps.detect-changes.outputs.preview_urls }}" + fi + netlify deploy \ --dir _build/html/ \ --site ${{ secrets.NETLIFY_SITE_ID }} \ --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ --context deploy-preview \ - --message "Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" \ + --message "${deploy_message}" \ --json + + echo "✅ Deployment completed!" + echo "🌐 Preview URL: https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/" + + # Display preview URLs for changed files + if [ ! -z "${{ steps.detect-changes.outputs.preview_urls }}" ]; then + echo "" + echo "📚 Direct links to changed lecture pages:" + echo -e "${{ steps.detect-changes.outputs.preview_urls }}" + fi + + # Display manual preview page if specified + if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then + echo "" + echo "🎯 Manual preview page: https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" + fi else + # Handle manual deployment + deploy_message="Manual Deploy from GitHub Actions (commit: ${{ github.sha }})" + + if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then + deploy_message="${deploy_message}\n\n🎯 Preview Page: https://manual-${{ github.run_id }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" + fi + netlify deploy \ --dir _build/html/ \ --site ${{ secrets.NETLIFY_SITE_ID }} \ --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ --alias manual-${{ github.run_id }} \ --context dev \ - --message "Manual Deploy from GitHub Actions (commit: ${{ github.sha }})" \ + --message "${deploy_message}" \ --json + + echo "✅ Manual deployment completed!" + echo "🌐 Preview URL: https://manual-${{ github.run_id }}--sunny-cactus-210e3e.netlify.app/" + + if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then + echo "🎯 Preview page: https://manual-${{ github.run_id }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" + fi fi env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} From c2d60799620ba9fd8047aa0ed8928e186d7c8194 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:38:50 +0000 Subject: [PATCH 11/25] Add automatic PR comment posting for preview links Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf1b6a181..84c00247a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,3 +198,44 @@ jobs: env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + - name: Post PR Comment with Preview Links + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const changedFiles = `${{ steps.detect-changes.outputs.changed_files }}`; + const previewUrls = `${{ steps.detect-changes.outputs.preview_urls }}`; + const manualPage = `${{ github.event.inputs.preview_page }}`; + const prNumber = ${{ github.event.pull_request.number }}; + const baseUrl = `https://deploy-preview-${prNumber}--sunny-cactus-210e3e.netlify.app`; + + let comment = `## 📖 Netlify Preview Ready!\n\n`; + comment += `**Preview URL:** ${baseUrl}\n\n`; + + // Add manual preview page if specified + if (manualPage) { + comment += `🎯 **Manual Preview:** [${manualPage}](${baseUrl}/${manualPage})\n\n`; + } + + // Add direct links to changed lecture pages + if (changedFiles && previewUrls) { + comment += `📚 **Changed Lecture Pages:**\n`; + const files = changedFiles.split('\n').filter(f => f.trim()); + const urls = previewUrls.split('\n').filter(u => u.trim()); + + for (let i = 0; i < files.length && i < urls.length; i++) { + const fileName = files[i].replace('lectures/', '').replace('.md', ''); + const url = urls[i].trim(); + if (url) { + comment += `- [${fileName}](${url})\n`; + } + } + } + + // Post the comment + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); From 31dfce964221a894d6cdfa216d1ba4207f7a7b6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:03:01 +0000 Subject: [PATCH 12/25] Fix Netlify preview URLs by using actual deploy_url from JSON response Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 101 ++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84c00247a..971acb574 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,95 +104,86 @@ jobs: echo "Changed lecture files:" echo "$changed_files" - # Convert .md files to .html and create preview URLs - preview_urls="" - for file in $changed_files; do - # Extract filename without path and extension - basename=$(basename "$file" .md) - html_file="${basename}.html" - preview_urls="${preview_urls}https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/${html_file}\n" - done - - echo "preview_urls<> $GITHUB_OUTPUT - echo -e "$preview_urls" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "changed_files<> $GITHUB_OUTPUT echo "$changed_files" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT else echo "No lecture files changed" - echo "preview_urls=" >> $GITHUB_OUTPUT echo "changed_files=" >> $GITHUB_OUTPUT fi else echo "Not a PR, skipping change detection" - echo "preview_urls=" >> $GITHUB_OUTPUT echo "changed_files=" >> $GITHUB_OUTPUT fi - name: Preview Deploy to Netlify + id: netlify-deploy shell: bash -l {0} run: | if [ "${{ github.event_name }}" = "pull_request" ]; then - # Construct deployment message with preview URLs + # Deploy to Netlify and capture the response deploy_message="Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" - # Add manual preview page if specified - if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then - deploy_message="${deploy_message}\n\n🎯 Manual Preview Page: https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" - fi - - # Add changed lecture pages - if [ ! -z "${{ steps.detect-changes.outputs.changed_files }}" ]; then - deploy_message="${deploy_message}\n\n📚 Changed Lecture Pages:" - deploy_message="${deploy_message}\n${{ steps.detect-changes.outputs.preview_urls }}" - fi - - netlify deploy \ + netlify_output=$(netlify deploy \ --dir _build/html/ \ --site ${{ secrets.NETLIFY_SITE_ID }} \ --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ --context deploy-preview \ --message "${deploy_message}" \ - --json + --json) + echo "Netlify deployment output:" + echo "$netlify_output" + + # Extract the actual deploy URL from the JSON response + deploy_url=$(echo "$netlify_output" | jq -r '.deploy_url') + + echo "deploy_url=$deploy_url" >> $GITHUB_OUTPUT echo "✅ Deployment completed!" - echo "🌐 Preview URL: https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/" + echo "🌐 Actual Deploy URL: $deploy_url" - # Display preview URLs for changed files - if [ ! -z "${{ steps.detect-changes.outputs.preview_urls }}" ]; then + # Generate preview URLs for changed files using the actual deploy URL + if [ ! -z "${{ steps.detect-changes.outputs.changed_files }}" ]; then echo "" echo "📚 Direct links to changed lecture pages:" - echo -e "${{ steps.detect-changes.outputs.preview_urls }}" + while read -r file; do + if [ ! -z "$file" ]; then + basename=$(basename "$file" .md) + html_file="${basename}.html" + echo "- ${basename}: ${deploy_url}/${html_file}" + fi + done <<< "${{ steps.detect-changes.outputs.changed_files }}" fi # Display manual preview page if specified if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then echo "" - echo "🎯 Manual preview page: https://deploy-preview-${{ github.event.pull_request.number }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" + echo "🎯 Manual preview page: ${deploy_url}/${{ github.event.inputs.preview_page }}" fi else # Handle manual deployment deploy_message="Manual Deploy from GitHub Actions (commit: ${{ github.sha }})" - if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then - deploy_message="${deploy_message}\n\n🎯 Preview Page: https://manual-${{ github.run_id }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" - fi - - netlify deploy \ + netlify_output=$(netlify deploy \ --dir _build/html/ \ --site ${{ secrets.NETLIFY_SITE_ID }} \ --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ --alias manual-${{ github.run_id }} \ --context dev \ --message "${deploy_message}" \ - --json + --json) + echo "Netlify deployment output:" + echo "$netlify_output" + + # Extract the actual deploy URL from the JSON response + deploy_url=$(echo "$netlify_output" | jq -r '.deploy_url') + + echo "deploy_url=$deploy_url" >> $GITHUB_OUTPUT echo "✅ Manual deployment completed!" - echo "🌐 Preview URL: https://manual-${{ github.run_id }}--sunny-cactus-210e3e.netlify.app/" + echo "🌐 Actual Deploy URL: $deploy_url" if [ ! -z "${{ github.event.inputs.preview_page }}" ]; then - echo "🎯 Preview page: https://manual-${{ github.run_id }}--sunny-cactus-210e3e.netlify.app/${{ github.event.inputs.preview_page }}" + echo "🎯 Preview page: ${deploy_url}/${{ github.event.inputs.preview_page }}" fi fi env: @@ -204,30 +195,30 @@ jobs: with: script: | const changedFiles = `${{ steps.detect-changes.outputs.changed_files }}`; - const previewUrls = `${{ steps.detect-changes.outputs.preview_urls }}`; const manualPage = `${{ github.event.inputs.preview_page }}`; + const deployUrl = `${{ steps.netlify-deploy.outputs.deploy_url }}`; const prNumber = ${{ github.event.pull_request.number }}; - const baseUrl = `https://deploy-preview-${prNumber}--sunny-cactus-210e3e.netlify.app`; let comment = `## 📖 Netlify Preview Ready!\n\n`; - comment += `**Preview URL:** ${baseUrl}\n\n`; + comment += `**Preview URL:** ${deployUrl}\n\n`; // Add manual preview page if specified if (manualPage) { - comment += `🎯 **Manual Preview:** [${manualPage}](${baseUrl}/${manualPage})\n\n`; + comment += `🎯 **Manual Preview:** [${manualPage}](${deployUrl}/${manualPage})\n\n`; } // Add direct links to changed lecture pages - if (changedFiles && previewUrls) { - comment += `📚 **Changed Lecture Pages:**\n`; + if (changedFiles) { const files = changedFiles.split('\n').filter(f => f.trim()); - const urls = previewUrls.split('\n').filter(u => u.trim()); - - for (let i = 0; i < files.length && i < urls.length; i++) { - const fileName = files[i].replace('lectures/', '').replace('.md', ''); - const url = urls[i].trim(); - if (url) { - comment += `- [${fileName}](${url})\n`; + if (files.length > 0) { + comment += `📚 **Changed Lecture Pages:**\n`; + + for (const file of files) { + if (file.trim()) { + const fileName = file.replace('lectures/', '').replace('.md', ''); + const pageUrl = `${deployUrl}/${fileName}.html`; + comment += `- [${fileName}](${pageUrl})\n`; + } } } } From ac4de704891076dbe77f7b0e08848ece5ddc41f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:47:43 +0000 Subject: [PATCH 13/25] Fix duplicate PR comments by updating existing Netlify preview comments instead of creating new ones Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 971acb574..7e16ee510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,10 +223,31 @@ jobs: } } - // Post the comment - await github.rest.issues.createComment({ + // Check if there's already a Netlify preview comment + const comments = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, - body: comment }); + + const existingComment = comments.data.find(c => + c.body.includes('## 📖 Netlify Preview Ready!') + ); + + if (existingComment) { + // Update the existing comment + await github.rest.issues.updateComment({ + comment_id: existingComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + } else { + // Create a new comment + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + } From b75a64df71d2cb6c7d6f33e663ae7ad51b839866 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:50:57 +0000 Subject: [PATCH 14/25] Fix PR preview URLs to use deploy-preview format by adding alias parameter Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e16ee510..64538d9f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,7 @@ jobs: --site ${{ secrets.NETLIFY_SITE_ID }} \ --auth ${{ secrets.NETLIFY_AUTH_TOKEN }} \ --context deploy-preview \ + --alias deploy-preview-${{ github.event.pull_request.number }} \ --message "${deploy_message}" \ --json) From a9c273b89a244149b37e0de239d0fca8d5d4b5a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:57:54 +0000 Subject: [PATCH 15/25] Revert to creating new PR comments per commit instead of updating existing ones Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 47 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64538d9f8..9d3ff941a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: Build Project [using jupyter-book] on: pull_request: + types: [opened, synchronize, reopened] workflow_dispatch: inputs: preview_page: @@ -199,9 +200,28 @@ jobs: const manualPage = `${{ github.event.inputs.preview_page }}`; const deployUrl = `${{ steps.netlify-deploy.outputs.deploy_url }}`; const prNumber = ${{ github.event.pull_request.number }}; + const commitSha = `${{ github.sha }}`; + + // Check if we already posted a comment for this commit + const comments = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const existingCommentForCommit = comments.data.find(c => + c.body.includes('## 📖 Netlify Preview Ready!') && + c.body.includes(`**Commit:** \`${commitSha}\``) + ); + + if (existingCommentForCommit) { + console.log(`Comment already exists for commit ${commitSha}, skipping...`); + return; + } let comment = `## 📖 Netlify Preview Ready!\n\n`; comment += `**Preview URL:** ${deployUrl}\n\n`; + comment += `**Commit:** \`${{ github.sha }}\`\n\n`; // Add manual preview page if specified if (manualPage) { @@ -224,31 +244,10 @@ jobs: } } - // Check if there's already a Netlify preview comment - const comments = await github.rest.issues.listComments({ + // Post the comment + await github.rest.issues.createComment({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, + body: comment }); - - const existingComment = comments.data.find(c => - c.body.includes('## 📖 Netlify Preview Ready!') - ); - - if (existingComment) { - // Update the existing comment - await github.rest.issues.updateComment({ - comment_id: existingComment.id, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); - } else { - // Create a new comment - await github.rest.issues.createComment({ - issue_number: prNumber, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); - } From f9d666f45d53f2d2bd4a5834d6dd154c5fff70f4 Mon Sep 17 00:00:00 2001 From: mmcky Date: Wed, 10 Sep 2025 11:26:50 +1000 Subject: [PATCH 16/25] tst: add test change to aiyagari --- lectures/aiyagari.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lectures/aiyagari.md b/lectures/aiyagari.md index c713f946f..210b237ff 100644 --- a/lectures/aiyagari.md +++ b/lectures/aiyagari.md @@ -22,6 +22,8 @@ kernelspec: # The Aiyagari Model +TEST CHANGE + ```{contents} Contents :depth: 2 ``` From 8551d26d64708809848d0e7fcba57089e696432f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 01:54:27 +0000 Subject: [PATCH 17/25] Fix git diff revision range error by adding fetch-depth: 0 to checkout action Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d3ff941a..5f806b514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 - name: Setup Anaconda uses: conda-incubator/setup-miniconda@v3 with: From c375d022c1e015f809d891883ee978ccecb5dd3b Mon Sep 17 00:00:00 2001 From: mmcky Date: Wed, 10 Sep 2025 12:21:56 +1000 Subject: [PATCH 18/25] tst: another change --- lectures/aiyagari.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lectures/aiyagari.md b/lectures/aiyagari.md index 210b237ff..4387606f0 100644 --- a/lectures/aiyagari.md +++ b/lectures/aiyagari.md @@ -22,7 +22,7 @@ kernelspec: # The Aiyagari Model -TEST CHANGE +TEST CHANGE -- ANOTHER CHANGE ```{contents} Contents :depth: 2 From f46f0c9562e277f18ea616fe6619f7d0e02e99b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 02:56:46 +0000 Subject: [PATCH 19/25] Make PR comment format more compact per feedback Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f806b514..14bb30047 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,8 +211,8 @@ jobs: }); const existingCommentForCommit = comments.data.find(c => - c.body.includes('## 📖 Netlify Preview Ready!') && - c.body.includes(`**Commit:** \`${commitSha}\``) + c.body.includes('**📖 Netlify Preview Ready!**') && + c.body.includes(`([${commitSha.substring(0, 7)}]`) ); if (existingCommentForCommit) { @@ -220,9 +220,11 @@ jobs: return; } - let comment = `## 📖 Netlify Preview Ready!\n\n`; - comment += `**Preview URL:** ${deployUrl}\n\n`; - comment += `**Commit:** \`${{ github.sha }}\`\n\n`; + const shortSha = commitSha.substring(0, 7); + const commitUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${commitSha}`; + + let comment = `**📖 Netlify Preview Ready!**\n\n`; + comment += `**Preview URL:** ${deployUrl} ([${shortSha}](${commitUrl}))\n\n`; // Add manual preview page if specified if (manualPage) { @@ -233,15 +235,17 @@ jobs: if (changedFiles) { const files = changedFiles.split('\n').filter(f => f.trim()); if (files.length > 0) { - comment += `📚 **Changed Lecture Pages:**\n`; + comment += `📚 **Changed Lecture Pages:** `; + const pageLinks = []; for (const file of files) { if (file.trim()) { const fileName = file.replace('lectures/', '').replace('.md', ''); const pageUrl = `${deployUrl}/${fileName}.html`; - comment += `- [${fileName}](${pageUrl})\n`; + pageLinks.push(`[${fileName}](${pageUrl})`); } } + comment += pageLinks.join(', ') + '\n\n'; } } From 69808831b0b2ede40bd08fe1f373b09ff62702fd Mon Sep 17 00:00:00 2001 From: mmcky Date: Wed, 10 Sep 2025 13:00:29 +1000 Subject: [PATCH 20/25] tst: revert test changes --- lectures/aiyagari.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/lectures/aiyagari.md b/lectures/aiyagari.md index 4387606f0..c713f946f 100644 --- a/lectures/aiyagari.md +++ b/lectures/aiyagari.md @@ -22,8 +22,6 @@ kernelspec: # The Aiyagari Model -TEST CHANGE -- ANOTHER CHANGE - ```{contents} Contents :depth: 2 ``` From 4a056473168497b79f18a23baa6825f3b936a7e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 03:21:39 +0000 Subject: [PATCH 21/25] Fix false positive change detection for lecture files Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14bb30047..69753afe0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,17 +98,48 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request" ]; then echo "Detecting changed lecture files..." + echo "Base SHA: ${{ github.event.pull_request.base.sha }}" + echo "Head SHA: ${{ github.event.pull_request.head.sha }}" - # Get changed files in the lectures directory - changed_files=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | grep '^lectures/.*\.md$' | grep -v '^lectures/_' || true) + # Get changed files in the lectures directory with better validation + all_changed=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} || true) + echo "All changed files:" + echo "$all_changed" + + changed_files=$(echo "$all_changed" | grep '^lectures/.*\.md$' | grep -v '^lectures/_' | grep -v '^lectures/intro\.md$' || true) if [ ! -z "$changed_files" ]; then - echo "Changed lecture files:" + echo "Filtered lecture files:" echo "$changed_files" - echo "changed_files<> $GITHUB_OUTPUT - echo "$changed_files" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + # Validate that these files actually exist and contain changes + validated_files="" + while IFS= read -r file; do + if [ ! -z "$file" ] && [ -f "$file" ]; then + # Check if the file actually has changes + if git diff --quiet ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} -- "$file"; then + echo "Warning: $file shows no actual changes, skipping" + else + echo "Confirmed changes in: $file" + if [ -z "$validated_files" ]; then + validated_files="$file" + else + validated_files="$validated_files"$'\n'"$file" + fi + fi + fi + done <<< "$changed_files" + + if [ ! -z "$validated_files" ]; then + echo "Final validated changed files:" + echo "$validated_files" + echo "changed_files<> $GITHUB_OUTPUT + echo "$validated_files" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "No lecture files with actual changes found" + echo "changed_files=" >> $GITHUB_OUTPUT + fi else echo "No lecture files changed" echo "changed_files=" >> $GITHUB_OUTPUT @@ -232,20 +263,28 @@ jobs: } // Add direct links to changed lecture pages - if (changedFiles) { - const files = changedFiles.split('\n').filter(f => f.trim()); + if (changedFiles && changedFiles.trim()) { + console.log('Raw changedFiles:', JSON.stringify(changedFiles)); + const files = changedFiles.split('\n').filter(f => f.trim() && f.includes('lectures/') && f.endsWith('.md')); + console.log('Filtered files:', files); + if (files.length > 0) { comment += `📚 **Changed Lecture Pages:** `; const pageLinks = []; for (const file of files) { - if (file.trim()) { - const fileName = file.replace('lectures/', '').replace('.md', ''); + const cleanFile = file.trim(); + if (cleanFile && cleanFile.startsWith('lectures/') && cleanFile.endsWith('.md')) { + const fileName = cleanFile.replace('lectures/', '').replace('.md', ''); + console.log(`Processing file: ${cleanFile} -> ${fileName}`); const pageUrl = `${deployUrl}/${fileName}.html`; pageLinks.push(`[${fileName}](${pageUrl})`); } } - comment += pageLinks.join(', ') + '\n\n'; + + if (pageLinks.length > 0) { + comment += pageLinks.join(', ') + '\n\n'; + } } } From f0b3207f4607fab300730c0f9cb7f47fe9739e0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 03:57:18 +0000 Subject: [PATCH 22/25] Fix false positive change detection and duplicate PR comments issues Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 126 ++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69753afe0..76934637b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,47 +101,65 @@ jobs: echo "Base SHA: ${{ github.event.pull_request.base.sha }}" echo "Head SHA: ${{ github.event.pull_request.head.sha }}" - # Get changed files in the lectures directory with better validation - all_changed=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} || true) - echo "All changed files:" - echo "$all_changed" + # Ensure we have both base and head commits available + git fetch origin ${{ github.event.pull_request.base.sha }}:refs/remotes/origin/pr-base || true + git fetch origin ${{ github.event.pull_request.head.sha }}:refs/remotes/origin/pr-head || true + + # Get changed files using git diff with status to see the type of change + echo "Getting diff between commits..." + all_changed=$(git diff --name-status ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>/dev/null || echo "") + + if [ -z "$all_changed" ]; then + echo "No changes detected or error in git diff" + echo "changed_files=" >> $GITHUB_OUTPUT + exit 0 + fi - changed_files=$(echo "$all_changed" | grep '^lectures/.*\.md$' | grep -v '^lectures/_' | grep -v '^lectures/intro\.md$' || true) + echo "All changed files with status:" + echo "$all_changed" - if [ ! -z "$changed_files" ]; then - echo "Filtered lecture files:" - echo "$changed_files" + # Filter for lecture files that are Added or Modified (not Deleted) + # Format: M lectures/file.md or A lectures/file.md + changed_lecture_files="" + while IFS=$'\t' read -r status file; do + # Skip if empty line + [ -z "$status" ] && continue - # Validate that these files actually exist and contain changes - validated_files="" - while IFS= read -r file; do - if [ ! -z "$file" ] && [ -f "$file" ]; then - # Check if the file actually has changes - if git diff --quiet ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} -- "$file"; then - echo "Warning: $file shows no actual changes, skipping" - else - echo "Confirmed changes in: $file" - if [ -z "$validated_files" ]; then - validated_files="$file" + echo "Processing: status='$status' file='$file'" + + # Only include Added (A) or Modified (M) files, skip Deleted (D) + if [[ "$status" =~ ^[AM] ]] && [[ "$file" =~ ^lectures/.*\.md$ ]] && [[ ! "$file" =~ ^lectures/_ ]] && [[ "$file" != "lectures/intro.md" ]]; then + # Double-check that the file exists and has real content changes + if [ -f "$file" ]; then + # Use git show to check if there are actual content changes (not just metadata) + content_diff=$(git diff ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} -- "$file" | grep -E '^[+-]' | grep -v '^[+-]{3}' | wc -l) + if [ "$content_diff" -gt 0 ]; then + echo "✓ Confirmed content changes in: $file" + if [ -z "$changed_lecture_files" ]; then + changed_lecture_files="$file" else - validated_files="$validated_files"$'\n'"$file" + changed_lecture_files="$changed_lecture_files"$'\n'"$file" fi + else + echo "⚠ No content changes found in: $file (possibly metadata only)" fi + else + echo "⚠ File not found in working directory: $file" fi - done <<< "$changed_files" - - if [ ! -z "$validated_files" ]; then - echo "Final validated changed files:" - echo "$validated_files" - echo "changed_files<> $GITHUB_OUTPUT - echo "$validated_files" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT else - echo "No lecture files with actual changes found" - echo "changed_files=" >> $GITHUB_OUTPUT + echo "⚠ Skipping: $file (status: $status, doesn't match lecture file pattern or is excluded)" fi + done <<< "$all_changed" + + if [ ! -z "$changed_lecture_files" ]; then + echo "" + echo "Final validated changed lecture files:" + echo "$changed_lecture_files" + echo "changed_files<> $GITHUB_OUTPUT + echo "$changed_lecture_files" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT else - echo "No lecture files changed" + echo "No lecture files with actual content changes found" echo "changed_files=" >> $GITHUB_OUTPUT fi else @@ -233,25 +251,39 @@ jobs: const deployUrl = `${{ steps.netlify-deploy.outputs.deploy_url }}`; const prNumber = ${{ github.event.pull_request.number }}; const commitSha = `${{ github.sha }}`; + const shortSha = commitSha.substring(0, 7); + + console.log(`Checking for existing comments for commit: ${commitSha}`); + console.log(`Deploy URL: ${deployUrl}`); + console.log(`Changed files: ${changedFiles}`); - // Check if we already posted a comment for this commit + // Get all comments on this PR to check for duplicates const comments = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, }); - const existingCommentForCommit = comments.data.find(c => - c.body.includes('**📖 Netlify Preview Ready!**') && - c.body.includes(`([${commitSha.substring(0, 7)}]`) - ); + console.log(`Found ${comments.data.length} comments on PR`); - if (existingCommentForCommit) { - console.log(`Comment already exists for commit ${commitSha}, skipping...`); + // Look for existing comment with this exact commit SHA and deploy URL + const duplicateComment = comments.data.find(comment => { + const hasMarker = comment.body.includes('**📖 Netlify Preview Ready!**'); + const hasCommitSha = comment.body.includes(`([${shortSha}]`); + const hasDeployUrl = comment.body.includes(deployUrl); + + console.log(`Comment ${comment.id}: hasMarker=${hasMarker}, hasCommitSha=${hasCommitSha}, hasDeployUrl=${hasDeployUrl}`); + + return hasMarker && hasCommitSha && hasDeployUrl; + }); + + if (duplicateComment) { + console.log(`Duplicate comment found (${duplicateComment.id}) for commit ${shortSha} and deploy URL ${deployUrl}, skipping...`); return; } - const shortSha = commitSha.substring(0, 7); + console.log(`No duplicate found, creating new comment for commit ${shortSha}`); + const commitUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${commitSha}`; let comment = `**📖 Netlify Preview Ready!**\n\n`; @@ -264,9 +296,9 @@ jobs: // Add direct links to changed lecture pages if (changedFiles && changedFiles.trim()) { - console.log('Raw changedFiles:', JSON.stringify(changedFiles)); + console.log('Processing changed files for preview links...'); const files = changedFiles.split('\n').filter(f => f.trim() && f.includes('lectures/') && f.endsWith('.md')); - console.log('Filtered files:', files); + console.log('Filtered lecture files:', files); if (files.length > 0) { comment += `📚 **Changed Lecture Pages:** `; @@ -276,7 +308,7 @@ jobs: const cleanFile = file.trim(); if (cleanFile && cleanFile.startsWith('lectures/') && cleanFile.endsWith('.md')) { const fileName = cleanFile.replace('lectures/', '').replace('.md', ''); - console.log(`Processing file: ${cleanFile} -> ${fileName}`); + console.log(`Creating preview link: ${cleanFile} -> ${fileName}.html`); const pageUrl = `${deployUrl}/${fileName}.html`; pageLinks.push(`[${fileName}](${pageUrl})`); } @@ -284,10 +316,18 @@ jobs: if (pageLinks.length > 0) { comment += pageLinks.join(', ') + '\n\n'; + } else { + console.log('No valid page links created'); } + } else { + console.log('No lecture files in changed files list'); } + } else { + console.log('No changed files detected'); } + console.log('Final comment:', comment); + // Post the comment await github.rest.issues.createComment({ issue_number: prNumber, @@ -295,3 +335,5 @@ jobs: repo: context.repo.repo, body: comment }); + + console.log('Comment posted successfully'); From b5dedf79f95a241564e493781b7270244d9267ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 05:16:34 +0000 Subject: [PATCH 23/25] Revert unintentional changes to lectures/mle.md file Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- lectures/mle.md | 559 ++++++++++++++++++++++++++---------------------- 1 file changed, 305 insertions(+), 254 deletions(-) diff --git a/lectures/mle.md b/lectures/mle.md index 6c431ce1c..929eed27c 100644 --- a/lectures/mle.md +++ b/lectures/mle.md @@ -3,8 +3,10 @@ jupytext: text_representation: extension: .md format_name: myst + format_version: 0.13 + jupytext_version: 1.16.7 kernelspec: - display_name: Python 3 + display_name: Python 3 (ipykernel) language: python name: python3 --- @@ -25,7 +27,7 @@ kernelspec: ## Overview -In a {doc}`previous lecture `, we estimated the relationship between +In {doc}`ols`, we estimated the relationship between dependent and explanatory variables using linear regression. But what if a linear relationship is not an appropriate assumption for our model? @@ -42,28 +44,33 @@ economic factors such as market size and tax rate predict. We'll require the following imports: -```{code-cell} ipython -import matplotlib.pyplot as plt + +```{code-cell} ipython3 import numpy as np -from numpy import exp -from scipy.special import factorial, gammaln +import jax.numpy as jnp +import jax import pandas as pd -from mpl_toolkits.mplot3d import Axes3D -import statsmodels.api as sm +from typing import NamedTuple + +from jax.scipy.special import factorial, gammaln +from jax.scipy.stats import norm + from statsmodels.api import Poisson -from scipy.stats import norm from statsmodels.iolib.summary2 import summary_col + +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import Axes3D ``` ### Prerequisites We assume familiarity with basic probability and multivariate calculus. -## Set Up and Assumptions +## Set up and assumptions Let's consider the steps we need to go through in maximum likelihood estimation and how they pertain to this study. -### Flow of Ideas +### Flow of ideas The first step with maximum likelihood estimation is to choose the probability distribution believed to be generating the data. @@ -80,7 +87,7 @@ We'll let the data pick out a particular element of the class by pinning down th The parameter estimates so produced will be called **maximum likelihood estimates**. -### Counting Billionaires +### Counting billionaires Treisman {cite}`Treisman2016` is interested in estimating the number of billionaires in different countries. @@ -89,7 +96,7 @@ The number of billionaires is integer-valued. Hence we consider distributions that take values only in the nonnegative integers. (This is one reason least squares regression is not the best tool for the present problem, since the dependent variable in linear regression is not restricted -to integer values) +to integer values.) One integer distribution is the [Poisson distribution](https://en.wikipedia.org/wiki/Poisson_distribution), the probability mass function (pmf) of which is @@ -100,8 +107,13 @@ $$ We can plot the Poisson distribution over $y$ for different values of $\mu$ as follows -```{code-cell} python3 -poisson_pmf = lambda y, μ: μ**y / factorial(y) * exp(-μ) +```{code-cell} ipython3 +@jax.jit +def poisson_pmf(y, μ): + return μ**y / factorial(y) * jnp.exp(-μ) +``` + +```{code-cell} ipython3 y_values = range(0, 25) fig, ax = plt.subplots(figsize=(12, 8)) @@ -110,16 +122,18 @@ for μ in [1, 5, 10]: distribution = [] for y_i in y_values: distribution.append(poisson_pmf(y_i, μ)) - ax.plot(y_values, - distribution, - label=fr'$\mu$={μ}', - alpha=0.5, - marker='o', - markersize=8) + ax.plot( + y_values, + distribution, + label=rf"$\mu$={μ}", + alpha=0.5, + marker="o", + markersize=8, + ) ax.grid() -ax.set_xlabel('$y$', fontsize=14) -ax.set_ylabel(r'$f(y \mid \mu)$', fontsize=14) +ax.set_xlabel(r"$y$", fontsize=14) +ax.set_ylabel(r"$f(y \mid \mu)$", fontsize=14) ax.axis(xmin=0, ymin=0) ax.legend(fontsize=14) @@ -135,11 +149,11 @@ Treisman's main source of data is *Forbes'* annual rankings of billionaires and The dataset `mle/fp.dta` can be downloaded from [here](https://python.quantecon.org/_static/lecture_specific/mle/fp.dta) or its [AER page](https://www.aeaweb.org/articles?id=10.1257/aer.p20161068). -```{code-cell} python3 -pd.options.display.max_columns = 10 - +```{code-cell} ipython3 # Load in data and view -df = pd.read_stata('https://github.com/QuantEcon/lecture-python.myst/raw/refs/heads/main/lectures/_static/lecture_specific/mle/fp.dta') +df = pd.read_stata( + "https://github.com/QuantEcon/lecture-python.myst/raw/refs/heads/main/lectures/_static/lecture_specific/mle/fp.dta" +) df.head() ``` @@ -147,28 +161,29 @@ Using a histogram, we can view the distribution of the number of billionaires per country, `numbil0`, in 2008 (the United States is dropped for plotting purposes) -```{code-cell} python3 -numbil0_2008 = df[(df['year'] == 2008) & ( - df['country'] != 'United States')].loc[:, 'numbil0'] +```{code-cell} ipython3 +numbil0_2008 = df[ + (df["year"] == 2008) & (df["country"] != "United States") +].loc[:, "numbil0"] plt.subplots(figsize=(12, 8)) plt.hist(numbil0_2008, bins=30) plt.xlim(left=0) plt.grid() -plt.xlabel('Number of billionaires in 2008') -plt.ylabel('Count') +plt.xlabel("Number of billionaires in 2008") +plt.ylabel("Count") plt.show() ``` From the histogram, it appears that the Poisson assumption is not unreasonable (albeit with a very low $\mu$ and some outliers). -## Conditional Distributions +## Conditional distributions In Treisman's paper, the dependent variable --- the number of billionaires $y_i$ in country $i$ --- is modeled as a function of GDP per capita, population size, and years membership in GATT and WTO. Hence, the distribution of $y_i$ needs to be conditioned on the vector of explanatory variables $\mathbf{x}_i$. -The standard formulation --- the so-called *poisson regression* model --- is as follows: +The standard formulation --- the so-called *Poisson regression* model --- is as follows: ```{math} :label: poissonreg @@ -188,37 +203,41 @@ $\mathbf{x}_i$ let's run a simple simulation. We use our `poisson_pmf` function from above and arbitrary values for $\boldsymbol{\beta}$ and $\mathbf{x}_i$ -```{code-cell} python3 +```{code-cell} ipython3 y_values = range(0, 20) # Define a parameter vector with estimates -β = np.array([0.26, 0.18, 0.25, -0.1, -0.22]) +β = jnp.array([0.26, 0.18, 0.25, -0.1, -0.22]) # Create some observations X -datasets = [np.array([0, 1, 1, 1, 2]), - np.array([2, 3, 2, 4, 0]), - np.array([3, 4, 5, 3, 2]), - np.array([6, 5, 4, 4, 7])] +datasets = [ + jnp.array([0, 1, 1, 1, 2]), + jnp.array([2, 3, 2, 4, 0]), + jnp.array([3, 4, 5, 3, 2]), + jnp.array([6, 5, 4, 4, 7]), +] fig, ax = plt.subplots(figsize=(12, 8)) for X in datasets: - μ = exp(X @ β) + μ = jnp.exp(X @ β) distribution = [] for y_i in y_values: distribution.append(poisson_pmf(y_i, μ)) - ax.plot(y_values, - distribution, - label=fr'$\mu_i$={μ:.1}', - marker='o', - markersize=8, - alpha=0.5) + ax.plot( + y_values, + distribution, + label=rf"$\mu_i$={μ:.1}", + marker="o", + markersize=8, + alpha=0.5, + ) ax.grid() ax.legend() -ax.set_xlabel(r'$y \mid x_i$') -ax.set_ylabel(r'$f(y \mid x_i; \beta )$') +ax.set_xlabel(r"$y \mid x_i$") +ax.set_ylabel(r"$f(y \mid x_i; \beta )$") ax.axis(xmin=0, ymin=0) plt.show() ``` @@ -226,7 +245,7 @@ plt.show() We can see that the distribution of $y_i$ is conditional on $\mathbf{x}_i$ ($\mu_i$ is no longer constant). -## Maximum Likelihood Estimation +## Maximum likelihood estimation In our model for number of billionaires, the conditional distribution contains 4 ($k = 4$) parameters that we need to estimate. @@ -258,24 +277,25 @@ data is $f(y_1, y_2) = f(y_1) \cdot f(y_2)$. If $y_i$ follows a Poisson distribution with $\lambda = 7$, we can visualize the joint pmf like so -```{code-cell} python3 +```{code-cell} ipython3 def plot_joint_poisson(μ=7, y_n=20): - yi_values = np.arange(0, y_n, 1) + yi_values = jnp.arange(0, y_n, 1) # Create coordinate points of X and Y - X, Y = np.meshgrid(yi_values, yi_values) + X, Y = jnp.meshgrid(yi_values, yi_values) # Multiply distributions together Z = poisson_pmf(X, μ) * poisson_pmf(Y, μ) fig = plt.figure(figsize=(12, 8)) - ax = fig.add_subplot(111, projection='3d') - ax.plot_surface(X, Y, Z.T, cmap='terrain', alpha=0.6) - ax.scatter(X, Y, Z.T, color='black', alpha=0.5, linewidths=1) - ax.set(xlabel='$y_1$', ylabel='$y_2$') - ax.set_zlabel('$f(y_1, y_2)$', labelpad=10) + ax = fig.add_subplot(111, projection="3d") + ax.plot_surface(X, Y, Z.T, cmap="terrain", alpha=0.6) + ax.scatter(X, Y, Z.T, color="black", alpha=0.5, linewidths=1) + ax.set(xlabel=r"$y_1$", ylabel=r"$y_2$") + ax.set_zlabel(r"$f(y_1, y_2)$", labelpad=10) plt.show() + plot_joint_poisson(μ=7, y_n=20) ``` @@ -309,7 +329,7 @@ $$ $$ In doing so it is generally easier to maximize the log-likelihood (consider -differentiating $f(x) = x \exp(x)$ vs. $f(x) = \log(x) + x$). +differentiating $f(x) = x \exp(x)$ vs. $f(x) = \log(x) + x$). Given that taking a logarithm is a monotone increasing transformation, a maximizer of the likelihood function will also be a maximizer of the log-likelihood function. @@ -337,7 +357,7 @@ $$ \end{split} $$ -The MLE of the Poisson to the Poisson for $\hat{\beta}$ can be obtained by solving +The MLE of the Poisson for $\hat{\beta}$ can be obtained by solving $$ \underset{\beta}{\max} \Big( @@ -349,7 +369,7 @@ $$ However, no analytical solution exists to the above problem -- to find the MLE we need to use numerical methods. -## MLE with Numerical Methods +## MLE with numerical methods Many distributions do not have nice, analytical solutions and therefore require numerical methods to solve for parameter estimates. @@ -367,27 +387,40 @@ $$ \log \mathcal{L(\beta)} = - (\beta - 10) ^2 - 10 $$ -```{code-cell} python3 -β = np.linspace(1, 20) -logL = -(β - 10) ** 2 - 10 -dlogL = -2 * β + 20 +```{code-cell} ipython3 +@jax.jit +def logL(β): + return -((β - 10) ** 2) - 10 +``` + +To find the value of the gradient of the above function, we can use [jax.grad](https://jax.readthedocs.io/en/latest/_autosummary/jax.grad.html) which auto-differentiates the given function. + +We further use [jax.vmap](https://jax.readthedocs.io/en/latest/_autosummary/jax.vmap.html) which vectorizes the given function i.e. the function acting upon scalar inputs can now be used with vector inputs. + +```{code-cell} ipython3 +dlogL = jax.vmap(jax.grad(logL)) +``` + +```{code-cell} ipython3 +β = jnp.linspace(1, 20) fig, (ax1, ax2) = plt.subplots(2, sharex=True, figsize=(12, 8)) -ax1.plot(β, logL, lw=2) -ax2.plot(β, dlogL, lw=2) - -ax1.set_ylabel(r'$log \mathcal{L(\beta)}$', - rotation=0, - labelpad=35, - fontsize=15) -ax2.set_ylabel(r'$\frac{dlog \mathcal{L(\beta)}}{d \beta}$ ', - rotation=0, - labelpad=35, - fontsize=19) -ax2.set_xlabel(r'$\beta$', fontsize=15) +ax1.plot(β, logL(β), lw=2) +ax2.plot(β, dlogL(β), lw=2) + +ax1.set_ylabel( + r"$log \mathcal{L(\beta)}$", rotation=0, labelpad=35, fontsize=15 +) +ax2.set_ylabel( + r"$\frac{dlog \mathcal{L(\beta)}}{d \beta}$ ", + rotation=0, + labelpad=35, + fontsize=19, +) +ax2.set_xlabel(r"$\beta$", fontsize=15) ax1.grid(), ax2.grid() -plt.axhline(c='black') +plt.axhline(c="black") plt.show() ``` @@ -422,17 +455,17 @@ guess), then \end{aligned} $$ -1. Check whether $\boldsymbol{\beta}_{(k+1)} - \boldsymbol{\beta}_{(k)} < tol$ +2. Check whether $\boldsymbol{\beta}_{(k+1)} - \boldsymbol{\beta}_{(k)} < tol$ - If true, then stop iterating and set $\hat{\boldsymbol{\beta}} = \boldsymbol{\beta}_{(k+1)}$ - If false, then update $\boldsymbol{\beta}_{(k+1)}$ As can be seen from the updating equation, $\boldsymbol{\beta}_{(k+1)} = \boldsymbol{\beta}_{(k)}$ only when -$G(\boldsymbol{\beta}_{(k)}) = 0$ ie. where the first derivative is equal to 0. +$G(\boldsymbol{\beta}_{(k)}) = 0$ i.e. where the first derivative is equal to 0. (In practice, we stop iterating when the difference is below a small -tolerance threshold) +tolerance threshold.) Let's have a go at implementing the Newton-Raphson algorithm. @@ -440,34 +473,36 @@ First, we'll create a class called `PoissonRegression` so we can easily recompute the values of the log likelihood, gradient and Hessian for every iteration -```{code-cell} python3 -class PoissonRegression: +```{code-cell} ipython3 +class PoissonRegression(NamedTuple): + X: jnp.ndarray + y: jnp.ndarray +``` + +Now we can define the log likelihood function in Python + +```{code-cell} ipython3 +@jax.jit +def logL(β, model): + y = model.y + μ = jnp.exp(model.X @ β) + return jnp.sum(model.y * jnp.log(μ) - μ - jnp.log(factorial(y))) +``` + +To find the gradient of the `poisson_logL`, we again use [jax.grad](https://jax.readthedocs.io/en/latest/_autosummary/jax.grad.html). - def __init__(self, y, X, β): - self.X = X - self.n, self.k = X.shape - # Reshape y as a n_by_1 column vector - self.y = y.reshape(self.n,1) - # Reshape β as a k_by_1 column vector - self.β = β.reshape(self.k,1) +According to [the documentation](https://jax.readthedocs.io/en/latest/notebooks/autodiff_cookbook.html#jacobians-and-hessians-using-jacfwd-and-jacrev), - def μ(self): - return np.exp(self.X @ self.β) +* `jax.jacfwd` uses forward-mode automatic differentiation, which is more efficient for “tall” Jacobian matrices, while +* `jax.jacrev` uses reverse-mode, which is more efficient for “wide” Jacobian matrices. - def logL(self): - y = self.y - μ = self.μ() - return np.sum(y * np.log(μ) - μ - gammaln(y + 1)) +(The documentation also states that when matrices that are near-square, `jax.jacfwd` probably has an edge over `jax.jacrev`.) - def G(self): - y = self.y - μ = self.μ() - return X.T @ (y - μ) +Therefore, to find the Hessian, we can directly use `jax.jacfwd`. - def H(self): - X = self.X - μ = self.μ() - return -(X.T @ (μ * X)) +```{code-cell} ipython3 +G_logL = jax.grad(logL) +H_logL = jax.jacfwd(G_logL) ``` Our function `newton_raphson` will take a `PoissonRegression` object @@ -486,8 +521,8 @@ So we can get an idea of what's going on while the algorithm is running, an option `display=True` is added to print out values at each iteration. -```{code-cell} python3 -def newton_raphson(model, tol=1e-3, max_iter=1000, display=True): +```{code-cell} ipython3 +def newton_raphson(model, β, tol=1e-3, max_iter=100, display=True): i = 0 error = 100 # Initial error value @@ -500,47 +535,41 @@ def newton_raphson(model, tol=1e-3, max_iter=1000, display=True): # While loop runs while any value in error is greater # than the tolerance until max iterations are reached - while np.any(error > tol) and i < max_iter: - H, G = model.H(), model.G() - β_new = model.β - (np.linalg.inv(H) @ G) - error = np.abs(β_new - model.β) - model.β = β_new + while jnp.any(error > tol) and i < max_iter: + H, G = jnp.squeeze(H_logL(β, model)), G_logL(β, model) + β_new = β - (jnp.dot(jnp.linalg.inv(H), G)) + error = jnp.abs(β_new - β) + β = β_new - # Print iterations if display: - β_list = [f'{t:.3}' for t in list(model.β.flatten())] - update = f'{i:<13}{model.logL():<16.8}{β_list}' + β_list = [f"{t:.3}" for t in list(β.flatten())] + update = f"{i:<13}{logL(β, model):<16.8}{β_list}" print(update) i += 1 - print(f'Number of iterations: {i}') - print(f'β_hat = {model.β.flatten()}') + print(f"Number of iterations: {i}") + print(f"β_hat = {β.flatten()}") - # Return a flat array for β (instead of a k_by_1 column vector) - return model.β.flatten() + return β ``` Let's try out our algorithm with a small dataset of 5 observations and 3 variables in $\mathbf{X}$. -```{code-cell} python3 -X = np.array([[1, 2, 5], - [1, 1, 3], - [1, 4, 2], - [1, 5, 2], - [1, 3, 1]]) +```{code-cell} ipython3 +X = jnp.array([[1, 2, 5], [1, 1, 3], [1, 4, 2], [1, 5, 2], [1, 3, 1]]) -y = np.array([1, 0, 1, 1, 0]) +y = jnp.array([1, 0, 1, 1, 0]) # Take a guess at initial βs -init_β = np.array([0.1, 0.1, 0.1]) +init_β = jnp.array([0.1, 0.1, 0.1]) # Create an object with Poisson model values -poi = PoissonRegression(y, X, β=init_β) +poi = PoissonRegression(X=X, y=y) # Use newton_raphson to find the MLE -β_hat = newton_raphson(poi, display=True) +β_hat = newton_raphson(poi, init_β, display=True) ``` As this was a simple model with few observations, the algorithm achieved @@ -559,45 +588,49 @@ and therefore the numerator in our updating equation is becoming smaller. The gradient vector should be close to 0 at $\hat{\boldsymbol{\beta}}$ -```{code-cell} python3 -poi.G() +```{code-cell} ipython3 +G_logL(β_hat, poi) ``` The iterative process can be visualized in the following diagram, where the maximum is found at $\beta = 10$ -```{code-cell} python3 ---- -tags: [output_scroll] ---- -logL = lambda x: -(x - 10) ** 2 - 10 +```{code-cell} ipython3 +@jax.jit +def logL(x): + return -((x - 10) ** 2) - 10 + +@jax.jit def find_tangent(β, a=0.01): y1 = logL(β) - y2 = logL(β+a) - x = np.array([[β, 1], [β+a, 1]]) - m, c = np.linalg.lstsq(x, np.array([y1, y2]), rcond=None)[0] + y2 = logL(β + a) + x = jnp.array([[β, 1], [β + a, 1]]) + m, c = jnp.linalg.lstsq(x, jnp.array([y1, y2]), rcond=None)[0] return m, c +``` -β = np.linspace(2, 18) +```{code-cell} ipython3 +:tags: [output_scroll] + +β = jnp.linspace(2, 18) fig, ax = plt.subplots(figsize=(12, 8)) -ax.plot(β, logL(β), lw=2, c='black') +ax.plot(β, logL(β), lw=2, c="black") for β in [7, 8.5, 9.5, 10]: - β_line = np.linspace(β-2, β+2) + β_line = jnp.linspace(β - 2, β + 2) m, c = find_tangent(β) y = m * β_line + c - ax.plot(β_line, y, '-', c='purple', alpha=0.8) - ax.text(β+2.05, y[-1], f'$G({β}) = {abs(m):.0f}$', fontsize=12) - ax.vlines(β, -24, logL(β), linestyles='--', alpha=0.5) - ax.hlines(logL(β), 6, β, linestyles='--', alpha=0.5) + ax.plot(β_line, y, "-", c="purple", alpha=0.8) + ax.text(β + 2.05, y[-1], rf"$G({β}) = {abs(m):.0f}$", fontsize=12) + ax.vlines(β, -24, logL(β), linestyles="--", alpha=0.5) + ax.hlines(logL(β), 6, β, linestyles="--", alpha=0.5) ax.set(ylim=(-24, -4), xlim=(6, 13)) -ax.set_xlabel(r'$\beta$', fontsize=15) -ax.set_ylabel(r'$log \mathcal{L(\beta)}$', - rotation=0, - labelpad=25, - fontsize=15) +ax.set_xlabel(r"$\beta$", fontsize=15) +ax.set_ylabel( + r"$log \mathcal{L(\beta)}$", rotation=0, labelpad=25, fontsize=15 +) ax.grid(alpha=0.3) plt.show() ``` @@ -606,7 +639,7 @@ Note that our implementation of the Newton-Raphson algorithm is rather basic --- for more robust implementations see, for example, [scipy.optimize](https://docs.scipy.org/doc/scipy/reference/optimize.html). -## Maximum Likelihood Estimation with `statsmodels` +## Maximum likelihood estimation with `statsmodels` Now that we know what's going on under the hood, we can apply MLE to an interesting application. @@ -619,16 +652,17 @@ likelihood estimates. Before we begin, let's re-estimate our simple model with `statsmodels` to confirm we obtain the same coefficients and log-likelihood value. -```{code-cell} python3 -X = np.array([[1, 2, 5], - [1, 1, 3], - [1, 4, 2], - [1, 5, 2], - [1, 3, 1]]) +Now, as `statsmodels` accepts only NumPy arrays, we can use `np.array` method +to convert them to NumPy arrays. + +```{code-cell} ipython3 +X = jnp.array([[1, 2, 5], [1, 1, 3], [1, 4, 2], [1, 5, 2], [1, 3, 1]]) -y = np.array([1, 0, 1, 1, 0]) +y = jnp.array([1, 0, 1, 1, 0]) -stats_poisson = Poisson(y, X).fit() +y_numpy = np.array(y) +X_numpy = np.array(X) +stats_poisson = Poisson(y_numpy, X_numpy).fit() print(stats_poisson.summary()) ``` @@ -648,19 +682,35 @@ The paper only considers the year 2008 for estimation. We will set up our variables for estimation like so (you should have the data assigned to `df` from earlier in the lecture) -```{code-cell} python3 +```{code-cell} ipython3 # Keep only year 2008 -df = df[df['year'] == 2008] +df = df[df["year"] == 2008] # Add a constant -df['const'] = 1 +df["const"] = 1 # Variable sets -reg1 = ['const', 'lngdppc', 'lnpop', 'gattwto08'] -reg2 = ['const', 'lngdppc', 'lnpop', - 'gattwto08', 'lnmcap08', 'rintr', 'topint08'] -reg3 = ['const', 'lngdppc', 'lnpop', 'gattwto08', 'lnmcap08', - 'rintr', 'topint08', 'nrrents', 'roflaw'] +reg1 = ["const", "lngdppc", "lnpop", "gattwto08"] +reg2 = [ + "const", + "lngdppc", + "lnpop", + "gattwto08", + "lnmcap08", + "rintr", + "topint08", +] +reg3 = [ + "const", + "lngdppc", + "lnpop", + "gattwto08", + "lnmcap08", + "rintr", + "topint08", + "nrrents", + "roflaw", +] ``` Then we can use the `Poisson` function from `statsmodels` to fit the @@ -668,10 +718,11 @@ model. We'll use robust standard errors as in the author's paper -```{code-cell} python3 +```{code-cell} ipython3 # Specify model -poisson_reg = sm.Poisson(df[['numbil0']], df[reg1], - missing='drop').fit(cov_type='HC0') +poisson_reg = Poisson(df[["numbil0"]], df[reg1], missing="drop").fit( + cov_type="HC0" +) print(poisson_reg.summary()) ``` @@ -685,36 +736,44 @@ expected. Let's also estimate the author's more full-featured models and display them in a single table -```{code-cell} python3 +```{code-cell} ipython3 regs = [reg1, reg2, reg3] -reg_names = ['Model 1', 'Model 2', 'Model 3'] -info_dict = {'Pseudo R-squared': lambda x: f"{x.prsquared:.2f}", - 'No. observations': lambda x: f"{int(x.nobs):d}"} -regressor_order = ['const', - 'lngdppc', - 'lnpop', - 'gattwto08', - 'lnmcap08', - 'rintr', - 'topint08', - 'nrrents', - 'roflaw'] +reg_names = ["Model 1", "Model 2", "Model 3"] +info_dict = { + "Pseudo R-squared": lambda x: f"{x.prsquared:.2f}", + "No. observations": lambda x: f"{int(x.nobs):d}", +} +regressor_order = [ + "const", + "lngdppc", + "lnpop", + "gattwto08", + "lnmcap08", + "rintr", + "topint08", + "nrrents", + "roflaw", +] results = [] for reg in regs: - result = sm.Poisson(df[['numbil0']], df[reg], - missing='drop').fit(cov_type='HC0', - maxiter=100, disp=0) + result = Poisson(df[["numbil0"]], df[reg], missing="drop").fit( + cov_type="HC0", maxiter=100, disp=0 + ) results.append(result) -results_table = summary_col(results=results, - float_format='%0.3f', - stars=True, - model_names=reg_names, - info_dict=info_dict, - regressor_order=regressor_order) -results_table.add_title('Table 1 - Explaining the Number of Billionaires \ - in 2008') +results_table = summary_col( + results=results, + float_format="%0.3f", + stars=True, + model_names=reg_names, + info_dict=info_dict, + regressor_order=regressor_order, +) +results_table.add_title( + "Table 1 - Explaining the Number of Billionaires \ + in 2008" +) print(results_table) ``` @@ -724,28 +783,40 @@ capitalization, and negatively correlated with top marginal income tax rate. To analyze our results by country, we can plot the difference between -the predicted an actual values, then sort from highest to lowest and +the predicted and actual values, then sort from highest to lowest and plot the first 15 -```{code-cell} python3 -data = ['const', 'lngdppc', 'lnpop', 'gattwto08', 'lnmcap08', 'rintr', - 'topint08', 'nrrents', 'roflaw', 'numbil0', 'country'] +```{code-cell} ipython3 +data = [ + "const", + "lngdppc", + "lnpop", + "gattwto08", + "lnmcap08", + "rintr", + "topint08", + "nrrents", + "roflaw", + "numbil0", + "country", +] results_df = df[data].dropna() # Use last model (model 3) -results_df['prediction'] = results[-1].predict() +results_df["prediction"] = results[-1].predict() # Calculate difference -results_df['difference'] = results_df['numbil0'] - results_df['prediction'] +results_df["difference"] = results_df["numbil0"] - results_df["prediction"] # Sort in descending order -results_df.sort_values('difference', ascending=False, inplace=True) +results_df.sort_values("difference", ascending=False, inplace=True) # Plot the first 15 data points -results_df[:15].plot('country', 'difference', kind='bar', - figsize=(12,8), legend=False) -plt.ylabel('Number of billionaires above predicted level') -plt.xlabel('Country') +results_df[:15].plot( + "country", "difference", kind="bar", figsize=(12, 8), legend=False +) +plt.ylabel("Number of billionaires above predicted level") +plt.xlabel("Country") plt.show() ``` @@ -802,8 +873,8 @@ Probit model. To begin, find the log-likelihood function and derive the gradient and Hessian. -The `scipy` module `stats.norm` contains the functions needed to -compute the cmf and pmf of the normal distribution. +The `jax.scipy.stats` module `norm` contains the functions needed to +compute the cdf and pdf of the normal distribution. ``` ```{solution-start} mle_ex1 @@ -853,40 +924,23 @@ $$ Using these results, we can write a class for the Probit model as follows -```{code-cell} python3 -class ProbitRegression: - - def __init__(self, y, X, β): - self.X, self.y, self.β = X, y, β - self.n, self.k = X.shape - - def μ(self): - return norm.cdf(self.X @ self.β.T) - - def ϕ(self): - return norm.pdf(self.X @ self.β.T) - - def logL(self): - y = self.y - μ = self.μ() - return y @ np.log(μ) + (1 - y) @ np.log(1 - μ) +```{code-cell} ipython3 +class ProbitRegression(NamedTuple): + X: jnp.ndarray + y: jnp.ndarray +``` - def G(self): - X = self.X - y = self.y - μ = self.μ() - ϕ = self.ϕ() - return X.T @ (y * ϕ / μ - (1 - y) * ϕ / (1 - μ)) +```{code-cell} ipython3 +@jax.jit +def logL(β, model): + y = model.y + μ = norm.cdf(model.X @ β.T) + return y @ jnp.log(μ) + (1 - y) @ jnp.log(1 - μ) +``` - def H(self): - X = self.X - y = self.y - β = self.β - μ = self.μ() - ϕ = self.ϕ() - a = (ϕ + (X @ β.T) * μ) / μ**2 - b = (ϕ - (X @ β.T) * (1 - μ)) / (1 - μ)**2 - return -(ϕ * (y * a + (1 - y) * b) * X.T) @ X +```{code-cell} ipython3 +G_logL = jax.grad(logL) +H_logL = jax.jacfwd(G_logL) ``` ```{solution-end} @@ -930,7 +984,7 @@ $$ Verify your results with `statsmodels` - you can import the Probit function with the following import statement -```{code-cell} python3 +```{code-cell} ipython3 from statsmodels.discrete.discrete_model import Probit ``` @@ -947,29 +1001,26 @@ achieve convergence with different starting values. Here is one solution -```{code-cell} python3 -X = np.array([[1, 2, 4], - [1, 1, 1], - [1, 4, 3], - [1, 5, 6], - [1, 3, 5]]) +```{code-cell} ipython3 +X = jnp.array([[1, 2, 4], [1, 1, 1], [1, 4, 3], [1, 5, 6], [1, 3, 5]]) -y = np.array([1, 0, 1, 1, 0]) +y = jnp.array([1, 0, 1, 1, 0]) # Take a guess at initial βs -β = np.array([0.1, 0.1, 0.1]) +β = jnp.array([0.1, 0.1, 0.1]) -# Create instance of Probit regression class -prob = ProbitRegression(y, X, β) +# Create a model of Probit regression +prob = ProbitRegression(y=y, X=X) # Run Newton-Raphson algorithm -newton_raphson(prob) +newton_raphson(prob, β) ``` -```{code-cell} python3 +```{code-cell} ipython3 # Use statsmodels to verify results - -print(Probit(y, X).fit().summary()) +y_numpy = np.array(y) +X_numpy = np.array(X) +print(Probit(y_numpy, X_numpy).fit().summary()) ``` ```{solution-end} From a2c83f43698dd91a71dab828ce49f21b07636969 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 05:25:46 +0000 Subject: [PATCH 24/25] Revert modifications to lectures/mle.md from commit b5dedf7 Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- lectures/mle.md | 559 ++++++++++++++++++++++-------------------------- 1 file changed, 254 insertions(+), 305 deletions(-) diff --git a/lectures/mle.md b/lectures/mle.md index 929eed27c..6c431ce1c 100644 --- a/lectures/mle.md +++ b/lectures/mle.md @@ -3,10 +3,8 @@ jupytext: text_representation: extension: .md format_name: myst - format_version: 0.13 - jupytext_version: 1.16.7 kernelspec: - display_name: Python 3 (ipykernel) + display_name: Python 3 language: python name: python3 --- @@ -27,7 +25,7 @@ kernelspec: ## Overview -In {doc}`ols`, we estimated the relationship between +In a {doc}`previous lecture `, we estimated the relationship between dependent and explanatory variables using linear regression. But what if a linear relationship is not an appropriate assumption for our model? @@ -44,33 +42,28 @@ economic factors such as market size and tax rate predict. We'll require the following imports: - -```{code-cell} ipython3 +```{code-cell} ipython +import matplotlib.pyplot as plt import numpy as np -import jax.numpy as jnp -import jax +from numpy import exp +from scipy.special import factorial, gammaln import pandas as pd -from typing import NamedTuple - -from jax.scipy.special import factorial, gammaln -from jax.scipy.stats import norm - +from mpl_toolkits.mplot3d import Axes3D +import statsmodels.api as sm from statsmodels.api import Poisson +from scipy.stats import norm from statsmodels.iolib.summary2 import summary_col - -import matplotlib.pyplot as plt -from mpl_toolkits.mplot3d import Axes3D ``` ### Prerequisites We assume familiarity with basic probability and multivariate calculus. -## Set up and assumptions +## Set Up and Assumptions Let's consider the steps we need to go through in maximum likelihood estimation and how they pertain to this study. -### Flow of ideas +### Flow of Ideas The first step with maximum likelihood estimation is to choose the probability distribution believed to be generating the data. @@ -87,7 +80,7 @@ We'll let the data pick out a particular element of the class by pinning down th The parameter estimates so produced will be called **maximum likelihood estimates**. -### Counting billionaires +### Counting Billionaires Treisman {cite}`Treisman2016` is interested in estimating the number of billionaires in different countries. @@ -96,7 +89,7 @@ The number of billionaires is integer-valued. Hence we consider distributions that take values only in the nonnegative integers. (This is one reason least squares regression is not the best tool for the present problem, since the dependent variable in linear regression is not restricted -to integer values.) +to integer values) One integer distribution is the [Poisson distribution](https://en.wikipedia.org/wiki/Poisson_distribution), the probability mass function (pmf) of which is @@ -107,13 +100,8 @@ $$ We can plot the Poisson distribution over $y$ for different values of $\mu$ as follows -```{code-cell} ipython3 -@jax.jit -def poisson_pmf(y, μ): - return μ**y / factorial(y) * jnp.exp(-μ) -``` - -```{code-cell} ipython3 +```{code-cell} python3 +poisson_pmf = lambda y, μ: μ**y / factorial(y) * exp(-μ) y_values = range(0, 25) fig, ax = plt.subplots(figsize=(12, 8)) @@ -122,18 +110,16 @@ for μ in [1, 5, 10]: distribution = [] for y_i in y_values: distribution.append(poisson_pmf(y_i, μ)) - ax.plot( - y_values, - distribution, - label=rf"$\mu$={μ}", - alpha=0.5, - marker="o", - markersize=8, - ) + ax.plot(y_values, + distribution, + label=fr'$\mu$={μ}', + alpha=0.5, + marker='o', + markersize=8) ax.grid() -ax.set_xlabel(r"$y$", fontsize=14) -ax.set_ylabel(r"$f(y \mid \mu)$", fontsize=14) +ax.set_xlabel('$y$', fontsize=14) +ax.set_ylabel(r'$f(y \mid \mu)$', fontsize=14) ax.axis(xmin=0, ymin=0) ax.legend(fontsize=14) @@ -149,11 +135,11 @@ Treisman's main source of data is *Forbes'* annual rankings of billionaires and The dataset `mle/fp.dta` can be downloaded from [here](https://python.quantecon.org/_static/lecture_specific/mle/fp.dta) or its [AER page](https://www.aeaweb.org/articles?id=10.1257/aer.p20161068). -```{code-cell} ipython3 +```{code-cell} python3 +pd.options.display.max_columns = 10 + # Load in data and view -df = pd.read_stata( - "https://github.com/QuantEcon/lecture-python.myst/raw/refs/heads/main/lectures/_static/lecture_specific/mle/fp.dta" -) +df = pd.read_stata('https://github.com/QuantEcon/lecture-python.myst/raw/refs/heads/main/lectures/_static/lecture_specific/mle/fp.dta') df.head() ``` @@ -161,29 +147,28 @@ Using a histogram, we can view the distribution of the number of billionaires per country, `numbil0`, in 2008 (the United States is dropped for plotting purposes) -```{code-cell} ipython3 -numbil0_2008 = df[ - (df["year"] == 2008) & (df["country"] != "United States") -].loc[:, "numbil0"] +```{code-cell} python3 +numbil0_2008 = df[(df['year'] == 2008) & ( + df['country'] != 'United States')].loc[:, 'numbil0'] plt.subplots(figsize=(12, 8)) plt.hist(numbil0_2008, bins=30) plt.xlim(left=0) plt.grid() -plt.xlabel("Number of billionaires in 2008") -plt.ylabel("Count") +plt.xlabel('Number of billionaires in 2008') +plt.ylabel('Count') plt.show() ``` From the histogram, it appears that the Poisson assumption is not unreasonable (albeit with a very low $\mu$ and some outliers). -## Conditional distributions +## Conditional Distributions In Treisman's paper, the dependent variable --- the number of billionaires $y_i$ in country $i$ --- is modeled as a function of GDP per capita, population size, and years membership in GATT and WTO. Hence, the distribution of $y_i$ needs to be conditioned on the vector of explanatory variables $\mathbf{x}_i$. -The standard formulation --- the so-called *Poisson regression* model --- is as follows: +The standard formulation --- the so-called *poisson regression* model --- is as follows: ```{math} :label: poissonreg @@ -203,41 +188,37 @@ $\mathbf{x}_i$ let's run a simple simulation. We use our `poisson_pmf` function from above and arbitrary values for $\boldsymbol{\beta}$ and $\mathbf{x}_i$ -```{code-cell} ipython3 +```{code-cell} python3 y_values = range(0, 20) # Define a parameter vector with estimates -β = jnp.array([0.26, 0.18, 0.25, -0.1, -0.22]) +β = np.array([0.26, 0.18, 0.25, -0.1, -0.22]) # Create some observations X -datasets = [ - jnp.array([0, 1, 1, 1, 2]), - jnp.array([2, 3, 2, 4, 0]), - jnp.array([3, 4, 5, 3, 2]), - jnp.array([6, 5, 4, 4, 7]), -] +datasets = [np.array([0, 1, 1, 1, 2]), + np.array([2, 3, 2, 4, 0]), + np.array([3, 4, 5, 3, 2]), + np.array([6, 5, 4, 4, 7])] fig, ax = plt.subplots(figsize=(12, 8)) for X in datasets: - μ = jnp.exp(X @ β) + μ = exp(X @ β) distribution = [] for y_i in y_values: distribution.append(poisson_pmf(y_i, μ)) - ax.plot( - y_values, - distribution, - label=rf"$\mu_i$={μ:.1}", - marker="o", - markersize=8, - alpha=0.5, - ) + ax.plot(y_values, + distribution, + label=fr'$\mu_i$={μ:.1}', + marker='o', + markersize=8, + alpha=0.5) ax.grid() ax.legend() -ax.set_xlabel(r"$y \mid x_i$") -ax.set_ylabel(r"$f(y \mid x_i; \beta )$") +ax.set_xlabel(r'$y \mid x_i$') +ax.set_ylabel(r'$f(y \mid x_i; \beta )$') ax.axis(xmin=0, ymin=0) plt.show() ``` @@ -245,7 +226,7 @@ plt.show() We can see that the distribution of $y_i$ is conditional on $\mathbf{x}_i$ ($\mu_i$ is no longer constant). -## Maximum likelihood estimation +## Maximum Likelihood Estimation In our model for number of billionaires, the conditional distribution contains 4 ($k = 4$) parameters that we need to estimate. @@ -277,25 +258,24 @@ data is $f(y_1, y_2) = f(y_1) \cdot f(y_2)$. If $y_i$ follows a Poisson distribution with $\lambda = 7$, we can visualize the joint pmf like so -```{code-cell} ipython3 +```{code-cell} python3 def plot_joint_poisson(μ=7, y_n=20): - yi_values = jnp.arange(0, y_n, 1) + yi_values = np.arange(0, y_n, 1) # Create coordinate points of X and Y - X, Y = jnp.meshgrid(yi_values, yi_values) + X, Y = np.meshgrid(yi_values, yi_values) # Multiply distributions together Z = poisson_pmf(X, μ) * poisson_pmf(Y, μ) fig = plt.figure(figsize=(12, 8)) - ax = fig.add_subplot(111, projection="3d") - ax.plot_surface(X, Y, Z.T, cmap="terrain", alpha=0.6) - ax.scatter(X, Y, Z.T, color="black", alpha=0.5, linewidths=1) - ax.set(xlabel=r"$y_1$", ylabel=r"$y_2$") - ax.set_zlabel(r"$f(y_1, y_2)$", labelpad=10) + ax = fig.add_subplot(111, projection='3d') + ax.plot_surface(X, Y, Z.T, cmap='terrain', alpha=0.6) + ax.scatter(X, Y, Z.T, color='black', alpha=0.5, linewidths=1) + ax.set(xlabel='$y_1$', ylabel='$y_2$') + ax.set_zlabel('$f(y_1, y_2)$', labelpad=10) plt.show() - plot_joint_poisson(μ=7, y_n=20) ``` @@ -329,7 +309,7 @@ $$ $$ In doing so it is generally easier to maximize the log-likelihood (consider -differentiating $f(x) = x \exp(x)$ vs. $f(x) = \log(x) + x$). +differentiating $f(x) = x \exp(x)$ vs. $f(x) = \log(x) + x$). Given that taking a logarithm is a monotone increasing transformation, a maximizer of the likelihood function will also be a maximizer of the log-likelihood function. @@ -357,7 +337,7 @@ $$ \end{split} $$ -The MLE of the Poisson for $\hat{\beta}$ can be obtained by solving +The MLE of the Poisson to the Poisson for $\hat{\beta}$ can be obtained by solving $$ \underset{\beta}{\max} \Big( @@ -369,7 +349,7 @@ $$ However, no analytical solution exists to the above problem -- to find the MLE we need to use numerical methods. -## MLE with numerical methods +## MLE with Numerical Methods Many distributions do not have nice, analytical solutions and therefore require numerical methods to solve for parameter estimates. @@ -387,40 +367,27 @@ $$ \log \mathcal{L(\beta)} = - (\beta - 10) ^2 - 10 $$ -```{code-cell} ipython3 -@jax.jit -def logL(β): - return -((β - 10) ** 2) - 10 -``` - -To find the value of the gradient of the above function, we can use [jax.grad](https://jax.readthedocs.io/en/latest/_autosummary/jax.grad.html) which auto-differentiates the given function. - -We further use [jax.vmap](https://jax.readthedocs.io/en/latest/_autosummary/jax.vmap.html) which vectorizes the given function i.e. the function acting upon scalar inputs can now be used with vector inputs. - -```{code-cell} ipython3 -dlogL = jax.vmap(jax.grad(logL)) -``` - -```{code-cell} ipython3 -β = jnp.linspace(1, 20) +```{code-cell} python3 +β = np.linspace(1, 20) +logL = -(β - 10) ** 2 - 10 +dlogL = -2 * β + 20 fig, (ax1, ax2) = plt.subplots(2, sharex=True, figsize=(12, 8)) -ax1.plot(β, logL(β), lw=2) -ax2.plot(β, dlogL(β), lw=2) - -ax1.set_ylabel( - r"$log \mathcal{L(\beta)}$", rotation=0, labelpad=35, fontsize=15 -) -ax2.set_ylabel( - r"$\frac{dlog \mathcal{L(\beta)}}{d \beta}$ ", - rotation=0, - labelpad=35, - fontsize=19, -) -ax2.set_xlabel(r"$\beta$", fontsize=15) +ax1.plot(β, logL, lw=2) +ax2.plot(β, dlogL, lw=2) + +ax1.set_ylabel(r'$log \mathcal{L(\beta)}$', + rotation=0, + labelpad=35, + fontsize=15) +ax2.set_ylabel(r'$\frac{dlog \mathcal{L(\beta)}}{d \beta}$ ', + rotation=0, + labelpad=35, + fontsize=19) +ax2.set_xlabel(r'$\beta$', fontsize=15) ax1.grid(), ax2.grid() -plt.axhline(c="black") +plt.axhline(c='black') plt.show() ``` @@ -455,17 +422,17 @@ guess), then \end{aligned} $$ -2. Check whether $\boldsymbol{\beta}_{(k+1)} - \boldsymbol{\beta}_{(k)} < tol$ +1. Check whether $\boldsymbol{\beta}_{(k+1)} - \boldsymbol{\beta}_{(k)} < tol$ - If true, then stop iterating and set $\hat{\boldsymbol{\beta}} = \boldsymbol{\beta}_{(k+1)}$ - If false, then update $\boldsymbol{\beta}_{(k+1)}$ As can be seen from the updating equation, $\boldsymbol{\beta}_{(k+1)} = \boldsymbol{\beta}_{(k)}$ only when -$G(\boldsymbol{\beta}_{(k)}) = 0$ i.e. where the first derivative is equal to 0. +$G(\boldsymbol{\beta}_{(k)}) = 0$ ie. where the first derivative is equal to 0. (In practice, we stop iterating when the difference is below a small -tolerance threshold.) +tolerance threshold) Let's have a go at implementing the Newton-Raphson algorithm. @@ -473,36 +440,34 @@ First, we'll create a class called `PoissonRegression` so we can easily recompute the values of the log likelihood, gradient and Hessian for every iteration -```{code-cell} ipython3 -class PoissonRegression(NamedTuple): - X: jnp.ndarray - y: jnp.ndarray -``` - -Now we can define the log likelihood function in Python - -```{code-cell} ipython3 -@jax.jit -def logL(β, model): - y = model.y - μ = jnp.exp(model.X @ β) - return jnp.sum(model.y * jnp.log(μ) - μ - jnp.log(factorial(y))) -``` - -To find the gradient of the `poisson_logL`, we again use [jax.grad](https://jax.readthedocs.io/en/latest/_autosummary/jax.grad.html). +```{code-cell} python3 +class PoissonRegression: -According to [the documentation](https://jax.readthedocs.io/en/latest/notebooks/autodiff_cookbook.html#jacobians-and-hessians-using-jacfwd-and-jacrev), + def __init__(self, y, X, β): + self.X = X + self.n, self.k = X.shape + # Reshape y as a n_by_1 column vector + self.y = y.reshape(self.n,1) + # Reshape β as a k_by_1 column vector + self.β = β.reshape(self.k,1) -* `jax.jacfwd` uses forward-mode automatic differentiation, which is more efficient for “tall” Jacobian matrices, while -* `jax.jacrev` uses reverse-mode, which is more efficient for “wide” Jacobian matrices. + def μ(self): + return np.exp(self.X @ self.β) -(The documentation also states that when matrices that are near-square, `jax.jacfwd` probably has an edge over `jax.jacrev`.) + def logL(self): + y = self.y + μ = self.μ() + return np.sum(y * np.log(μ) - μ - gammaln(y + 1)) -Therefore, to find the Hessian, we can directly use `jax.jacfwd`. + def G(self): + y = self.y + μ = self.μ() + return X.T @ (y - μ) -```{code-cell} ipython3 -G_logL = jax.grad(logL) -H_logL = jax.jacfwd(G_logL) + def H(self): + X = self.X + μ = self.μ() + return -(X.T @ (μ * X)) ``` Our function `newton_raphson` will take a `PoissonRegression` object @@ -521,8 +486,8 @@ So we can get an idea of what's going on while the algorithm is running, an option `display=True` is added to print out values at each iteration. -```{code-cell} ipython3 -def newton_raphson(model, β, tol=1e-3, max_iter=100, display=True): +```{code-cell} python3 +def newton_raphson(model, tol=1e-3, max_iter=1000, display=True): i = 0 error = 100 # Initial error value @@ -535,41 +500,47 @@ def newton_raphson(model, β, tol=1e-3, max_iter=100, display=True): # While loop runs while any value in error is greater # than the tolerance until max iterations are reached - while jnp.any(error > tol) and i < max_iter: - H, G = jnp.squeeze(H_logL(β, model)), G_logL(β, model) - β_new = β - (jnp.dot(jnp.linalg.inv(H), G)) - error = jnp.abs(β_new - β) - β = β_new + while np.any(error > tol) and i < max_iter: + H, G = model.H(), model.G() + β_new = model.β - (np.linalg.inv(H) @ G) + error = np.abs(β_new - model.β) + model.β = β_new + # Print iterations if display: - β_list = [f"{t:.3}" for t in list(β.flatten())] - update = f"{i:<13}{logL(β, model):<16.8}{β_list}" + β_list = [f'{t:.3}' for t in list(model.β.flatten())] + update = f'{i:<13}{model.logL():<16.8}{β_list}' print(update) i += 1 - print(f"Number of iterations: {i}") - print(f"β_hat = {β.flatten()}") + print(f'Number of iterations: {i}') + print(f'β_hat = {model.β.flatten()}') - return β + # Return a flat array for β (instead of a k_by_1 column vector) + return model.β.flatten() ``` Let's try out our algorithm with a small dataset of 5 observations and 3 variables in $\mathbf{X}$. -```{code-cell} ipython3 -X = jnp.array([[1, 2, 5], [1, 1, 3], [1, 4, 2], [1, 5, 2], [1, 3, 1]]) +```{code-cell} python3 +X = np.array([[1, 2, 5], + [1, 1, 3], + [1, 4, 2], + [1, 5, 2], + [1, 3, 1]]) -y = jnp.array([1, 0, 1, 1, 0]) +y = np.array([1, 0, 1, 1, 0]) # Take a guess at initial βs -init_β = jnp.array([0.1, 0.1, 0.1]) +init_β = np.array([0.1, 0.1, 0.1]) # Create an object with Poisson model values -poi = PoissonRegression(X=X, y=y) +poi = PoissonRegression(y, X, β=init_β) # Use newton_raphson to find the MLE -β_hat = newton_raphson(poi, init_β, display=True) +β_hat = newton_raphson(poi, display=True) ``` As this was a simple model with few observations, the algorithm achieved @@ -588,49 +559,45 @@ and therefore the numerator in our updating equation is becoming smaller. The gradient vector should be close to 0 at $\hat{\boldsymbol{\beta}}$ -```{code-cell} ipython3 -G_logL(β_hat, poi) +```{code-cell} python3 +poi.G() ``` The iterative process can be visualized in the following diagram, where the maximum is found at $\beta = 10$ -```{code-cell} ipython3 -@jax.jit -def logL(x): - return -((x - 10) ** 2) - 10 - +```{code-cell} python3 +--- +tags: [output_scroll] +--- +logL = lambda x: -(x - 10) ** 2 - 10 -@jax.jit def find_tangent(β, a=0.01): y1 = logL(β) - y2 = logL(β + a) - x = jnp.array([[β, 1], [β + a, 1]]) - m, c = jnp.linalg.lstsq(x, jnp.array([y1, y2]), rcond=None)[0] + y2 = logL(β+a) + x = np.array([[β, 1], [β+a, 1]]) + m, c = np.linalg.lstsq(x, np.array([y1, y2]), rcond=None)[0] return m, c -``` -```{code-cell} ipython3 -:tags: [output_scroll] - -β = jnp.linspace(2, 18) +β = np.linspace(2, 18) fig, ax = plt.subplots(figsize=(12, 8)) -ax.plot(β, logL(β), lw=2, c="black") +ax.plot(β, logL(β), lw=2, c='black') for β in [7, 8.5, 9.5, 10]: - β_line = jnp.linspace(β - 2, β + 2) + β_line = np.linspace(β-2, β+2) m, c = find_tangent(β) y = m * β_line + c - ax.plot(β_line, y, "-", c="purple", alpha=0.8) - ax.text(β + 2.05, y[-1], rf"$G({β}) = {abs(m):.0f}$", fontsize=12) - ax.vlines(β, -24, logL(β), linestyles="--", alpha=0.5) - ax.hlines(logL(β), 6, β, linestyles="--", alpha=0.5) + ax.plot(β_line, y, '-', c='purple', alpha=0.8) + ax.text(β+2.05, y[-1], f'$G({β}) = {abs(m):.0f}$', fontsize=12) + ax.vlines(β, -24, logL(β), linestyles='--', alpha=0.5) + ax.hlines(logL(β), 6, β, linestyles='--', alpha=0.5) ax.set(ylim=(-24, -4), xlim=(6, 13)) -ax.set_xlabel(r"$\beta$", fontsize=15) -ax.set_ylabel( - r"$log \mathcal{L(\beta)}$", rotation=0, labelpad=25, fontsize=15 -) +ax.set_xlabel(r'$\beta$', fontsize=15) +ax.set_ylabel(r'$log \mathcal{L(\beta)}$', + rotation=0, + labelpad=25, + fontsize=15) ax.grid(alpha=0.3) plt.show() ``` @@ -639,7 +606,7 @@ Note that our implementation of the Newton-Raphson algorithm is rather basic --- for more robust implementations see, for example, [scipy.optimize](https://docs.scipy.org/doc/scipy/reference/optimize.html). -## Maximum likelihood estimation with `statsmodels` +## Maximum Likelihood Estimation with `statsmodels` Now that we know what's going on under the hood, we can apply MLE to an interesting application. @@ -652,17 +619,16 @@ likelihood estimates. Before we begin, let's re-estimate our simple model with `statsmodels` to confirm we obtain the same coefficients and log-likelihood value. -Now, as `statsmodels` accepts only NumPy arrays, we can use `np.array` method -to convert them to NumPy arrays. - -```{code-cell} ipython3 -X = jnp.array([[1, 2, 5], [1, 1, 3], [1, 4, 2], [1, 5, 2], [1, 3, 1]]) +```{code-cell} python3 +X = np.array([[1, 2, 5], + [1, 1, 3], + [1, 4, 2], + [1, 5, 2], + [1, 3, 1]]) -y = jnp.array([1, 0, 1, 1, 0]) +y = np.array([1, 0, 1, 1, 0]) -y_numpy = np.array(y) -X_numpy = np.array(X) -stats_poisson = Poisson(y_numpy, X_numpy).fit() +stats_poisson = Poisson(y, X).fit() print(stats_poisson.summary()) ``` @@ -682,35 +648,19 @@ The paper only considers the year 2008 for estimation. We will set up our variables for estimation like so (you should have the data assigned to `df` from earlier in the lecture) -```{code-cell} ipython3 +```{code-cell} python3 # Keep only year 2008 -df = df[df["year"] == 2008] +df = df[df['year'] == 2008] # Add a constant -df["const"] = 1 +df['const'] = 1 # Variable sets -reg1 = ["const", "lngdppc", "lnpop", "gattwto08"] -reg2 = [ - "const", - "lngdppc", - "lnpop", - "gattwto08", - "lnmcap08", - "rintr", - "topint08", -] -reg3 = [ - "const", - "lngdppc", - "lnpop", - "gattwto08", - "lnmcap08", - "rintr", - "topint08", - "nrrents", - "roflaw", -] +reg1 = ['const', 'lngdppc', 'lnpop', 'gattwto08'] +reg2 = ['const', 'lngdppc', 'lnpop', + 'gattwto08', 'lnmcap08', 'rintr', 'topint08'] +reg3 = ['const', 'lngdppc', 'lnpop', 'gattwto08', 'lnmcap08', + 'rintr', 'topint08', 'nrrents', 'roflaw'] ``` Then we can use the `Poisson` function from `statsmodels` to fit the @@ -718,11 +668,10 @@ model. We'll use robust standard errors as in the author's paper -```{code-cell} ipython3 +```{code-cell} python3 # Specify model -poisson_reg = Poisson(df[["numbil0"]], df[reg1], missing="drop").fit( - cov_type="HC0" -) +poisson_reg = sm.Poisson(df[['numbil0']], df[reg1], + missing='drop').fit(cov_type='HC0') print(poisson_reg.summary()) ``` @@ -736,44 +685,36 @@ expected. Let's also estimate the author's more full-featured models and display them in a single table -```{code-cell} ipython3 +```{code-cell} python3 regs = [reg1, reg2, reg3] -reg_names = ["Model 1", "Model 2", "Model 3"] -info_dict = { - "Pseudo R-squared": lambda x: f"{x.prsquared:.2f}", - "No. observations": lambda x: f"{int(x.nobs):d}", -} -regressor_order = [ - "const", - "lngdppc", - "lnpop", - "gattwto08", - "lnmcap08", - "rintr", - "topint08", - "nrrents", - "roflaw", -] +reg_names = ['Model 1', 'Model 2', 'Model 3'] +info_dict = {'Pseudo R-squared': lambda x: f"{x.prsquared:.2f}", + 'No. observations': lambda x: f"{int(x.nobs):d}"} +regressor_order = ['const', + 'lngdppc', + 'lnpop', + 'gattwto08', + 'lnmcap08', + 'rintr', + 'topint08', + 'nrrents', + 'roflaw'] results = [] for reg in regs: - result = Poisson(df[["numbil0"]], df[reg], missing="drop").fit( - cov_type="HC0", maxiter=100, disp=0 - ) + result = sm.Poisson(df[['numbil0']], df[reg], + missing='drop').fit(cov_type='HC0', + maxiter=100, disp=0) results.append(result) -results_table = summary_col( - results=results, - float_format="%0.3f", - stars=True, - model_names=reg_names, - info_dict=info_dict, - regressor_order=regressor_order, -) -results_table.add_title( - "Table 1 - Explaining the Number of Billionaires \ - in 2008" -) +results_table = summary_col(results=results, + float_format='%0.3f', + stars=True, + model_names=reg_names, + info_dict=info_dict, + regressor_order=regressor_order) +results_table.add_title('Table 1 - Explaining the Number of Billionaires \ + in 2008') print(results_table) ``` @@ -783,40 +724,28 @@ capitalization, and negatively correlated with top marginal income tax rate. To analyze our results by country, we can plot the difference between -the predicted and actual values, then sort from highest to lowest and +the predicted an actual values, then sort from highest to lowest and plot the first 15 -```{code-cell} ipython3 -data = [ - "const", - "lngdppc", - "lnpop", - "gattwto08", - "lnmcap08", - "rintr", - "topint08", - "nrrents", - "roflaw", - "numbil0", - "country", -] +```{code-cell} python3 +data = ['const', 'lngdppc', 'lnpop', 'gattwto08', 'lnmcap08', 'rintr', + 'topint08', 'nrrents', 'roflaw', 'numbil0', 'country'] results_df = df[data].dropna() # Use last model (model 3) -results_df["prediction"] = results[-1].predict() +results_df['prediction'] = results[-1].predict() # Calculate difference -results_df["difference"] = results_df["numbil0"] - results_df["prediction"] +results_df['difference'] = results_df['numbil0'] - results_df['prediction'] # Sort in descending order -results_df.sort_values("difference", ascending=False, inplace=True) +results_df.sort_values('difference', ascending=False, inplace=True) # Plot the first 15 data points -results_df[:15].plot( - "country", "difference", kind="bar", figsize=(12, 8), legend=False -) -plt.ylabel("Number of billionaires above predicted level") -plt.xlabel("Country") +results_df[:15].plot('country', 'difference', kind='bar', + figsize=(12,8), legend=False) +plt.ylabel('Number of billionaires above predicted level') +plt.xlabel('Country') plt.show() ``` @@ -873,8 +802,8 @@ Probit model. To begin, find the log-likelihood function and derive the gradient and Hessian. -The `jax.scipy.stats` module `norm` contains the functions needed to -compute the cdf and pdf of the normal distribution. +The `scipy` module `stats.norm` contains the functions needed to +compute the cmf and pmf of the normal distribution. ``` ```{solution-start} mle_ex1 @@ -924,23 +853,40 @@ $$ Using these results, we can write a class for the Probit model as follows -```{code-cell} ipython3 -class ProbitRegression(NamedTuple): - X: jnp.ndarray - y: jnp.ndarray -``` +```{code-cell} python3 +class ProbitRegression: -```{code-cell} ipython3 -@jax.jit -def logL(β, model): - y = model.y - μ = norm.cdf(model.X @ β.T) - return y @ jnp.log(μ) + (1 - y) @ jnp.log(1 - μ) -``` + def __init__(self, y, X, β): + self.X, self.y, self.β = X, y, β + self.n, self.k = X.shape + + def μ(self): + return norm.cdf(self.X @ self.β.T) -```{code-cell} ipython3 -G_logL = jax.grad(logL) -H_logL = jax.jacfwd(G_logL) + def ϕ(self): + return norm.pdf(self.X @ self.β.T) + + def logL(self): + y = self.y + μ = self.μ() + return y @ np.log(μ) + (1 - y) @ np.log(1 - μ) + + def G(self): + X = self.X + y = self.y + μ = self.μ() + ϕ = self.ϕ() + return X.T @ (y * ϕ / μ - (1 - y) * ϕ / (1 - μ)) + + def H(self): + X = self.X + y = self.y + β = self.β + μ = self.μ() + ϕ = self.ϕ() + a = (ϕ + (X @ β.T) * μ) / μ**2 + b = (ϕ - (X @ β.T) * (1 - μ)) / (1 - μ)**2 + return -(ϕ * (y * a + (1 - y) * b) * X.T) @ X ``` ```{solution-end} @@ -984,7 +930,7 @@ $$ Verify your results with `statsmodels` - you can import the Probit function with the following import statement -```{code-cell} ipython3 +```{code-cell} python3 from statsmodels.discrete.discrete_model import Probit ``` @@ -1001,26 +947,29 @@ achieve convergence with different starting values. Here is one solution -```{code-cell} ipython3 -X = jnp.array([[1, 2, 4], [1, 1, 1], [1, 4, 3], [1, 5, 6], [1, 3, 5]]) +```{code-cell} python3 +X = np.array([[1, 2, 4], + [1, 1, 1], + [1, 4, 3], + [1, 5, 6], + [1, 3, 5]]) -y = jnp.array([1, 0, 1, 1, 0]) +y = np.array([1, 0, 1, 1, 0]) # Take a guess at initial βs -β = jnp.array([0.1, 0.1, 0.1]) +β = np.array([0.1, 0.1, 0.1]) -# Create a model of Probit regression -prob = ProbitRegression(y=y, X=X) +# Create instance of Probit regression class +prob = ProbitRegression(y, X, β) # Run Newton-Raphson algorithm -newton_raphson(prob, β) +newton_raphson(prob) ``` -```{code-cell} ipython3 +```{code-cell} python3 # Use statsmodels to verify results -y_numpy = np.array(y) -X_numpy = np.array(X) -print(Probit(y_numpy, X_numpy).fit().summary()) + +print(Probit(y, X).fit().summary()) ``` ```{solution-end} From 289bdd41d3825fa336be0f64c4ec5efca272b0ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:21:07 +0000 Subject: [PATCH 25/25] Fix commit SHA mismatch in Netlify PR comments by using pull_request.head.sha instead of github.sha Co-authored-by: mmcky <8263752+mmcky@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76934637b..b5b265955 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,7 +172,7 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request" ]; then # Deploy to Netlify and capture the response - deploy_message="Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.sha }})" + deploy_message="Preview Deploy from GitHub Actions PR #${{ github.event.pull_request.number }} (commit: ${{ github.event.pull_request.head.sha }})" netlify_output=$(netlify deploy \ --dir _build/html/ \ @@ -250,7 +250,7 @@ jobs: const manualPage = `${{ github.event.inputs.preview_page }}`; const deployUrl = `${{ steps.netlify-deploy.outputs.deploy_url }}`; const prNumber = ${{ github.event.pull_request.number }}; - const commitSha = `${{ github.sha }}`; + const commitSha = `${{ github.event.pull_request.head.sha }}`; const shortSha = commitSha.substring(0, 7); console.log(`Checking for existing comments for commit: ${commitSha}`);