<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://nicolas-cozzarin.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nicolas-cozzarin.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-28T10:00:10+00:00</updated><id>https://nicolas-cozzarin.github.io/feed.xml</id><title type="html">Nicolas Cozzarin</title><subtitle>Product Owner with 10 years in tech, working on AI and data products in finance and the public sector.</subtitle><author><name>Nicolas Cozzarin</name></author><entry xml:lang="en"><title type="html">Checking the EU AI Act in the Deployment Pipeline: My Final Degree Project</title><link href="https://nicolas-cozzarin.github.io/2026/10/25/eu-ai-act-checks-in-the-deployment-pipeline.html" rel="alternate" type="text/html" title="Checking the EU AI Act in the Deployment Pipeline: My Final Degree Project" /><published>2026-10-25T00:00:00+00:00</published><updated>2026-10-25T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/10/25/eu-ai-act-checks-in-the-deployment-pipeline</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/10/25/eu-ai-act-checks-in-the-deployment-pipeline.html"><![CDATA[<h3 id="1-the-problem">1. The problem</h3>

<p>The EU AI Act asks providers of high-risk AI systems to document their models, keep event logs, plan human oversight and examine their data for bias. A model that decides if a person can get credit is high-risk (Annex III, point 5(b)). For credit models, these obligations apply from December 2027.</p>

<p>Models change fast. A team can put a new version in production in a few hours. Compliance checks do not follow that pace: someone reviews the documentation with a checklist at the end of development, and the whole review starts again with each new version. Two auditors can read the same criterion differently, and later it is hard to know why a version was approved.</p>

<p>There are tools that help. Model Cards, Datasheets and the TechOps templates give a structure for the documentation, but people fill them in by hand and nothing checks them. Governance platforms such as Credo AI or OneTrust know the AI Act but work with questionnaires and human review. Open Policy Agent can block a deployment, but it has no AI Act rules. I wanted to put the two together: AI Act controls that run on their own in the pipeline and can stop a deployment.</p>

<p>The case is modelled. ComplianceTech Europa is a fictional consultancy in Paris that audits AI models for banks, and Banco Aurora is a fictional client with a credit model. Their details are my assumptions, and I say so in the project.</p>

<hr />

<h3 id="2-what-the-validator-does">2. What the validator does</h3>

<p>When the bank publishes a new version of its credit model, its GitLab CI pipeline runs two jobs before the deployment:</p>

<ol>
  <li><strong>fairness-measure</strong> computes, with Fairlearn, the difference between the approval rates of men and women (demographic parity difference).</li>
  <li><strong>compliance-check</strong> sends the card of the version and the fairness result to GraphDB, which checks them against five SHACL rules.</li>
</ol>

<p>If the five rules pass, the deployment continues. If one fails, the job fails, the deployment does not run, and the report says which rule failed, the value found, the value expected and the article of the AI Act. If GraphDB does not answer, the job also fails, so that no version goes out without being checked.</p>

<p>Every run is saved in a history, including the blocked ones, with its date, its result for each rule and the deployment decision.</p>

<hr />

<h3 id="3-the-five-rules">3. The five rules</h3>

<table>
  <thead>
    <tr>
      <th>Rule</th>
      <th>AI Act</th>
      <th>What it checks on the card</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>R1</td>
      <td>Art. 6(2) and Annex III, 5(b)</td>
      <td>The declared risk category matches the purpose. A creditworthiness model is high-risk.</td>
    </tr>
    <tr>
      <td>R2</td>
      <td>Art. 11 and Annex IV</td>
      <td>Four fields are filled in: intended purpose, training data, accuracy metrics, known limitations.</td>
    </tr>
    <tr>
      <td>R3</td>
      <td>Art. 12</td>
      <td>Automatic event logging is declared, with where the logs are stored.</td>
    </tr>
    <tr>
      <td>R4</td>
      <td>Art. 14</td>
      <td>A human oversight measure and a responsible person are declared.</td>
    </tr>
    <tr>
      <td>R5</td>
      <td>Art. 10(2)(f) and (g)</td>
      <td>The demographic parity difference by sex is 0.10 or less.</td>
    </tr>
  </tbody>
</table>

<p>These rules cover only part of each obligation, and I want to be clear about where they stop:</p>

<ul>
  <li>R2, R3 and R4 check what the card <strong>declares</strong>. They do not check that the documentation is good, that the logs exist or that someone really supervises the model. That evidence stays with the human auditors.</li>
  <li>The AI Act asks to examine and correct bias, but it does not say how to measure it. The metric and the 0.10 limit are my choice for the case, so that all auditors measure the same way. A low value does not prove that a model is fair: Hardt et al. (2016) showed with credit scores that equal approval rates can mean approving people who will not repay in one group and refusing people who would in another. So R5 works as a warning signal.</li>
</ul>

<hr />

<h3 id="4-design-choices">4. Design choices</h3>

<p><strong>A graph instead of tables.</strong> The rules test relations between data. For example, the purpose of the model implies a risk category, and R1 compares it with the category the card declares. In RDF this is a path in the graph, and SHACL rules run directly on it. I adapted the structure of the card from the TechOps templates.</p>

<p><strong>Keep the blocked versions.</strong> GraphDB can validate SHACL by itself, but then it refuses to store data that does not conform. That would remove the non-compliant versions from the history, and those are the ones an auditor most needs to see. So the validation runs as a separate step, and the history lives in a repository without SHACL. This is where my roles as product owner and developer met: the design needed something the tool did not do by default.</p>

<p><strong>No personal data in the graph.</strong> The fairness measure runs inside the client’s pipeline. Only the result reaches the graph: the rates by group and the difference.</p>

<p><strong>Rules with a version.</strong> Each report says which version of the rules was used. The plan is that the consultancy publishes new rules when the law changes, without the client touching its pipeline.</p>

<hr />

<h3 id="5-the-screens">5. The screens</h3>

<p>I designed the screens in Figma and built them with Streamlit. There are three:</p>

<ul>
  <li><strong>Executions</strong>, with two tabs. <em>Result</em> shows the model, the version, a table with each rule (value found, value expected, legal basis, result), the approval rates of men and women, and a button to download the SHACL report. <em>History</em> shows one row per validation, with the parity value against the limit, the origin (pipeline or manual) and the SPARQL query that builds the table.</li>
  <li><strong>Manual validation</strong>, where an auditor can check a card without deploying anything.</li>
  <li><strong>Integration</strong>, with the four steps a client follows to connect its pipeline.</li>
</ul>

<p><strong><a href="https://nicolas-cozzarin.github.io/validador-prototipo/">See the prototype screens</a></strong> (in Spanish). It is a static view with the data of a real run. The full prototype, with live validation, runs in Docker: GraphDB, Streamlit and Caddy for HTTPS.</p>

<hr />

<h3 id="6-first-run">6. First run</h3>

<p>On 25 October 2026 I ran the same steps as the bank’s pipeline on my computer, against GraphDB 10.8, with two versions of a test credit model trained with scikit-learn:</p>

<ul>
  <li><strong>Version 1</strong> approves 91% of men and 93% of women. Difference: 0.01. The five rules pass and the deployment is authorised.</li>
  <li><strong>Version 2</strong> was trained with a bias on purpose. It approves 96% of men and 82% of women. Difference: 0.15. R5 fails, the deployment is blocked, and the report points to Article 10.</li>
</ul>

<p>The data comes from the public <em>Default of Credit Card Clients</em> dataset (30,000 clients of a bank in Taiwan, 2005). It is used to test the mechanism, not to say anything about credit in Europe.</p>

<hr />

<h3 id="7-how-i-will-evaluate-it">7. How I will evaluate it</h3>

<p>This first run shows that the mechanism works. It does not show that the validator is reliable. For that I prepared a test, which runs in November 2026:</p>

<ul>
  <li>20 model cards built on purpose: 10 that pass the five rules and 10 that break at least one. Each rule is broken at least twice. There are edge cases, such as parity values of 0.09, 0.10 and 0.12, and a card with two problems.</li>
  <li>The expected result of each card is written in a separate file before running the validator.</li>
  <li>Goals: zero compliant verdicts for the 10 cards that break a rule (the serious error, because it lets a violation through), and at most one wrong block among the 10 that comply.</li>
  <li>Time: I also review the same 20 cards by hand with a checklist and compare the median minutes per card. The goal is at least 50% less time, for these five controls only, not for a full audit.</li>
</ul>

<p>The limits are real. The organisation is modelled, and I build the cards, write the rules and time both reviews, which can bias the result.</p>

<hr />

<h3 id="8-what-i-take-from-it">8. What I take from it</h3>

<p>The hardest part was separating what the law says from my technical reading of it. For each rule I wrote the article, what it checks on the card and an example of a violation, so that anyone can trace a blocked deployment back to the text.</p>

<p>Next steps after the degree: a version that runs on the client’s own servers, publishing new rules without touching the client’s pipeline, and a downloadable report that can serve as evidence. The rules can also grow. I started with biases the law protects, because an error there can lead to a fine, but other biases, such as speciesism in AI systems (Hagendorff et al., 2023), could be added the same way.</p>

<p><strong>Stack:</strong> Python 3.12, RDF and Turtle, SHACL, rdflib, pySHACL, GraphDB 10.8, Fairlearn, scikit-learn, GitLab CI, Streamlit, Docker, Caddy, Figma.</p>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[A validator that checks five EU AI Act controls on every new version of a credit model, measures fairness, and blocks the deployment when a rule fails.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="es"><title type="html">Verificar el Reglamento europeo de IA en el pipeline de despliegue: mi trabajo final de grado</title><link href="https://nicolas-cozzarin.github.io/2026/10/25/verificar-el-reglamento-de-ia-en-el-pipeline.html" rel="alternate" type="text/html" title="Verificar el Reglamento europeo de IA en el pipeline de despliegue: mi trabajo final de grado" /><published>2026-10-25T00:00:00+00:00</published><updated>2026-10-25T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/10/25/verificar-el-reglamento-de-ia-en-el-pipeline</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/10/25/verificar-el-reglamento-de-ia-en-el-pipeline.html"><![CDATA[<h3 id="1-el-problema">1. El problema</h3>

<p>El Reglamento europeo de IA les pide a los proveedores de sistemas de alto riesgo que documenten sus modelos, guarden registros de eventos, prevean supervisión humana y examinen sus datos para detectar sesgos. Un modelo que decide si una persona puede acceder a un crédito es de alto riesgo (Anexo III, punto 5, letra b). Para los modelos de crédito, estas obligaciones se aplican desde diciembre de 2027.</p>

<p>Los modelos cambian rápido. Un equipo puede poner una versión nueva en producción en pocas horas. Los controles de cumplimiento no siguen ese ritmo: alguien revisa la documentación con una checklist al final del desarrollo, y toda la revisión empieza de nuevo con cada versión. Dos auditores pueden leer el mismo criterio de forma distinta, y después cuesta saber por qué se aprobó una versión.</p>

<p>Hay herramientas que ayudan. Las Tarjetas de Modelo, las Hojas de Datos y las plantillas de TechOps ordenan la documentación, pero las completan personas y nada las verifica. Las plataformas de gobernanza como Credo AI u OneTrust conocen el Reglamento, pero trabajan con cuestionarios y revisión humana. Open Policy Agent puede frenar un despliegue, pero no trae reglas del Reglamento. Quise juntar las dos cosas: controles del Reglamento que corren solos en el pipeline y pueden frenar un despliegue.</p>

<p>El caso es modelado. ComplianceTech Europa es una consultora ficticia de París que audita modelos de IA para bancos, y el Banco Aurora es un cliente ficticio con un modelo de crédito. Sus detalles son supuestos míos, y lo digo en el trabajo.</p>

<hr />

<h3 id="2-qué-hace-el-validador">2. Qué hace el validador</h3>

<p>Cuando el banco publica una versión nueva de su modelo de crédito, su pipeline de GitLab CI corre dos jobs antes del despliegue:</p>

<ol>
  <li><strong>fairness-measure</strong> calcula con Fairlearn la diferencia entre la tasa de aprobación de hombres y de mujeres (diferencia de paridad demográfica).</li>
  <li><strong>compliance-check</strong> manda la ficha de la versión y el resultado de equidad a GraphDB, que los verifica con cinco reglas SHACL.</li>
</ol>

<p>Si las cinco reglas se cumplen, el despliegue sigue. Si una falla, el job falla, el despliegue no corre, y el reporte dice qué regla falló, el valor obtenido, el valor esperado y el artículo del Reglamento. Si GraphDB no responde, el job también falla, para que ninguna versión salga sin validar.</p>

<p>Cada ejecución queda guardada en un historial, también las bloqueadas, con su fecha, el resultado de cada regla y la decisión de despliegue.</p>

<hr />

<h3 id="3-las-cinco-reglas">3. Las cinco reglas</h3>

<table>
  <thead>
    <tr>
      <th>Regla</th>
      <th>Reglamento de IA</th>
      <th>Qué verifica en la ficha</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>R1</td>
      <td>Art. 6, apartado 2, y Anexo III, punto 5, letra b</td>
      <td>Que la categoría de riesgo declarada coincida con la finalidad. Un modelo de solvencia es de alto riesgo.</td>
    </tr>
    <tr>
      <td>R2</td>
      <td>Art. 11 y Anexo IV</td>
      <td>Que estén cuatro campos: finalidad prevista, datos de entrenamiento, métricas de precisión y limitaciones conocidas.</td>
    </tr>
    <tr>
      <td>R3</td>
      <td>Art. 12</td>
      <td>Que se declare el registro automático de eventos y dónde se guarda.</td>
    </tr>
    <tr>
      <td>R4</td>
      <td>Art. 14</td>
      <td>Que se declare una medida de supervisión humana y una persona responsable.</td>
    </tr>
    <tr>
      <td>R5</td>
      <td>Art. 10, apartado 2, letras f y g</td>
      <td>Que la diferencia de paridad demográfica por sexo sea menor o igual a 0,10.</td>
    </tr>
  </tbody>
</table>

<p>Estas reglas cubren solo una parte de cada obligación, y quiero ser claro sobre dónde terminan:</p>

<ul>
  <li>R2, R3 y R4 controlan lo que la ficha <strong>declara</strong>. No controlan que la documentación sea buena, que los registros existan ni que alguien supervise de verdad el modelo. Esa evidencia sigue a cargo de los auditores.</li>
  <li>El Reglamento pide examinar y corregir los sesgos, pero no dice cómo medirlos. La métrica y el umbral de 0,10 son una decisión mía para el caso, para que todos los auditores midan igual. Un valor bajo no prueba que el modelo sea justo: Hardt et al. (2016) mostraron, con puntajes de crédito, que exigir la misma tasa de aprobación puede llevar a aprobar en un grupo a personas que no van a pagar y a rechazar en otro a personas que sí pagarían. Por eso R5 funciona como una señal de alerta.</li>
</ul>

<hr />

<h3 id="4-decisiones-de-diseño">4. Decisiones de diseño</h3>

<p><strong>Un grafo en lugar de tablas.</strong> Las reglas prueban relaciones entre los datos. Por ejemplo, la finalidad del modelo implica una categoría de riesgo, y R1 la compara con la que declara la ficha. En RDF eso es un camino en el grafo, y las reglas SHACL se aplican directo sobre él. La estructura de la ficha la adapté de las plantillas de TechOps.</p>

<p><strong>Guardar las versiones bloqueadas.</strong> GraphDB puede validar SHACL por su cuenta, pero entonces rechaza los datos que no cumplen. Las versiones no conformes no quedarían en el historial, y son justamente las que un auditor más necesita ver. Por eso la validación se hace en un paso aparte y el historial se guarda en un repositorio sin SHACL activado. Ahí se cruzaron mis roles de Product Owner y de desarrollador: el diseño pedía algo que la herramienta no hacía por defecto.</p>

<p><strong>Sin datos personales en el grafo.</strong> La medición de equidad corre en el pipeline del cliente. Al grafo solo llega el resultado: las tasas por grupo y la diferencia.</p>

<p><strong>Reglas con versión.</strong> Cada reporte indica qué versión de las reglas se usó. La idea es que la consultora publique reglas nuevas cuando cambie la ley, sin que el cliente toque su pipeline.</p>

<hr />

<h3 id="5-las-pantallas">5. Las pantallas</h3>

<p>Diseñé las pantallas en Figma y las programé con Streamlit. Son tres:</p>

<ul>
  <li><strong>Ejecuciones</strong>, con dos pestañas. <em>Resultado</em> muestra el modelo, la versión, una tabla con cada regla (valor obtenido, esperado, base legal y resultado), la tasa de aprobación de hombres y de mujeres, y un botón para descargar el reporte SHACL. <em>Historial</em> muestra una fila por validación, con la paridad frente al umbral, el origen (pipeline o manual) y la consulta SPARQL que arma la tabla.</li>
  <li><strong>Validación manual</strong>, donde un auditor puede validar una ficha sin desplegar nada.</li>
  <li><strong>Integración</strong>, con los cuatro pasos que sigue un cliente para conectar su pipeline.</li>
</ul>

<p><strong><a href="https://nicolas-cozzarin.github.io/validador-prototipo/">Ver las pantallas del prototipo</a></strong>. Es una vista estática con los datos de una ejecución real. El prototipo completo, con validación en vivo, corre en Docker: GraphDB, Streamlit y Caddy para HTTPS.</p>

<hr />

<h3 id="6-primera-ejecución">6. Primera ejecución</h3>

<p>El 25 de octubre de 2026 corrí en mi computadora los mismos pasos que el pipeline del banco, contra GraphDB 10.8, con dos versiones de un modelo de crédito de prueba entrenadas con scikit-learn:</p>

<ul>
  <li><strong>Versión 1</strong>: aprueba al 91 % de los hombres y al 93 % de las mujeres. Diferencia: 0,01. Las cinco reglas se cumplen y el despliegue se autoriza.</li>
  <li><strong>Versión 2</strong>: entrenada con sesgo a propósito. Aprueba al 96 % de los hombres y al 82 % de las mujeres. Diferencia: 0,15. R5 falla, el despliegue se bloquea y el reporte cita el artículo 10.</li>
</ul>

<p>Los datos vienen del dataset público <em>Default of Credit Card Clients</em> (30.000 clientes de un banco de Taiwán, de 2005). Sirve para probar el mecanismo, no para sacar conclusiones sobre el crédito en Europa.</p>

<hr />

<h3 id="7-cómo-lo-voy-a-evaluar">7. Cómo lo voy a evaluar</h3>

<p>Esta primera ejecución muestra que el mecanismo funciona. No muestra que el validador sea confiable. Para eso preparé una prueba, que se hace en noviembre de 2026:</p>

<ul>
  <li>20 fichas armadas a propósito: 10 que cumplen las cinco reglas y 10 que violan al menos una. Cada regla se viola en al menos dos fichas. Hay casos límite, como paridades de 0,09, 0,10 y 0,12, y una ficha con dos incumplimientos.</li>
  <li>El resultado esperado de cada ficha se anota en un archivo aparte antes de correr el validador.</li>
  <li>Metas: cero fichas que incumplen autorizadas entre las 10 que violan una regla (el error grave, porque deja pasar un incumplimiento), y como máximo un bloqueo equivocado entre las 10 que cumplen.</li>
  <li>Tiempo: también reviso las mismas 20 fichas a mano con una checklist y comparo la mediana de minutos por ficha. La meta es al menos un 50 % menos de tiempo, solo para estos cinco controles, no para una auditoría completa.</li>
</ul>

<p>Hay límites. La organización es modelada, y yo armo las fichas, escribo las reglas y tomo el tiempo de las dos revisiones, lo que puede sesgar el resultado.</p>

<hr />

<h3 id="8-lo-que-me-llevo">8. Lo que me llevo</h3>

<p>Lo más difícil fue separar lo que dice la ley de mi interpretación técnica. Para cada regla anoté el artículo, qué verifica en la ficha y un ejemplo de incumplimiento, para que cualquiera pueda rastrear un despliegue bloqueado hasta el texto.</p>

<p>Próximos pasos después de la carrera: una versión que corra en los servidores del cliente, publicar reglas nuevas sin tocar el pipeline del cliente y un reporte descargable que sirva como evidencia. Las reglas también pueden crecer. Empecé por los sesgos que protege la ley, porque un error ahí puede terminar en una sanción, pero otros sesgos, como el especismo en los sistemas de IA (Hagendorff et al., 2023), se podrían sumar de la misma forma.</p>

<p><strong>Tecnologías:</strong> Python 3.12, RDF y Turtle, SHACL, rdflib, pySHACL, GraphDB 10.8, Fairlearn, scikit-learn, GitLab CI, Streamlit, Docker, Caddy, Figma.</p>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[Un validador que verifica cinco controles del Reglamento europeo de IA en cada versión nueva de un modelo de crédito, mide la equidad y frena el despliegue si una regla no se cumple.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="fr"><title type="html">Vérifier le Règlement européen sur l’IA dans le pipeline de déploiement : mon projet de fin d’études</title><link href="https://nicolas-cozzarin.github.io/2026/10/25/verifier-le-reglement-ia-dans-le-pipeline.html" rel="alternate" type="text/html" title="Vérifier le Règlement européen sur l’IA dans le pipeline de déploiement : mon projet de fin d’études" /><published>2026-10-25T00:00:00+00:00</published><updated>2026-10-25T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/10/25/verifier-le-reglement-ia-dans-le-pipeline</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/10/25/verifier-le-reglement-ia-dans-le-pipeline.html"><![CDATA[<h3 id="1-le-problème">1. Le problème</h3>

<p>Le Règlement européen sur l’IA demande aux fournisseurs de systèmes à haut risque de documenter leurs modèles, de conserver des journaux d’événements, de prévoir un contrôle humain et d’examiner leurs données pour détecter les biais. Un modèle qui décide si une personne peut obtenir un crédit est à haut risque (annexe III, point 5, b). Pour les modèles de crédit, ces obligations s’appliquent à partir de décembre 2027.</p>

<p>Les modèles changent vite. Une équipe peut mettre une nouvelle version en production en quelques heures. Les contrôles de conformité ne suivent pas ce rythme : quelqu’un relit la documentation avec une checklist à la fin du développement, et toute la revue recommence à chaque nouvelle version. Deux auditeurs peuvent lire le même critère différemment, et plus tard il est difficile de savoir pourquoi une version a été validée.</p>

<p>Des outils existent. Les Model Cards, les Datasheets et les modèles TechOps donnent une structure à la documentation, mais on les remplit à la main et rien ne les vérifie. Les plateformes de gouvernance comme Credo AI ou OneTrust connaissent le Règlement, mais fonctionnent avec des questionnaires et une revue humaine. Open Policy Agent peut bloquer un déploiement, mais n’a pas de règles liées au Règlement sur l’IA. J’ai voulu réunir les deux : des contrôles du Règlement qui tournent seuls dans le pipeline et peuvent arrêter un déploiement.</p>

<p>Le cas est modélisé. ComplianceTech Europa est un cabinet de conseil fictif à Paris qui audite des modèles d’IA pour des banques, et Banco Aurora est un client fictif avec un modèle de crédit. Leurs détails sont mes hypothèses, et je le dis dans le projet.</p>

<hr />

<h3 id="2-ce-que-fait-le-validateur">2. Ce que fait le validateur</h3>

<p>Quand la banque publie une nouvelle version de son modèle de crédit, son pipeline GitLab CI lance deux jobs avant le déploiement :</p>

<ol>
  <li><strong>fairness-measure</strong> calcule avec Fairlearn l’écart entre les taux d’acceptation des hommes et des femmes (différence de parité démographique).</li>
  <li><strong>compliance-check</strong> envoie la fiche de la version et le résultat d’équité à GraphDB, qui les vérifie avec cinq règles SHACL.</li>
</ol>

<p>Si les cinq règles sont respectées, le déploiement continue. Si l’une échoue, le job échoue, le déploiement ne se lance pas, et le rapport indique la règle en cause, la valeur trouvée, la valeur attendue et l’article du Règlement. Si GraphDB ne répond pas, le job échoue aussi, pour qu’aucune version ne parte sans être vérifiée.</p>

<p>Chaque exécution est enregistrée dans un historique, y compris celles qui ont été bloquées, avec sa date, le résultat de chaque règle et la décision de déploiement.</p>

<hr />

<h3 id="3-les-cinq-règles">3. Les cinq règles</h3>

<table>
  <thead>
    <tr>
      <th>Règle</th>
      <th>Règlement sur l’IA</th>
      <th>Ce qu’elle vérifie dans la fiche</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>R1</td>
      <td>Art. 6, § 2, et annexe III, 5, b</td>
      <td>La catégorie de risque déclarée correspond à la finalité. Un modèle de solvabilité est à haut risque.</td>
    </tr>
    <tr>
      <td>R2</td>
      <td>Art. 11 et annexe IV</td>
      <td>Quatre champs sont remplis : finalité prévue, données d’entraînement, métriques de précision, limites connues.</td>
    </tr>
    <tr>
      <td>R3</td>
      <td>Art. 12</td>
      <td>L’enregistrement automatique des événements est déclaré, avec le lieu de stockage des journaux.</td>
    </tr>
    <tr>
      <td>R4</td>
      <td>Art. 14</td>
      <td>Une mesure de contrôle humain et une personne responsable sont déclarées.</td>
    </tr>
    <tr>
      <td>R5</td>
      <td>Art. 10, § 2, f et g</td>
      <td>La différence de parité démographique par sexe est inférieure ou égale à 0,10.</td>
    </tr>
  </tbody>
</table>

<p>Ces règles ne couvrent qu’une partie de chaque obligation, et je veux être clair sur leurs limites :</p>

<ul>
  <li>R2, R3 et R4 vérifient ce que la fiche <strong>déclare</strong>. Elles ne vérifient pas que la documentation est bonne, que les journaux existent ou que quelqu’un supervise vraiment le modèle. Ces preuves restent du ressort des auditeurs.</li>
  <li>Le Règlement demande d’examiner et de corriger les biais, mais il ne dit pas comment les mesurer. La métrique et le seuil de 0,10 sont mon choix pour le cas, afin que tous les auditeurs mesurent de la même façon. Une valeur basse ne prouve pas qu’un modèle est équitable : Hardt et al. (2016) ont montré, sur des scores de crédit, que des taux d’acceptation égaux peuvent conduire à accepter dans un groupe des personnes qui ne rembourseront pas et à refuser dans un autre des personnes qui rembourseraient. R5 sert donc de signal d’alerte.</li>
</ul>

<hr />

<h3 id="4-choix-de-conception">4. Choix de conception</h3>

<p><strong>Un graphe plutôt que des tables.</strong> Les règles testent des relations entre les données. Par exemple, la finalité du modèle implique une catégorie de risque, et R1 la compare à celle que déclare la fiche. En RDF, c’est un chemin dans le graphe, et les règles SHACL s’appliquent directement dessus. J’ai adapté la structure de la fiche à partir des modèles TechOps.</p>

<p><strong>Garder les versions bloquées.</strong> GraphDB peut valider SHACL tout seul, mais il refuse alors d’enregistrer les données non conformes. Les versions non conformes disparaîtraient de l’historique, alors que ce sont celles qu’un auditeur a le plus besoin de voir. La validation se fait donc dans une étape séparée, et l’historique est stocké dans un dépôt sans SHACL. C’est là que mes rôles de Product Owner et de développeur se sont croisés : la conception demandait quelque chose que l’outil ne faisait pas par défaut.</p>

<p><strong>Pas de données personnelles dans le graphe.</strong> La mesure d’équité tourne dans le pipeline du client. Seul le résultat arrive dans le graphe : les taux par groupe et l’écart.</p>

<p><strong>Des règles versionnées.</strong> Chaque rapport indique la version des règles utilisée. L’idée est que le cabinet publie de nouvelles règles quand la loi change, sans que le client touche à son pipeline.</p>

<hr />

<h3 id="5-les-écrans">5. Les écrans</h3>

<p>J’ai conçu les écrans dans Figma et je les ai développés avec Streamlit. Il y en a trois :</p>

<ul>
  <li><strong>Exécutions</strong>, avec deux onglets. <em>Résultat</em> montre le modèle, la version, un tableau avec chaque règle (valeur trouvée, valeur attendue, base légale, résultat), les taux d’acceptation des hommes et des femmes, et un bouton pour télécharger le rapport SHACL. <em>Historique</em> montre une ligne par validation, avec la parité comparée au seuil, l’origine (pipeline ou manuelle) et la requête SPARQL qui construit le tableau.</li>
  <li><strong>Validation manuelle</strong>, où un auditeur peut vérifier une fiche sans rien déployer.</li>
  <li><strong>Intégration</strong>, avec les quatre étapes qu’un client suit pour connecter son pipeline.</li>
</ul>

<p><strong><a href="https://nicolas-cozzarin.github.io/validador-prototipo/">Voir les écrans du prototype</a></strong> (en espagnol). C’est une vue statique avec les données d’une exécution réelle. Le prototype complet, avec la validation en direct, tourne dans Docker : GraphDB, Streamlit et Caddy pour le HTTPS.</p>

<hr />

<h3 id="6-première-exécution">6. Première exécution</h3>

<p>Le 25 octobre 2026, j’ai lancé sur mon ordinateur les mêmes étapes que le pipeline de la banque, avec GraphDB 10.8 et deux versions d’un modèle de crédit de test entraînées avec scikit-learn :</p>

<ul>
  <li><strong>Version 1</strong> : elle accepte 91 % des hommes et 93 % des femmes. Écart : 0,01. Les cinq règles passent et le déploiement est autorisé.</li>
  <li><strong>Version 2</strong> : entraînée exprès avec un biais. Elle accepte 96 % des hommes et 82 % des femmes. Écart : 0,15. R5 échoue, le déploiement est bloqué et le rapport renvoie à l’article 10.</li>
</ul>

<p>Les données viennent du jeu public <em>Default of Credit Card Clients</em> (30 000 clients d’une banque de Taïwan, 2005). Il sert à tester le mécanisme, pas à tirer des conclusions sur le crédit en Europe.</p>

<hr />

<h3 id="7-comment-je-vais-lévaluer">7. Comment je vais l’évaluer</h3>

<p>Cette première exécution montre que le mécanisme fonctionne. Elle ne montre pas que le validateur est fiable. Pour cela, j’ai préparé un test, qui a lieu en novembre 2026 :</p>

<ul>
  <li>20 fiches construites exprès : 10 qui respectent les cinq règles et 10 qui en enfreignent au moins une. Chaque règle est enfreinte au moins deux fois. Il y a des cas limites, comme des parités de 0,09, 0,10 et 0,12, et une fiche avec deux problèmes.</li>
  <li>Le résultat attendu de chaque fiche est noté dans un fichier séparé avant de lancer le validateur.</li>
  <li>Objectifs : aucune fiche non conforme acceptée parmi les 10 qui enfreignent une règle (c’est l’erreur grave, car elle laisse passer une infraction), et au plus un blocage à tort parmi les 10 conformes.</li>
  <li>Temps : je vérifie aussi les 20 fiches à la main avec une checklist et je compare la médiane de minutes par fiche. L’objectif est au moins 50 % de temps en moins, pour ces cinq contrôles seulement, pas pour un audit complet.</li>
</ul>

<p>Les limites existent. L’organisation est modélisée, et c’est moi qui construis les fiches, écris les règles et chronomètre les deux revues, ce qui peut biaiser le résultat.</p>

<hr />

<h3 id="8-ce-que-jen-retiens">8. Ce que j’en retiens</h3>

<p>Le plus difficile a été de séparer ce que dit la loi de ma lecture technique. Pour chaque règle, j’ai noté l’article, ce qu’elle vérifie dans la fiche et un exemple d’infraction, pour que chacun puisse remonter d’un déploiement bloqué jusqu’au texte.</p>

<p>Prochaines étapes après le diplôme : une version qui tourne sur les serveurs du client, la publication de nouvelles règles sans toucher au pipeline du client, et un rapport téléchargeable qui puisse servir de preuve. Les règles peuvent aussi s’étendre. J’ai commencé par les biais que la loi protège, parce qu’une erreur peut y entraîner une amende, mais d’autres biais, comme le spécisme dans les systèmes d’IA (Hagendorff et al., 2023), pourraient être ajoutés de la même façon.</p>

<p><strong>Technologies :</strong> Python 3.12, RDF et Turtle, SHACL, rdflib, pySHACL, GraphDB 10.8, Fairlearn, scikit-learn, GitLab CI, Streamlit, Docker, Caddy, Figma.</p>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[Un validateur qui vérifie cinq contrôles du Règlement européen sur l'IA sur chaque nouvelle version d'un modèle de crédit, mesure l'équité et bloque le déploiement quand une règle n'est pas respectée.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Selling Bitcoin Over the Counter: Launching a Regulated Crypto Product in Swiss Branches</title><link href="https://nicolas-cozzarin.github.io/2026/09/28/selling-bitcoin-over-the-counter.html" rel="alternate" type="text/html" title="Selling Bitcoin Over the Counter: Launching a Regulated Crypto Product in Swiss Branches" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/09/28/selling-bitcoin-over-the-counter</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/09/28/selling-bitcoin-over-the-counter.html"><![CDATA[<h3 id="1-context">1. Context</h3>

<p>Union of Financial Corners is a money transfer and currency exchange company in Switzerland. Most of its business happens in physical branches: people come to the counter to send money abroad with Western Union or to change currency. It is a regulated business that works under FINMA rules and Swiss anti-money-laundering law.</p>

<p>There were three reasons to add crypto. Swiss law around crypto had changed, which made it possible to offer it in a clear legal frame. Clients were asking for it at the counter. And the company wanted a new source of revenue next to transfers and exchange.</p>

<p>My job was to turn that into a product that the staff could sell every day, in every branch, without creating a compliance risk.</p>

<hr />

<h3 id="2-how-it-works-for-the-client">2. How it works for the client</h3>

<p>From the client’s side, it is simple:</p>

<ol>
  <li>The client comes to the counter and pays in cash.</li>
  <li>They show the QR code of their own wallet and prove the wallet is theirs.</li>
  <li>The staff scan the QR code and the Bitcoin or Ethereum is sent to that wallet.</li>
</ol>

<p>Clients can also do the opposite and sell crypto for cash.</p>

<p>Behind these three steps there is a lot more: identifying the client, checking the amount against the limits, deciding which level of KYC applies, saving the documents, and sending the order to the crypto provider. The design goal was to keep the time at the counter short without letting any of these checks be skipped.</p>

<hr />

<h3 id="3-my-role">3. My role</h3>

<p>I was the product owner on this launch. In practice that meant:</p>

<ul>
  <li>writing the requirements, from the client flow at the counter to the compliance rules;</li>
  <li>choosing the partner, a Swiss crypto provider, and being their contact from the first calls until launch;</li>
  <li>running the backlog with the external developers who built the integration;</li>
  <li>testing the full flow before launch;</li>
  <li>writing the branch procedures;</li>
  <li>training the branch staff.</li>
</ul>

<p>The project took about ten months from start to launch.</p>

<hr />

<h3 id="4-working-with-the-partner-and-the-developers">4. Working with the partner and the developers</h3>

<p>The crypto itself came from a partner, a Swiss crypto provider, and our system had to work with theirs. Choosing that partner was one of my first tasks. After that I stayed their contact for the whole project and followed the integration and the tests with them.</p>

<p>The developers were external too. I wrote the user stories, kept the backlog in order and tested each delivery against the way a branch really works, with a client waiting at the counter.</p>

<hr />

<h3 id="5-compliance-built-into-the-product">5. Compliance built into the product</h3>

<p>This was the core of the project. Before launch, the product had to show that FINMA rules and anti-money-laundering requirements were respected, and we needed FINMA’s approval.</p>

<p>In practice, the product had to handle:</p>

<ul>
  <li>identifying the client, with the level of KYC depending on the amount;</li>
  <li>amount limits;</li>
  <li>saving every ID check and document;</li>
  <li>the anti-money-laundering procedures for the staff;</li>
  <li>tracking each client across all branches.</li>
</ul>

<p>The hardest part was the last one, together with the documents. A client can buy in Geneva one day and in Lausanne the next. If each branch only sees its own transactions, the limits and the KYC levels do not mean much. So we had to recognise the same client in any branch, and keep all their documents saved in a way that we could show them at any moment if there was an audit.</p>

<p>I was responsible for that tracking and for checking that it was done correctly, also beyond the normal case, for example with a client who comes back in another city.</p>

<hr />

<h3 id="6-rolling-out-to-the-branches">6. Rolling out to the branches</h3>

<p>We went live in all our branches in Geneva, Lausanne, Bern and Zurich. For each branch, the staff needed to know the new flow, the new procedures and, above all, why the compliance steps mattered.</p>

<p>Training is the part I would change. I explained the product the way I understood it, with all the details of the rules. For many agents it was too much at once. Next time I would start from the basics and explain in very simple words the few things that really matter: who the client is, whether the wallet is theirs, which limit applies, and which document must be saved. The rest can come later.</p>

<hr />

<h3 id="7-results">7. Results</h3>

<p>I do not have exact numbers to share, but the signs were clear:</p>

<ul>
  <li>Clients came back regularly to buy crypto, and also to sell it.</li>
  <li>When audits came, we had the documents they asked for.</li>
  <li>The service is still running today.</li>
  <li>It is now being extended to third-party partner kiosks across Switzerland.</li>
</ul>

<hr />

<h3 id="8-what-i-learned">8. What I learned</h3>

<ul>
  <li>In a regulated business, compliance requirements are part of the product design. In this project, the hardest part was a compliance requirement: recognising each client in every branch and keeping their documents ready for an audit.</li>
  <li>Next time I would train the staff in two steps: first the few points that matter at the counter, then the details of the rules.</li>
</ul>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[How I led the launch of crypto sales in the branches of a Swiss money transfer company: the partner, the product, the compliance work and what I would do differently.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="es"><title type="html">Vender Bitcoin en ventanilla: el lanzamiento de un producto cripto regulado en sucursales suizas</title><link href="https://nicolas-cozzarin.github.io/2026/09/28/vender-bitcoin-en-ventanilla.html" rel="alternate" type="text/html" title="Vender Bitcoin en ventanilla: el lanzamiento de un producto cripto regulado en sucursales suizas" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/09/28/vender-bitcoin-en-ventanilla</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/09/28/vender-bitcoin-en-ventanilla.html"><![CDATA[<h3 id="1-contexto">1. Contexto</h3>

<p>Union of Financial Corners es una empresa de envío de dinero y cambio de divisas en Suiza. La mayor parte de su actividad pasa por las sucursales: la gente viene a la ventanilla para enviar dinero al exterior con Western Union o para cambiar divisas. Es una actividad regulada, que funciona bajo las reglas de la FINMA y la ley suiza de prevención de lavado de dinero.</p>

<p>Había tres razones para sumar la cripto. La ley suiza sobre cripto había cambiado, y eso permitía ofrecerla dentro de un marco legal claro. Los clientes la pedían en la ventanilla. Y la empresa buscaba una nueva fuente de ingresos, además de los envíos y el cambio.</p>

<p>Mi trabajo era convertir eso en un producto que el personal pudiera vender todos los días, en cada sucursal, sin generar un riesgo de cumplimiento.</p>

<hr />

<h3 id="2-cómo-funciona-para-el-cliente">2. Cómo funciona para el cliente</h3>

<p>Del lado del cliente es simple:</p>

<ol>
  <li>El cliente viene a la ventanilla y paga en efectivo.</li>
  <li>Muestra el código QR de su propia wallet y demuestra que la wallet es suya.</li>
  <li>El personal escanea el código QR y el Bitcoin o el Ethereum se envía a esa wallet.</li>
</ol>

<p>Los clientes también pueden hacer lo contrario y vender cripto a cambio de efectivo.</p>

<p>Detrás de esos tres pasos hay mucho más: identificar al cliente, controlar el monto contra los límites, decidir qué nivel de KYC corresponde, guardar los documentos y enviar la orden al proveedor cripto. El objetivo era que el paso por la ventanilla fuera corto, sin que se pudiera saltear ninguno de esos controles.</p>

<hr />

<h3 id="3-mi-rol">3. Mi rol</h3>

<p>Fui el Product Owner de este lanzamiento. En la práctica eso significó:</p>

<ul>
  <li>redactar los requerimientos, desde el flujo del cliente en la ventanilla hasta las reglas de cumplimiento;</li>
  <li>elegir al socio, un proveedor cripto suizo, y ser su contacto desde las primeras reuniones hasta el lanzamiento;</li>
  <li>llevar el backlog con los desarrolladores externos que construyeron la integración;</li>
  <li>probar el flujo completo antes del lanzamiento;</li>
  <li>redactar los procedimientos para las sucursales;</li>
  <li>capacitar al personal de las sucursales.</li>
</ul>

<p>El proyecto llevó unos diez meses desde el inicio hasta el lanzamiento.</p>

<hr />

<h3 id="4-trabajar-con-el-socio-y-los-desarrolladores">4. Trabajar con el socio y los desarrolladores</h3>

<p>La cripto la ponía un socio, un proveedor cripto suizo, y nuestro sistema tenía que funcionar con el suyo. Elegir ese socio fue una de mis primeras tareas. Después seguí siendo su contacto durante todo el proyecto y acompañé con ellos la integración y las pruebas.</p>

<p>Los desarrolladores también eran externos. Escribí las user stories, mantuve el backlog en orden y probé cada entrega según cómo funciona de verdad una sucursal, con un cliente esperando en la ventanilla.</p>

<hr />

<h3 id="5-el-cumplimiento-normativo-dentro-del-producto">5. El cumplimiento normativo dentro del producto</h3>

<p>Esto fue el centro del proyecto. Antes del lanzamiento, el producto tenía que demostrar que se respetaban las reglas de la FINMA y los requisitos de prevención de lavado de dinero, y necesitábamos la aprobación de la FINMA.</p>

<p>En la práctica, el producto tenía que resolver:</p>

<ul>
  <li>la identificación del cliente, con un nivel de KYC que depende del monto;</li>
  <li>los límites de monto;</li>
  <li>el guardado de cada control de identidad y de cada documento;</li>
  <li>los procedimientos de prevención de lavado de dinero para el personal;</li>
  <li>el seguimiento de cada cliente en todas las sucursales.</li>
</ul>

<p>Lo más difícil fue esto último, junto con los documentos. Un cliente puede comprar en Ginebra un día y en Lausana al siguiente. Si cada sucursal solo ve sus propias operaciones, los límites y los niveles de KYC pierden sentido. Por eso teníamos que reconocer al mismo cliente en cualquier sucursal y tener todos sus documentos guardados de forma que pudiéramos mostrarlos en cualquier momento si había una auditoría.</p>

<p>Yo era el responsable de ese seguimiento y de controlar que se hiciera bien, también fuera del caso normal, por ejemplo con un cliente que vuelve en otra ciudad.</p>

<hr />

<h3 id="6-el-despliegue-en-las-sucursales">6. El despliegue en las sucursales</h3>

<p>Lanzamos el servicio en todas nuestras sucursales de Ginebra, Lausana, Berna y Zúrich. En cada sucursal, el personal tenía que conocer el nuevo flujo, los nuevos procedimientos y, sobre todo, por qué importaban los pasos de cumplimiento.</p>

<p>La capacitación es lo que cambiaría. Expliqué el producto como yo lo entendía, con todos los detalles de las reglas. Para muchos agentes fue demasiado de una vez. La próxima vez empezaría por lo básico y explicaría con palabras muy simples las pocas cosas que de verdad importan: quién es el cliente, si la wallet es suya, qué límite corresponde y qué documento hay que guardar. El resto puede venir después.</p>

<hr />

<h3 id="7-resultados">7. Resultados</h3>

<p>No tengo cifras exactas para compartir, pero las señales fueron claras:</p>

<ul>
  <li>Los clientes volvían seguido a comprar cripto, y también a venderla.</li>
  <li>En las auditorías teníamos los documentos que nos pidieron.</li>
  <li>El servicio sigue funcionando hoy.</li>
  <li>Se está extendiendo a quioscos de socios en toda Suiza.</li>
</ul>

<hr />

<h3 id="8-lo-que-aprendí">8. Lo que aprendí</h3>

<ul>
  <li>En una actividad regulada, los requisitos de cumplimiento son parte del diseño del producto. En este proyecto, lo más difícil fue un requisito de cumplimiento: reconocer a cada cliente en todas las sucursales y tener sus documentos listos para una auditoría.</li>
  <li>La próxima vez capacitaría al personal en dos etapas: primero los pocos puntos que importan en la ventanilla, después el detalle de las reglas.</li>
</ul>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[Cómo lideré el lanzamiento de la venta de criptomonedas en las sucursales de una empresa suiza de envío de dinero: el socio, el producto, el cumplimiento normativo y lo que haría distinto.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="fr"><title type="html">Vendre du Bitcoin au guichet : lancer un produit crypto réglementé dans des agences suisses</title><link href="https://nicolas-cozzarin.github.io/2026/09/28/vendre-du-bitcoin-au-guichet.html" rel="alternate" type="text/html" title="Vendre du Bitcoin au guichet : lancer un produit crypto réglementé dans des agences suisses" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/09/28/vendre-du-bitcoin-au-guichet</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/09/28/vendre-du-bitcoin-au-guichet.html"><![CDATA[<h3 id="1-contexte">1. Contexte</h3>

<p>Union of Financial Corners est une société de transfert d’argent et de change en Suisse. La plus grande partie de son activité se fait en agence : les gens viennent au guichet pour envoyer de l’argent à l’étranger avec Western Union ou pour changer des devises. C’est une activité réglementée, soumise aux règles de la FINMA et à la loi suisse sur le blanchiment d’argent.</p>

<p>Il y avait trois raisons d’ajouter la crypto. La loi suisse sur la crypto avait changé, ce qui permettait de la proposer dans un cadre légal clair. Les clients la demandaient au guichet. Et l’entreprise cherchait une nouvelle source de revenus, à côté des transferts et du change.</p>

<p>Mon travail était d’en faire un produit que le personnel puisse vendre tous les jours, dans chaque agence, sans créer de risque de conformité.</p>

<hr />

<h3 id="2-comment-ça-marche-pour-le-client">2. Comment ça marche pour le client</h3>

<p>Du côté du client, c’est simple :</p>

<ol>
  <li>Le client vient au guichet et paie en espèces.</li>
  <li>Il montre le QR code de son propre wallet et prouve que ce wallet lui appartient.</li>
  <li>Le personnel scanne le QR code et le Bitcoin ou l’Ethereum est envoyé sur ce wallet.</li>
</ol>

<p>Les clients peuvent aussi faire l’inverse et vendre de la crypto contre des espèces.</p>

<p>Derrière ces trois étapes, il y a beaucoup plus : identifier le client, vérifier le montant par rapport aux limites, décider quel niveau de KYC s’applique, enregistrer les documents et envoyer l’ordre au fournisseur crypto. L’objectif était de garder le passage au guichet court, sans permettre de sauter aucun de ces contrôles.</p>

<hr />

<h3 id="3-mon-rôle">3. Mon rôle</h3>

<p>J’étais le Product Owner de ce lancement. Concrètement, cela voulait dire :</p>

<ul>
  <li>rédiger les besoins, du parcours client au guichet jusqu’aux règles de conformité ;</li>
  <li>choisir le partenaire, un fournisseur crypto suisse, et être son interlocuteur des premiers échanges jusqu’au lancement ;</li>
  <li>gérer le backlog avec les développeurs externes qui ont construit l’intégration ;</li>
  <li>tester le parcours complet avant le lancement ;</li>
  <li>rédiger les procédures pour les agences ;</li>
  <li>former le personnel des agences.</li>
</ul>

<p>Le projet a duré environ dix mois, du début jusqu’au lancement.</p>

<hr />

<h3 id="4-travailler-avec-le-partenaire-et-les-développeurs">4. Travailler avec le partenaire et les développeurs</h3>

<p>La crypto elle-même venait d’un partenaire, un fournisseur crypto suisse, et notre système devait fonctionner avec le sien. Choisir ce partenaire a été l’une de mes premières tâches. Ensuite, je suis resté son interlocuteur pendant tout le projet et j’ai suivi avec lui l’intégration et les tests.</p>

<p>Les développeurs étaient eux aussi externes. J’écrivais les user stories, je tenais le backlog à jour et je testais chaque livraison en fonction de la réalité d’une agence, avec un client qui attend au guichet.</p>

<hr />

<h3 id="5-la-conformité-au-cœur-du-produit">5. La conformité au cœur du produit</h3>

<p>C’était le cœur du projet. Avant le lancement, le produit devait démontrer que les règles de la FINMA et les exigences de lutte contre le blanchiment étaient respectées, et il nous fallait l’approbation de la FINMA.</p>

<p>Concrètement, le produit devait gérer :</p>

<ul>
  <li>l’identification du client, avec un niveau de KYC qui dépend du montant ;</li>
  <li>les limites de montant ;</li>
  <li>l’enregistrement de chaque contrôle d’identité et de chaque document ;</li>
  <li>les procédures anti-blanchiment pour le personnel ;</li>
  <li>le suivi de chaque client dans toutes les agences.</li>
</ul>

<p>Le plus difficile était ce dernier point, avec les documents. Un client peut acheter à Genève un jour et à Lausanne le lendemain. Si chaque agence ne voit que ses propres transactions, les limites et les niveaux de KYC n’ont plus beaucoup de sens. Il fallait donc reconnaître le même client dans n’importe quelle agence, et garder tous ses documents enregistrés de façon à pouvoir les présenter à tout moment en cas d’audit.</p>

<p>J’étais responsable de ce suivi et de la vérification qu’il était fait correctement, y compris en dehors du cas normal, par exemple avec un client qui revient dans une autre ville.</p>

<hr />

<h3 id="6-le-déploiement-dans-les-agences">6. Le déploiement dans les agences</h3>

<p>Nous avons lancé le service dans toutes nos agences de Genève, Lausanne, Berne et Zurich. Dans chaque agence, le personnel devait connaître le nouveau parcours, les nouvelles procédures et surtout pourquoi les étapes de conformité étaient importantes.</p>

<p>C’est la formation que je changerais. J’ai expliqué le produit comme je le comprenais, avec tous les détails des règles. Pour beaucoup d’agents, c’était trop d’un coup. La prochaine fois, je partirais des bases et j’expliquerais avec des mots très simples les quelques points qui comptent vraiment : qui est le client, est-ce que le wallet est bien à lui, quelle limite s’applique et quel document doit être enregistré. Le reste peut venir ensuite.</p>

<hr />

<h3 id="7-résultats">7. Résultats</h3>

<p>Je n’ai pas de chiffres exacts à partager, mais les signes étaient clairs :</p>

<ul>
  <li>Les clients revenaient régulièrement pour acheter de la crypto, et aussi pour en vendre.</li>
  <li>Lors des audits, nous avions les documents demandés.</li>
  <li>Le service fonctionne toujours aujourd’hui.</li>
  <li>Il est en cours d’extension à des bornes de partenaires tiers dans toute la Suisse.</li>
</ul>

<hr />

<h3 id="8-ce-que-jen-retiens">8. Ce que j’en retiens</h3>

<ul>
  <li>Dans une activité réglementée, les exigences de conformité font partie de la conception du produit. Dans ce projet, le plus difficile était une exigence de conformité : reconnaître chaque client dans toutes les agences et garder ses documents prêts pour un audit.</li>
  <li>La prochaine fois, je formerais le personnel en deux temps : d’abord les quelques points qui comptent au guichet, ensuite le détail des règles.</li>
</ul>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[Comment j'ai piloté le lancement de la vente de cryptomonnaies dans les agences d'une société suisse de transfert d'argent : le partenaire, le produit, la conformité et ce que je ferais autrement.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">An Anonymized NLP Pipeline for Police Disciplinary Oversight in Córdoba</title><link href="https://nicolas-cozzarin.github.io/2026/07/10/anonymized-triage-security-forces.html" rel="alternate" type="text/html" title="An Anonymized NLP Pipeline for Police Disciplinary Oversight in Córdoba" /><published>2026-07-10T00:00:00+00:00</published><updated>2026-07-10T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2026/07/10/anonymized-triage-security-forces</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2026/07/10/anonymized-triage-security-forces.html"><![CDATA[<h3 id="1-motivation">1. Motivation</h3>

<p>In August 2020, a police officer in Córdoba, Argentina, shot and killed Valentino “Blas” Correas, a seventeen-year-old, after his car was stopped at a checkpoint. The case led the province to change how it holds its security forces accountable. The following year, the provincial legislature passed Law 10.731, which created an independent body to investigate and sanction misconduct in the Police, the Anti-Narcotics Force and the Penitentiary Service, outside the chain of command of those institutions.</p>

<p>An oversight body like this depends on the trust of the people who file complaints. In practice, that trust depends on two operational problems.</p>

<p>The first is privacy. Every complaint names people: victims, witnesses, officers under investigation, and sometimes minors. Before anyone can analyze a complaint, compare it with others or use it for statistics, those names have to be removed, in a way that cannot be reversed. Doing this by hand does not scale, and one missed name or a partly redacted address can put someone at risk.</p>

<p>The second is volume and imbalance. Complaints arrive without a category. An operator reads each one, decides what it is about and sends it to the right team. The office receives many more routine complaints, such as general poor performance, than serious ones, such as institutional violence or corruption. With manual triage, the serious cases wait in the same queue as the routine ones.</p>

<p><strong>Goal:</strong> build an NLP pipeline that removes identifying details from every incoming complaint before a person or a model reads it, and then classifies the anonymized text into the office’s official categories, so that investigators spend their time on decisions instead of redaction and sorting.</p>

<p>I worked on this as AI Product Owner and ML Developer during my degree internship with the Coordination of Information Registry and Analysis, the team inside the office that is responsible for this data. I scoped the requirements with the legal and coordination staff, wrote the system specification and built the model. This article describes the design of the full system and what has been measured so far.</p>

<hr />

<h3 id="2-institutional-context-and-related-work">2. Institutional context and related work</h3>

<p>Two existing systems were useful references.</p>

<p><strong>Prometea</strong>, developed for the Public Prosecutor’s Office of the City of Buenos Aires, uses AI to read, classify and triage judicial case files. It is the most cited example of AI-assisted triage in the Argentine public sector, and it reports accuracy above 93 percent while keeping the process auditable. It also shows that Argentine institutions can accept this kind of tool.</p>

<p><strong>PretorIA</strong>, used by the Constitutional Court of Colombia, applies NLP and transformer models to classify large volumes of tutela filings, the Colombian equivalent of a constitutional protection claim. It showed that transformer models can handle dense, informal and legally sensitive text, and that a high court was willing to use one in a process that affects citizens’ rights.</p>

<p>In the public documentation I could find, neither system explains in detail how it anonymizes text before classifying it. That is the part of this project I spent the most time on, so I treated it as a separate problem.</p>

<hr />

<h3 id="3-objectives">3. Objectives</h3>

<p><strong>General objective:</strong> reduce the time and the errors involved in receiving, protecting and classifying complaints at the Coordination of Information Registry and Analysis.</p>

<p><strong>Specific objectives:</strong></p>

<ol>
  <li>Anonymize every complaint automatically before analysis, removing names, ID numbers and addresses.</li>
  <li>Classify each complaint into the office’s eight official categories, with an F1 score above 0.70 for each category.</li>
  <li>Replace manual triage with an automatic suggestion, to reduce the time it takes to send a new complaint to the right area.</li>
  <li>Keep all data inside government infrastructure: no complaint text or model file leaves it.</li>
  <li>Train the coordination staff to run the pipeline themselves after handover.</li>
</ol>

<p>The eight official categories are: conflicto laboral, corrupción, inconductas fuera de servicio, mal desempeño, violencia de género, violencia familiar, violencia institucional and otro.</p>

<hr />

<h3 id="4-data">4. Data</h3>

<p>Each record is a free-text complaint (RESEÑA) written by an operator in informal Spanish, with two labels: <strong>tema</strong>, the main category from the list above, and <strong>subcategoria</strong>, a finer label within that category. A complaint is usually a short paragraph describing an incident, with no fixed structure and with typos, abbreviations and institutional jargon that a general Spanish language model has not seen.</p>

<p>The classes are very unbalanced. Mal desempeño has about 4,000 examples, more than the next two categories together, while the smallest category has fewer than 500 (Figure 1). With this distribution, a model that mostly predicts the majority class can reach a reasonable overall accuracy while missing many of the cases the office most needs to find. For that reason I report results per category, and not only overall accuracy.</p>

<p><em>Figure 1: Distribution of complaint categories in the dataset</em>
<img src="/docs/assets/secfs-theme-distribution.png" alt="Figure 1" /></p>

<hr />

<h3 id="5-system-design-and-data-governance">5. System design and data governance</h3>

<p>Before writing any code, I defined who handles the data and what each role can see. This access model drove most of the later design decisions.</p>

<table>
  <thead>
    <tr>
      <th>Role</th>
      <th>Access</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Data Operator</td>
      <td>Uploads raw complaints and runs the anonymization batch. Never sees classification results.</td>
    </tr>
    <tr>
      <td>System Administrator</td>
      <td>Manages users and defines the categories and examples the model learns from.</td>
    </tr>
    <tr>
      <td>Prosecutor</td>
      <td>Reviews anonymized complaints with the AI suggestion attached, and approves or changes the classification. Never sees raw personal data.</td>
    </tr>
    <tr>
      <td>Researcher</td>
      <td>Queries fully anonymized data for statistics and trend analysis. No access to raw text at any point.</td>
    </tr>
  </tbody>
</table>

<p>No role, including mine during development, has permanent access to the raw complaints. Raw text is kept only in a restricted vault used for chain of custody, and every access to it is logged.</p>

<p><strong>Stack:</strong> Python 3.12 backend, PyTorch with CUDA, Hugging Face Transformers, scikit-learn and pandas. The frontend is React with Tailwind, served separately from the API so that the AI workload can scale independently of the interface.</p>

<hr />

<h3 id="6-anonymization-pipeline">6. Anonymization pipeline</h3>

<p>Anonymization has to run before any person or model sees the text, and it has to be irreversible. The rules run in a fixed order, because a rule applied too early can break a pattern that a later rule needs: emails first, then account numbers, then phone numbers, then national ID numbers (DNI), then names, then addresses, and finally anything else spaCy’s NER model detects. For example, phone numbers are removed before DNI numbers because a phone number contains a run of seven or eight digits that the DNI pattern would also match. In the wrong order, the DNI rule would replace part of the phone number and leave the area code in the text.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="nn">re</span>
<span class="kn">import</span> <span class="nn">spacy</span>

<span class="n">EMAIL_RE</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"</span><span class="p">)</span>
<span class="n">DNI_RE</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">"\b\d{7,8}\b"</span><span class="p">)</span>
<span class="n">PHONE_RE</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">"\b(?:\+54)?\s?9?\s?\d{2,4}[\s.-]?\d{6,8}\b"</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">strip_regex_pii</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">EMAIL_RE</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="s">"[EMAIL]"</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">PHONE_RE</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="s">"[TELEFONO]"</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">DNI_RE</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="s">"[DNI]"</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">text</span></code></pre></figure>

<p>Names first go through a dictionary of common Argentine first names and surnames, which catches most cases at a low cost. spaCy’s named entity recognition handles the rest. Only entity recognition is needed at this stage, so the other spaCy components are disabled to keep it fast:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="n">nlp</span> <span class="o">=</span> <span class="n">spacy</span><span class="p">.</span><span class="n">load</span><span class="p">(</span>
    <span class="s">"es_core_news_lg"</span><span class="p">,</span>
    <span class="n">disable</span><span class="o">=</span><span class="p">[</span><span class="s">"tok2vec"</span><span class="p">,</span> <span class="s">"tagger"</span><span class="p">,</span> <span class="s">"parser"</span><span class="p">,</span> <span class="s">"attribute_ruler"</span><span class="p">,</span> <span class="s">"lemmatizer"</span><span class="p">],</span>
<span class="p">)</span>

<span class="k">def</span> <span class="nf">mask_names</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">doc</span> <span class="o">=</span> <span class="n">nlp</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">ent</span> <span class="ow">in</span> <span class="nb">reversed</span><span class="p">(</span><span class="n">doc</span><span class="p">.</span><span class="n">ents</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">ent</span><span class="p">.</span><span class="n">label_</span> <span class="o">==</span> <span class="s">"PER"</span><span class="p">:</span>
            <span class="n">text</span> <span class="o">=</span> <span class="n">text</span><span class="p">[:</span> <span class="n">ent</span><span class="p">.</span><span class="n">start_char</span><span class="p">]</span> <span class="o">+</span> <span class="s">"[PERSONA]"</span> <span class="o">+</span> <span class="n">text</span><span class="p">[</span><span class="n">ent</span><span class="p">.</span><span class="n">end_char</span> <span class="p">:]</span>
    <span class="k">return</span> <span class="n">text</span></code></pre></figure>

<p>Addresses and place names are handled by Microsoft Presidio, which recognizes street names and location patterns better than a general NER model. The link between the raw text and the anonymized text is kept only in the encrypted vault described in section 5, never in the training or inference path.</p>

<p>Every rule is tested against a set of honeypot records with deliberately difficult names, addresses and edge cases, so that false positives and false negatives show up in testing and not in production. The two kinds of error do not have the same cost: a missed name is a privacy breach, while an unnecessary mask only removes some context for the reader. The roadmap (section 14) adds a fixed threshold for acceptable leakage, so that this trade-off can be measured instead of judged case by case.</p>

<hr />

<h3 id="7-modeling-plan">7. Modeling plan</h3>

<p>Once the text is anonymized, the task is to predict tema and subcategoria from the RESEÑA field. I worked in stages, so that each more complex model had a simpler baseline to beat.</p>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>Task</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Data inspection</td>
      <td>Shape, missing values, class distribution per category</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Data cleaning</td>
      <td>Lowercasing, noise removal, missing value handling</td>
    </tr>
    <tr>
      <td>3</td>
      <td>EDA and feature analysis</td>
      <td>Class balance, text length statistics, keyword patterns</td>
    </tr>
    <tr>
      <td>4</td>
      <td>scikit-learn baselines</td>
      <td>TF-IDF with Logistic Regression, Naive Bayes and SVM, cross-validated</td>
    </tr>
    <tr>
      <td>5</td>
      <td>BETO fine-tuning</td>
      <td>Spanish BERT fine-tuned with a class-weighted loss</td>
    </tr>
    <tr>
      <td>6</td>
      <td>Evaluation and comparison</td>
      <td>Accuracy, F1 and confusion matrices side by side</td>
    </tr>
  </tbody>
</table>

<p><strong>Baselines.</strong> Before using a transformer, I set a floor with standard TF-IDF pipelines. If a linear model came close to a fine-tuned BERT model, it would mean that most of the signal is in the vocabulary, and the extra cost of a transformer on the office’s hardware would be hard to justify.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="nn">sklearn.feature_extraction.text</span> <span class="kn">import</span> <span class="n">TfidfVectorizer</span>
<span class="kn">from</span> <span class="nn">sklearn.linear_model</span> <span class="kn">import</span> <span class="n">LogisticRegression</span>
<span class="kn">from</span> <span class="nn">sklearn.naive_bayes</span> <span class="kn">import</span> <span class="n">MultinomialNB</span>
<span class="kn">from</span> <span class="nn">sklearn.svm</span> <span class="kn">import</span> <span class="n">LinearSVC</span>
<span class="kn">from</span> <span class="nn">sklearn.pipeline</span> <span class="kn">import</span> <span class="n">Pipeline</span>
<span class="kn">from</span> <span class="nn">sklearn.model_selection</span> <span class="kn">import</span> <span class="n">cross_val_score</span>

<span class="n">vectorizer</span> <span class="o">=</span> <span class="n">TfidfVectorizer</span><span class="p">(</span><span class="n">max_features</span><span class="o">=</span><span class="mi">20000</span><span class="p">,</span> <span class="n">ngram_range</span><span class="o">=</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">))</span>

<span class="n">baselines</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"logreg"</span><span class="p">:</span> <span class="n">LogisticRegression</span><span class="p">(</span><span class="n">max_iter</span><span class="o">=</span><span class="mi">1000</span><span class="p">,</span> <span class="n">class_weight</span><span class="o">=</span><span class="s">"balanced"</span><span class="p">),</span>
    <span class="s">"naive_bayes"</span><span class="p">:</span> <span class="n">MultinomialNB</span><span class="p">(),</span>
    <span class="s">"svm"</span><span class="p">:</span> <span class="n">LinearSVC</span><span class="p">(</span><span class="n">class_weight</span><span class="o">=</span><span class="s">"balanced"</span><span class="p">),</span>
<span class="p">}</span>

<span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">model</span> <span class="ow">in</span> <span class="n">baselines</span><span class="p">.</span><span class="n">items</span><span class="p">():</span>
    <span class="n">pipe</span> <span class="o">=</span> <span class="n">Pipeline</span><span class="p">([(</span><span class="s">"tfidf"</span><span class="p">,</span> <span class="n">vectorizer</span><span class="p">),</span> <span class="p">(</span><span class="s">"clf"</span><span class="p">,</span> <span class="n">model</span><span class="p">)])</span>
    <span class="n">scores</span> <span class="o">=</span> <span class="n">cross_val_score</span><span class="p">(</span><span class="n">pipe</span><span class="p">,</span> <span class="n">X_train</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">cv</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span> <span class="n">scoring</span><span class="o">=</span><span class="s">"f1_weighted"</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">scores</span><span class="p">.</span><span class="n">mean</span><span class="p">())</span></code></pre></figure>

<hr />

<h3 id="8-classification-model">8. Classification model</h3>

<p>For the main model I fine-tuned BETO, a Spanish BERT model, on the anonymized complaints. The main difficulty is the class imbalance described in section 4: without a correction, the model learns to favour the majority class, and its overall accuracy hides poor results on the smaller categories.</p>

<p>To correct this, I compute class weights from the training distribution and use them in a weighted cross-entropy loss inside the Hugging Face Trainer:</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">import</span> <span class="nn">torch</span>
<span class="kn">from</span> <span class="nn">sklearn.utils.class_weight</span> <span class="kn">import</span> <span class="n">compute_class_weight</span>
<span class="kn">from</span> <span class="nn">transformers</span> <span class="kn">import</span> <span class="n">Trainer</span>

<span class="n">class_weights</span> <span class="o">=</span> <span class="n">compute_class_weight</span><span class="p">(</span>
    <span class="n">class_weight</span><span class="o">=</span><span class="s">"balanced"</span><span class="p">,</span>
    <span class="n">classes</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">unique</span><span class="p">(</span><span class="n">y_train</span><span class="p">),</span>
    <span class="n">y</span><span class="o">=</span><span class="n">y_train</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">class_weights</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">tensor</span><span class="p">(</span><span class="n">class_weights</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">torch</span><span class="p">.</span><span class="nb">float</span><span class="p">)</span>

<span class="k">class</span> <span class="nc">WeightedTrainer</span><span class="p">(</span><span class="n">Trainer</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">compute_loss</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">model</span><span class="p">,</span> <span class="n">inputs</span><span class="p">,</span> <span class="n">return_outputs</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
        <span class="n">labels</span> <span class="o">=</span> <span class="n">inputs</span><span class="p">.</span><span class="n">pop</span><span class="p">(</span><span class="s">"labels"</span><span class="p">)</span>
        <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="o">**</span><span class="n">inputs</span><span class="p">)</span>
        <span class="n">logits</span> <span class="o">=</span> <span class="n">outputs</span><span class="p">.</span><span class="n">logits</span>
        <span class="n">loss_fct</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">nn</span><span class="p">.</span><span class="n">CrossEntropyLoss</span><span class="p">(</span><span class="n">weight</span><span class="o">=</span><span class="n">class_weights</span><span class="p">.</span><span class="n">to</span><span class="p">(</span><span class="n">logits</span><span class="p">.</span><span class="n">device</span><span class="p">))</span>
        <span class="n">loss</span> <span class="o">=</span> <span class="n">loss_fct</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">labels</span><span class="p">)</span>
        <span class="k">return</span> <span class="p">(</span><span class="n">loss</span><span class="p">,</span> <span class="n">outputs</span><span class="p">)</span> <span class="k">if</span> <span class="n">return_outputs</span> <span class="k">else</span> <span class="n">loss</span></code></pre></figure>

<p>To predict tema and subcategoria together, there are two options. A multi-task model shares one BETO backbone with two classification heads. It is cheaper to run, but the two tasks influence each other during training. Two separate models cost more compute, and an error in one task does not affect the other. The office has a single GPU workstation, so the current version uses the multi-task setup, and two separate models are planned as a comparison when more compute is available.</p>

<p>Each prediction returns a score for every category, so the prosecutor can see whether the top suggestion was clear or close:</p>

<figure class="highlight"><pre><code class="language-json" data-lang="json"><span class="p">{</span><span class="w">
  </span><span class="nl">"complaint_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"uuid-12345"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pending_human_verification"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"classification"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"tema"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"ai_suggested"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mal_desempeno"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"human_validated"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
      </span><span class="nl">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.87</span><span class="p">,</span><span class="w">
      </span><span class="nl">"all_scores"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"mal_desempeno"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.87</span><span class="p">,</span><span class="w">
        </span><span class="nl">"violencia_institucional"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.06</span><span class="p">,</span><span class="w">
        </span><span class="nl">"conflicto_laboral"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.04</span><span class="p">,</span><span class="w">
        </span><span class="nl">"otro"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.03</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span></code></pre></figure>

<hr />

<h3 id="9-human-review">9. Human review</h3>

<p>No classification is final without a person. The prosecutor sees the anonymized text next to the suggested category and its scores, and approves or changes it. Every change is logged and added to a table of corrected labels, which becomes training data for the next round of fine-tuning. Model errors are collected as part of the normal work, without a separate relabeling project.</p>

<p>For this office, review is a requirement. A wrong category on a complaint about institutional violence can delay an investigation, and the office was created to be more transparent than the institutions it oversees, so its own tools have to be open to checking.</p>

<p>One risk with this design is that reviewers start to accept suggestions without reading the complaint carefully, especially when the score is high. Tracking how often prosecutors change a suggestion, by category and by score, is a simple way to see whether this happens.</p>

<hr />

<h3 id="10-deployment-and-data-sovereignty">10. Deployment and data sovereignty</h3>

<p>The office required that no complaint text, raw or anonymized, leave government infrastructure. The plan is to run the whole pipeline on a GPU workstation inside the office or in the province’s data centre, with no need for internet access once deployed. This excluded cloud APIs for inference, and it is the reason I used an open model that runs locally, BETO, instead of a larger hosted model that would have required sending complaint text outside the province.</p>

<hr />

<h3 id="11-results">11. Results</h3>

<p>These are partial results, on a held-out set of 2,189 complaints that were not used for training.</p>

<ul>
  <li>Accuracy: 66 percent</li>
  <li>Weighted average F1 score: 66 percent</li>
</ul>

<p>The weighted average is below the project’s target, and it hides differences between categories:</p>

<ul>
  <li>Mal desempeño, the majority class, reaches 87 percent precision: when the model assigns this label, it is usually right.</li>
  <li>Violencia institucional reaches 77 percent recall: the model finds most of the real cases in this category.</li>
  <li>Violencia de género reaches an F1 score of 0.72, above the 0.70 target.</li>
</ul>

<p>For a triage tool, recall on the serious categories matters more than precision, because a person reviews every suggestion. A false alarm costs a prosecutor a few minutes; a missed case may stay at the back of the queue.</p>

<p><em>Figure 2: Confusion matrix across the eight official categories</em>
<img src="/docs/assets/secfs-confusion-matrix.png" alt="Figure 2" /></p>

<p><em>Figure 3: Training loss and validation loss over training steps</em>
<img src="/docs/assets/secfs-loss-curve.png" alt="Figure 3" /></p>

<p><em>Figure 4: Precision, recall and F1 by category</em>
<img src="/docs/assets/secfs-metrics-per-class.png" alt="Figure 4" /></p>

<hr />

<h3 id="12-discussion">12. Discussion</h3>

<p><strong>Overfitting.</strong> In Figure 3, the training loss keeps going down while the validation loss reaches a minimum and then rises. The model starts to memorize training examples instead of generalizing. The next steps are earlier stopping, stronger regularization and more training data for the smaller categories.</p>

<p><strong>Overlapping categories.</strong> Most errors in Figure 2 are between violencia familiar and violencia de género. Many cases of gender-based violence in this data happen inside a family, and the complaint texts often do not separate the two clearly. Part of this error comes from the category definitions rather than from the model. Two options are to allow a complaint to carry both labels, or to agree with the legal team on a written rule for cases that fit both.</p>

<p><strong>Limits of class weighting.</strong> Weighting the loss improves the results on the smaller categories, but it cannot replace missing examples. The most direct improvement is more labeled data for the categories that matter most for oversight, which are also the ones that occur least often. The corrections collected through human review (section 9) are one way to build this data over time.</p>

<hr />

<h3 id="13-legal-and-governance-relevance">13. Legal and governance relevance</h3>

<p>A recurring question about AI in public institutions is how to get the benefits of automated triage without creating an opaque system inside a body whose job is accountability. The main design choices in this project, anonymization, mandatory human review and no external servers, all come from that question.</p>

<p>The anonymization requirement comes from Argentina’s Personal Data Protection Law (Law 25.326). The human review requirement comes from the office’s mandate under Law 10.731 to be more transparent than the institutions it investigates. Both were conditions for building the classifier, set in the first design meetings.</p>

<p>The EU AI Act is a useful comparison. It classifies some AI systems used in law enforcement as high risk, for example systems that evaluate the reliability of evidence or support profiling in an investigation, and it requires human oversight and logging for them (Article 14 and Annex III). Argentina is not bound by the Act, and this system is not one of the tools it describes. Still, the Act reaches the same requirements this project had for its own reasons: human review of each decision, an audit trail and no fully automatic decisions. This suggests that these requirements are a common baseline wherever AI is used in decisions by the state about individuals.</p>

<p>The data sovereignty requirement follows the same logic. The data concerns both police officers and vulnerable complainants, and the province did not accept processing it on infrastructure it does not control. In practice, this meant choosing an open model that runs locally over a more capable hosted one, and building the audit log before the dashboard. The cost of this choice is real: a smaller local model may perform worse than a large hosted one, and the province accepted that trade-off for this data.</p>

<hr />

<h3 id="14-roadmap">14. Roadmap</h3>

<p>The specification includes these next steps:</p>

<ul>
  <li>An urgency scale from P1 to P5 on top of the category, so the most time-sensitive complaints are reviewed first.</li>
  <li>A bias monitoring dashboard that checks whether predictions correlate with demographic or geographic patterns that should not affect a classification.</li>
  <li>A fixed threshold for acceptable personal data leakage, so that the anonymization audit has a number to test against.</li>
  <li>Export of the model to ONNX for faster CPU inference on the office’s hardware.</li>
  <li>A documented REST API (OpenAPI and Swagger), so the office’s intake system can call the pipeline directly.</li>
</ul>

<hr />

<h3 id="15-references">15. References</h3>

<ul>
  <li>Law 10.731, Province of Córdoba, Argentina. Creation of the disciplinary control system for the security forces.</li>
  <li>Law 25.326, National Personal Data Protection Law, Argentina.</li>
  <li>Prometea, Public Prosecutor’s Office of the City of Buenos Aires (Ministerio Público Fiscal de la CABA).</li>
  <li>PretorIA, Constitutional Court of Colombia.</li>
  <li>Cañete, J. et al., “Spanish Pre-Trained BERT Model and Evaluation Data,” PML4DC at ICLR, 2020. (BETO)</li>
  <li>Wolf, T. et al., “Transformers: State-of-the-Art Natural Language Processing,” Hugging Face, 2020.</li>
  <li>European Union, Artificial Intelligence Act, Article 14 and Annex III, on high-risk classification and human oversight for law enforcement AI systems.</li>
  <li>Microsoft Presidio, data protection and anonymization SDK.</li>
  <li>spaCy, es_core_news_lg Spanish language model.</li>
</ul>

<p>I developed this project during my internship as AI Product Owner and ML Developer with the Coordination of Information Registry and Analysis, part of the Security Forces Disciplinary Control System of the Province of Córdoba, Argentina, between November 2025 and June 2026. The results above are partial: the classifier is in its second build phase, and deployment on government infrastructure is planned for the next phase. Because the system processes real and sensitive government data, the source code is not public. This article describes the design and the method.</p>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[How I designed and built an on-premise NLP pipeline that anonymizes citizen complaints about police misconduct and suggests categories for human review.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Justice Inequality Simulator: Counterfactual Testing of a Court Outcome Model for Bias</title><link href="https://nicolas-cozzarin.github.io/2025/07/06/justice-inequality-simulator.html" rel="alternate" type="text/html" title="Justice Inequality Simulator: Counterfactual Testing of a Court Outcome Model for Bias" /><published>2025-07-06T00:00:00+00:00</published><updated>2025-07-06T00:00:00+00:00</updated><id>https://nicolas-cozzarin.github.io/2025/07/06/justice-inequality-simulator</id><content type="html" xml:base="https://nicolas-cozzarin.github.io/2025/07/06/justice-inequality-simulator.html"><![CDATA[<h3 id="1-motivation">1. Motivation</h3>

<p>In May 2016, ProPublica published an investigation into COMPAS, a risk assessment tool used in the United States to predict whether a defendant will reoffend. Its main finding was that Black defendants who did not reoffend were classified as high risk at roughly twice the rate of white defendants who did not reoffend. Race was not an input of the tool. The bias came from the historical data the model learned from.</p>

<p>A year later, the Wisconsin Supreme Court ruled on this problem in State v. Loomis. Eric Loomis had been sentenced partly on the basis of a COMPAS score, and he could not see how the score was calculated because the method was a trade secret. The court allowed courts to keep using the tool, on condition that every report included a written warning about its limitations. The defendant still had no way to examine how his own score was produced.</p>

<p>Judicial risk prediction keeps running into the same issue. Historical sentencing data reflects the decisions of the judges, prosecutors and police who produced it. A model trained on that data reproduces those patterns in the form of a score, and a score is harder to contest than a person’s judgment because it looks objective. When the method is closed, the people affected by the score have very little to work with if they want to challenge it.</p>

<p>Most bias audits of these systems work at the group level: they compare error rates across race, gender or income and report the gap. This is useful, but it does not show which individual decisions depend on which words. I wanted to test something more direct: take one case, create a version where only one sensitive detail changes, and see whether the model’s prediction moves. If the predicted outcome changes only because a name or a pronoun changes, that is a concrete failure in a specific case.</p>

<p><strong>Goal:</strong> measure how sensitive a court-outcome prediction model is to sensitive attributes in the case text (gender, ethnicity and socioeconomic markers) by generating counterfactual versions of each case, and use that sensitivity as a case-level measure of bias in addition to group statistics.</p>

<hr />

<h3 id="2-data">2. Data</h3>

<p>I considered three datasets:</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Rows</th>
      <th>Key features</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>COMPAS recidivism</td>
      <td>≈ 7,000</td>
      <td>age, priors, race, charge</td>
    </tr>
    <tr>
      <td>U.S. Supreme Court Database (SCDB)</td>
      <td>≈ 28,000</td>
      <td>issue, petitioner, lower-court direction</td>
    </tr>
    <tr>
      <td>justice.csv (U.S. Supreme Court cases, course data)</td>
      <td>≈ 5,000</td>
      <td>free-text facts, first_party_winner</td>
    </tr>
  </tbody>
</table>

<p>The method needs a text description of each case, so I used the third one, justice.csv. It contains U.S. Supreme Court cases with a short summary of the facts and a label saying whether the first party won. The other two datasets are mostly structured variables. The same method can be applied to any dataset that has a text description of the facts, with the preprocessing adapted to it.</p>

<p>Dataset and reference notebook: <a href="https://www.kaggle.com/code/raghavkachroo/supreme-court-judgement-prediction">Supreme Court judgement prediction on Kaggle</a></p>

<p><em>Figure 1: Most frequent words in the case descriptions</em>
<img src="/docs/assets/mostfrequentwords.png" alt="Figure 1" /></p>

<hr />

<h3 id="3-preprocessing">3. Preprocessing</h3>

<p>The case descriptions contain HTML tags, punctuation and extra whitespace. I remove them so that the model receives clean text, and I convert the outcome into a binary label.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="nn">re</span>
<span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>

<span class="n">TAG_RE</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nb">compile</span><span class="p">(</span><span class="sa">r</span><span class="s">"&lt;[^&gt;]+&gt;"</span><span class="p">)</span>
<span class="n">PUNCT</span> <span class="o">=</span> <span class="nb">str</span><span class="p">.</span><span class="n">maketrans</span><span class="p">(</span><span class="s">""</span><span class="p">,</span> <span class="s">""</span><span class="p">,</span> <span class="sa">r</span><span class="s">"""!"#$%&amp;'()*+,-./:;&lt;=&gt;?@[]^_`{|}~"""</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">clean</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="s">"""Strip HTML and punctuation, and collapse whitespace."""</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">TAG_RE</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="s">""</span><span class="p">,</span> <span class="n">html</span> <span class="ow">or</span> <span class="s">""</span><span class="p">).</span><span class="n">translate</span><span class="p">(</span><span class="n">PUNCT</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">re</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s">"\s+"</span><span class="p">,</span> <span class="s">" "</span><span class="p">,</span> <span class="n">text</span><span class="p">).</span><span class="n">strip</span><span class="p">()</span>

<span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">read_csv</span><span class="p">(</span><span class="s">"justice.csv"</span><span class="p">).</span><span class="n">dropna</span><span class="p">(</span><span class="n">subset</span><span class="o">=</span><span class="p">[</span><span class="s">"facts"</span><span class="p">,</span> <span class="s">"first_party_winner"</span><span class="p">])</span>
<span class="n">df</span><span class="p">[</span><span class="s">"text"</span><span class="p">]</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="s">"facts"</span><span class="p">].</span><span class="nb">map</span><span class="p">(</span><span class="n">clean</span><span class="p">)</span>
<span class="n">df</span><span class="p">[</span><span class="s">"label"</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">df</span><span class="p">[</span><span class="s">"first_party_winner"</span><span class="p">]</span>
    <span class="p">.</span><span class="n">astype</span><span class="p">(</span><span class="nb">str</span><span class="p">).</span><span class="nb">str</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span>
    <span class="p">.</span><span class="nb">map</span><span class="p">({</span><span class="s">"true"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"false"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span> <span class="s">"1"</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s">"0"</span><span class="p">:</span> <span class="mi">0</span><span class="p">})</span>
<span class="p">)</span></code></pre></figure>

<p><em>Figure 2: Example of a cleaned record</em>
<img src="/docs/assets/factsclean.png" alt="Figure 2" /></p>

<hr />

<h3 id="4-balancing-the-data">4. Balancing the data</h3>

<p>One outcome is more frequent than the other. Without a correction, a classifier can reach a good accuracy by predicting the majority outcome most of the time. I upsampled the minority class so that both outcomes have the same number of cases. Section 14 explains a problem with doing this before the train/test split.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="nn">sklearn.utils</span> <span class="kn">import</span> <span class="n">resample</span>

<span class="n">pos</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">.</span><span class="n">label</span> <span class="o">==</span> <span class="mi">1</span><span class="p">]</span>
<span class="n">neg</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">.</span><span class="n">label</span> <span class="o">==</span> <span class="mi">0</span><span class="p">]</span>

<span class="n">minority</span> <span class="o">=</span> <span class="n">pos</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">pos</span><span class="p">)</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">neg</span><span class="p">)</span> <span class="k">else</span> <span class="n">neg</span>
<span class="n">majority</span> <span class="o">=</span> <span class="n">neg</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">pos</span><span class="p">)</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">neg</span><span class="p">)</span> <span class="k">else</span> <span class="n">pos</span>

<span class="n">minority_up</span> <span class="o">=</span> <span class="n">resample</span><span class="p">(</span>
    <span class="n">minority</span><span class="p">,</span>
    <span class="n">replace</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">n_samples</span><span class="o">=</span><span class="nb">len</span><span class="p">(</span><span class="n">majority</span><span class="p">),</span>
    <span class="n">random_state</span><span class="o">=</span><span class="mi">42</span><span class="p">,</span>
<span class="p">)</span>

<span class="n">df_bal</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">concat</span><span class="p">([</span><span class="n">majority</span><span class="p">,</span> <span class="n">minority_up</span><span class="p">]).</span><span class="n">reset_index</span><span class="p">(</span><span class="n">drop</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span></code></pre></figure>

<p><em>Figure 3: Class distribution after balancing</em>
<img src="/docs/assets/balancedCases.png" alt="Figure 3" /></p>

<hr />

<h3 id="5-features-with-legal-bert">5. Features with Legal-BERT</h3>

<p>I convert each case description into a vector with Legal-BERT, a BERT model pre-trained on legal text. I use the hidden state of the [CLS] token as the representation of the whole text, which is the usual choice for classification. The embeddings are computed in batches, on GPU when available, and saved to disk so they do not have to be recomputed.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">import</span> <span class="nn">torch</span>
<span class="kn">from</span> <span class="nn">transformers</span> <span class="kn">import</span> <span class="n">AutoTokenizer</span><span class="p">,</span> <span class="n">AutoModel</span>

<span class="n">DEVICE</span> <span class="o">=</span> <span class="s">"cuda"</span> <span class="k">if</span> <span class="n">torch</span><span class="p">.</span><span class="n">cuda</span><span class="p">.</span><span class="n">is_available</span><span class="p">()</span> <span class="k">else</span> <span class="s">"cpu"</span>
<span class="n">MODEL_ID</span> <span class="o">=</span> <span class="s">"nlpaueb/legal-bert-base-uncased"</span>

<span class="n">tok</span> <span class="o">=</span> <span class="n">AutoTokenizer</span><span class="p">.</span><span class="n">from_pretrained</span><span class="p">(</span><span class="n">MODEL_ID</span><span class="p">)</span>
<span class="n">bert</span> <span class="o">=</span> <span class="n">AutoModel</span><span class="p">.</span><span class="n">from_pretrained</span><span class="p">(</span><span class="n">MODEL_ID</span><span class="p">).</span><span class="n">to</span><span class="p">(</span><span class="n">DEVICE</span><span class="p">).</span><span class="nb">eval</span><span class="p">()</span>

<span class="o">@</span><span class="n">torch</span><span class="p">.</span><span class="n">no_grad</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">embed</span><span class="p">(</span><span class="n">txts</span><span class="p">,</span> <span class="n">batch</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span> <span class="n">max_len</span><span class="o">=</span><span class="mi">256</span><span class="p">):</span>
    <span class="n">vecs</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">txts</span><span class="p">),</span> <span class="n">batch</span><span class="p">):</span>
        <span class="n">enc</span> <span class="o">=</span> <span class="n">tok</span><span class="p">(</span>
            <span class="n">txts</span><span class="p">[</span><span class="n">i</span><span class="p">:</span><span class="n">i</span> <span class="o">+</span> <span class="n">batch</span><span class="p">],</span>
            <span class="n">padding</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
            <span class="n">truncation</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
            <span class="n">max_length</span><span class="o">=</span><span class="n">max_len</span><span class="p">,</span>
            <span class="n">return_tensors</span><span class="o">=</span><span class="s">"pt"</span><span class="p">,</span>
        <span class="p">).</span><span class="n">to</span><span class="p">(</span><span class="n">DEVICE</span><span class="p">)</span>
        <span class="n">h</span> <span class="o">=</span> <span class="n">bert</span><span class="p">(</span><span class="o">**</span><span class="n">enc</span><span class="p">).</span><span class="n">last_hidden_state</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">]</span>  <span class="c1"># [CLS] token
</span>        <span class="n">vecs</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">h</span><span class="p">.</span><span class="n">cpu</span><span class="p">())</span>
    <span class="k">return</span> <span class="n">torch</span><span class="p">.</span><span class="n">cat</span><span class="p">(</span><span class="n">vecs</span><span class="p">).</span><span class="n">numpy</span><span class="p">()</span>

<span class="n">X</span> <span class="o">=</span> <span class="n">embed</span><span class="p">(</span><span class="n">df_bal</span><span class="p">.</span><span class="n">text</span><span class="p">.</span><span class="n">tolist</span><span class="p">())</span>
<span class="n">np</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="s">"legal_cls.npy"</span><span class="p">,</span> <span class="n">X</span><span class="p">)</span>  <span class="c1"># cache</span></code></pre></figure>

<p><em>Figure 4: t-SNE projection of the [CLS] embeddings, coloured by outcome</em>
<img src="/docs/assets/tsne.png" alt="Figure 4" /></p>

<p>Figure 4 projects the 768-dimensional embeddings to two dimensions. Each dot is a case, and the colour shows whether the first party won or lost. The two outcomes are mixed together, and the two clusters that do appear are not related to the outcome. The pre-trained embeddings alone do not separate wins from losses, which makes the classifier’s job harder.</p>

<hr />

<h3 id="6-scaling">6. Scaling</h3>

<p>I standardize the embeddings and multiply them by 5. The factor was meant to balance the text features against hand-crafted features in a later version; in this version there are no other features, so it only changes the scale of the input.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="nn">sklearn.preprocessing</span> <span class="kn">import</span> <span class="n">StandardScaler</span>

<span class="n">scaler</span> <span class="o">=</span> <span class="n">StandardScaler</span><span class="p">().</span><span class="n">fit</span><span class="p">(</span><span class="n">X</span><span class="p">)</span>
<span class="n">X_std</span> <span class="o">=</span> <span class="n">scaler</span><span class="p">.</span><span class="n">transform</span><span class="p">(</span><span class="n">X</span><span class="p">)</span> <span class="o">*</span> <span class="mi">5</span></code></pre></figure>

<hr />

<h3 id="7-train-validation-and-test-split">7. Train, validation and test split</h3>

<p>I split the data into 60 percent for training, 20 percent for validation and 20 percent for testing, keeping the same proportion of outcomes in each part.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="nn">sklearn.model_selection</span> <span class="kn">import</span> <span class="n">train_test_split</span>

<span class="n">idx</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">X_std</span><span class="p">))</span>
<span class="n">train</span><span class="p">,</span> <span class="n">tmp</span> <span class="o">=</span> <span class="n">train_test_split</span><span class="p">(</span>
    <span class="n">idx</span><span class="p">,</span> <span class="n">test_size</span><span class="o">=</span><span class="mf">0.4</span><span class="p">,</span> <span class="n">random_state</span><span class="o">=</span><span class="mi">42</span><span class="p">,</span> <span class="n">stratify</span><span class="o">=</span><span class="n">df_bal</span><span class="p">.</span><span class="n">label</span>
<span class="p">)</span>
<span class="n">val</span><span class="p">,</span> <span class="n">test</span> <span class="o">=</span> <span class="n">train_test_split</span><span class="p">(</span>
    <span class="n">tmp</span><span class="p">,</span> <span class="n">test_size</span><span class="o">=</span><span class="mf">0.5</span><span class="p">,</span> <span class="n">random_state</span><span class="o">=</span><span class="mi">42</span><span class="p">,</span> <span class="n">stratify</span><span class="o">=</span><span class="n">df_bal</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">iloc</span><span class="p">[</span><span class="n">tmp</span><span class="p">]</span>
<span class="p">)</span></code></pre></figure>

<hr />

<h3 id="8-model">8. Model</h3>

<p>The classifier is a multi-layer perceptron with three hidden layers of 1024, 512 and 128 neurons on top of the 768-dimensional embeddings, with a sigmoid output for the binary outcome. Training stops early when the validation score stops improving.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">from</span> <span class="nn">sklearn.neural_network</span> <span class="kn">import</span> <span class="n">MLPClassifier</span>

<span class="n">mlp</span> <span class="o">=</span> <span class="n">MLPClassifier</span><span class="p">(</span>
    <span class="n">hidden_layer_sizes</span><span class="o">=</span><span class="p">(</span><span class="mi">1024</span><span class="p">,</span> <span class="mi">512</span><span class="p">,</span> <span class="mi">128</span><span class="p">),</span>
    <span class="n">activation</span><span class="o">=</span><span class="s">"relu"</span><span class="p">,</span>
    <span class="n">alpha</span><span class="o">=</span><span class="mf">1e-3</span><span class="p">,</span>
    <span class="n">batch_size</span><span class="o">=</span><span class="mi">128</span><span class="p">,</span>
    <span class="n">max_iter</span><span class="o">=</span><span class="mi">120</span><span class="p">,</span>
    <span class="n">early_stopping</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">validation_fraction</span><span class="o">=</span><span class="mf">0.20</span><span class="p">,</span>
    <span class="n">n_iter_no_change</span><span class="o">=</span><span class="mi">7</span><span class="p">,</span>
    <span class="n">random_state</span><span class="o">=</span><span class="mi">42</span><span class="p">,</span>
<span class="p">)</span>

<span class="n">mlp</span><span class="p">.</span><span class="n">fit</span><span class="p">(</span><span class="n">X_std</span><span class="p">[</span><span class="n">train</span><span class="p">],</span> <span class="n">df_bal</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">iloc</span><span class="p">[</span><span class="n">train</span><span class="p">])</span></code></pre></figure>

<p><em>Figure 5: Model architecture</em>
<img src="/docs/assets/archi.png" alt="Figure 5" /></p>

<hr />

<h3 id="9-training-checks">9. Training checks</h3>

<p>I followed the training loss and compared the accuracy on the training, validation and test sets to check for overfitting.</p>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="kn">import</span> <span class="nn">matplotlib.pyplot</span> <span class="k">as</span> <span class="n">plt</span>
<span class="kn">from</span> <span class="nn">sklearn.metrics</span> <span class="kn">import</span> <span class="n">accuracy_score</span>

<span class="n">plt</span><span class="p">.</span><span class="n">plot</span><span class="p">(</span><span class="n">mlp</span><span class="p">.</span><span class="n">loss_curve_</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">xlabel</span><span class="p">(</span><span class="s">"Epoch"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">ylabel</span><span class="p">(</span><span class="s">"Loss"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">title</span><span class="p">(</span><span class="s">"MLP training loss"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">savefig</span><span class="p">(</span><span class="s">"assets/2025-07-06-loss.png"</span><span class="p">)</span>

<span class="n">acc_train</span> <span class="o">=</span> <span class="n">accuracy_score</span><span class="p">(</span><span class="n">df_bal</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">iloc</span><span class="p">[</span><span class="n">train</span><span class="p">],</span> <span class="n">mlp</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">X_std</span><span class="p">[</span><span class="n">train</span><span class="p">]))</span>
<span class="n">acc_val</span> <span class="o">=</span> <span class="n">accuracy_score</span><span class="p">(</span><span class="n">df_bal</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">iloc</span><span class="p">[</span><span class="n">val</span><span class="p">],</span> <span class="n">mlp</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">X_std</span><span class="p">[</span><span class="n">val</span><span class="p">]))</span>
<span class="n">acc_test</span> <span class="o">=</span> <span class="n">accuracy_score</span><span class="p">(</span><span class="n">df_bal</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">iloc</span><span class="p">[</span><span class="n">test</span><span class="p">],</span> <span class="n">mlp</span><span class="p">.</span><span class="n">predict</span><span class="p">(</span><span class="n">X_std</span><span class="p">[</span><span class="n">test</span><span class="p">]))</span></code></pre></figure>

<hr />

<h3 id="10-counterfactual-generator">10. Counterfactual generator</h3>

<p>A counterfactual is a version of a case description with a few words changed and everything else kept the same. Comparing the model’s prediction on the original and on the counterfactual shows which words the prediction depends on. The generator works in three steps:</p>

<ol>
  <li>Swap every sensitive word that has a pair in the mapping (for example “he” and “she”).</li>
  <li>Swap a fixed number of the words with the highest influence on the prediction (TOP_FORCE).</li>
  <li>Keep swapping influential words until a maximum number of edits is reached (TOP_K).</li>
</ol>

<figure class="highlight"><pre><code class="language-python" data-lang="python"><span class="k">def</span> <span class="nf">build_cf</span><span class="p">(</span><span class="n">i</span><span class="p">):</span>
    <span class="n">w</span> <span class="o">=</span> <span class="n">toks</span><span class="p">(</span><span class="n">texts</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>
    <span class="n">changed</span><span class="p">,</span> <span class="n">sens</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(),</span> <span class="p">[]</span>
    <span class="n">impacts</span> <span class="o">=</span> <span class="n">impact_map</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>

    <span class="c1"># 1) swap sensitive tokens (skip no-ops)
</span>    <span class="k">for</span> <span class="n">p</span><span class="p">,</span> <span class="n">t</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">w</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">t</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span> <span class="ow">in</span> <span class="n">SENSITIVE_MAP</span><span class="p">:</span>
            <span class="n">new</span> <span class="o">=</span> <span class="n">subst</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">new</span> <span class="o">!=</span> <span class="n">t</span><span class="p">:</span>
                <span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">]</span> <span class="o">=</span> <span class="n">new</span>
                <span class="n">changed</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
                <span class="n">sens</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">t</span><span class="p">,</span> <span class="n">new</span><span class="p">))</span>

    <span class="c1"># 2) force TOP_FORCE swaps among the most influential tokens
</span>    <span class="n">imp_sorted</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">impacts</span><span class="p">.</span><span class="n">items</span><span class="p">(),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">reverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">forced</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">p</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">imp_sorted</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">forced</span> <span class="o">&gt;=</span> <span class="n">TOP_FORCE</span><span class="p">:</span>
            <span class="k">break</span>
        <span class="k">if</span> <span class="n">p</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">changed</span><span class="p">:</span>
            <span class="n">new</span> <span class="o">=</span> <span class="n">subst</span><span class="p">(</span><span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">])</span>
            <span class="k">if</span> <span class="n">new</span> <span class="o">!=</span> <span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">]:</span>
                <span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">]</span> <span class="o">=</span> <span class="n">new</span>
                <span class="n">changed</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
                <span class="n">forced</span> <span class="o">+=</span> <span class="mi">1</span>

    <span class="c1"># 3) continue until TOP_K edits
</span>    <span class="k">for</span> <span class="n">p</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">imp_sorted</span><span class="p">:</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">changed</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="nb">min</span><span class="p">(</span><span class="n">TOP_K</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">w</span><span class="p">)):</span>
            <span class="k">break</span>
        <span class="k">if</span> <span class="n">p</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">changed</span><span class="p">:</span>
            <span class="n">new</span> <span class="o">=</span> <span class="n">subst</span><span class="p">(</span><span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">])</span>
            <span class="k">if</span> <span class="n">new</span> <span class="o">!=</span> <span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">]:</span>
                <span class="n">w</span><span class="p">[</span><span class="n">p</span><span class="p">]</span> <span class="o">=</span> <span class="n">new</span>
                <span class="n">changed</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>

    <span class="k">return</span> <span class="s">" "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">w</span><span class="p">),</span> <span class="n">changed</span><span class="p">,</span> <span class="n">sens</span><span class="p">,</span> <span class="n">impacts</span></code></pre></figure>

<p><em>Figure 6: Example of a counterfactual</em>
<img src="/docs/assets/counterfactual.png" alt="Figure 6" /></p>

<p>The full code is in the Colab notebook linked at the end of this article.</p>

<hr />

<h3 id="11-measuring-sensitivity">11. Measuring sensitivity</h3>

<p>The idea behind the test is simple: if a model is fair with respect to an attribute, its prediction should not change when only that attribute changes in the text.</p>

<p>For each case, I compute the predicted probability that the first party wins, build the counterfactual with <code class="language-plaintext highlighter-rouge">build_cf</code>, and compute the probability again. The difference between the two is the model’s sensitivity for that case. Because the generator also records which tokens were swapped and how much each one influenced the prediction, the test also shows which words drove the change.</p>

<p>This measure is local: it describes the behaviour of the model on one case. Group metrics describe averages over many cases. The two answer different questions, and an audit needs both.</p>

<hr />

<h3 id="12-results">12. Results</h3>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Accuracy (test)</td>
      <td>0.78</td>
    </tr>
    <tr>
      <td>ROC-AUC (test)</td>
      <td>0.84</td>
    </tr>
  </tbody>
</table>

<p>I examined the counterfactuals case by case with an interactive loop:</p>

<ul>
  <li>Swaps of gendered words moved the predicted probability by more than 0.05.</li>
  <li>Substitutions of sensitive words often moved it by 0.10 to 0.25.</li>
  <li>The shifts were larger when words related to the sensitive term were also changed.</li>
  <li>Some cases did not change at all.</li>
</ul>

<p>This suggests that in some cases the prediction depends on wording linked to gender, ethnicity or socioeconomic status, even though none of these attributes is an input of the model. Section 14 explains why these numbers are indicative rather than final.</p>

<hr />

<h3 id="13-discussion">13. Discussion</h3>

<p>The training loss levels off and the validation and test accuracies are similar, so the model does not show strong overfitting. Accuracy, however, says nothing about whether the prediction depends on sensitive wording, which is what the counterfactual test is for.</p>

<p>Two improvements would make the predictions more reliable for an audit. Cases whose embedding is more than three standard deviations from the training mean could be flagged as uncertain, so the model reports low confidence instead of a precise-looking number. Temperature scaling would make the predicted probabilities better calibrated, which matters when the measure of bias is a change in probability.</p>

<p>Future work could replace the MLP with a generative model such as a conditional VAE or a cGAN, which could generate counterfactuals that read more naturally than word swaps, and expose the system through a REST API.</p>

<hr />

<h3 id="14-limits-of-this-evaluation">14. Limits of this evaluation</h3>

<p>Reviewing the project later, I found three issues that a reader should keep in mind.</p>

<p><strong>Upsampling before the split.</strong> I upsampled the minority class (section 4) before splitting the data (section 7). Upsampling copies cases, so the same case can appear in both the training set and the test set. The model has then already seen part of the test data, and the accuracy of 0.78 and the ROC-AUC of 0.84 are probably optimistic. The correct order is to split first and balance only the training set, or to use class weights instead of copies.</p>

<p><strong>Edits that are not sensitive.</strong> Steps 2 and 3 of the generator also change the most influential words, even when they are not sensitive. In those counterfactuals, part of the shift comes from these extra edits. To attribute a change to gender or ethnicity, the measurement should use counterfactuals with only the sensitive swaps.</p>

<p><strong>Case-by-case inspection.</strong> The shifts in section 12 come from inspecting examples, not from a run over the whole test set. A stronger result would report the average and the distribution of the shift over all test cases, separately for each type of swap. Simple word swaps can also change the meaning of a sentence (for example “white” in “white car”), so the swap list needs to be checked in context.</p>

<p>These issues concern how the method was evaluated in this project, not the idea of counterfactual testing itself.</p>

<hr />

<h3 id="15-policy-and-governance-relevance">15. Policy and governance relevance</h3>

<p>This project is a small academic version of a question that courts and governments are already facing: what happens when part of a judicial decision depends on a model trained on a history that is not neutral.</p>

<p>The COMPAS investigation and State v. Loomis show two sides of the problem. ProPublica’s finding came from comparing error rates between groups, which a single accuracy score would not have shown. A counterfactual test looks for the same kind of problem in individual cases. Loomis shows that even when a court has doubts about a tool, it may have no way to examine a specific decision because the method is proprietary. A counterfactual test does not need access to the model’s internals: it only needs the ability to change an input and observe the output. A court, a regulator or a journalist can run it on a system they do not control.</p>

<p>Two European texts point in the same direction. Article 22 of the GDPR gives individuals the right not to be subject to a decision based solely on automated processing that has legal effects on them, without safeguards. The EU AI Act classifies AI systems that assist judicial authorities in researching and interpreting facts and law as high risk (Recital 61 and Annex III), because of “the risks of potential biases, errors and opacity.” Both texts require systems whose decisions can be examined and contested. The counterfactual method is one practical way to examine them.</p>

<p>A class project does not settle these questions. Building a small audit tool did help me understand what these legal requirements ask engineers to do in practice.</p>

<hr />

<h3 id="16-references">16. References</h3>

<ul>
  <li>COMPAS dataset (ProPublica)</li>
  <li>Supreme Court Database (Washington University, Olin School of Law)</li>
  <li>Angwin, J., Larson, J., Mattu, S., Kirchner, L., “Machine Bias,” ProPublica, 2016</li>
  <li>State v. Loomis, 881 N.W.2d 749, 2016 WI 68 (Wis. 2016)</li>
  <li>European Union, General Data Protection Regulation, Article 22</li>
  <li>European Union, Artificial Intelligence Act, Recital 61 and Annex III, point 8, on high-risk classification for AI assisting judicial authorities</li>
  <li>Devlin et al., “BERT: Pre-training of Deep Bidirectional Transformers,” 2018</li>
  <li>CEUR-WS Vol-3841, Paper 5: Counterfactual Explanations in Legal NLP</li>
  <li>HFU Deep Generative Models lecture notes</li>
</ul>

<p>This project started as a project proposal for Ruxandra Lasowski’s Deep Generative Models class in the HFU Master’s programme.</p>

<p><strong>Source code:</strong> <a href="https://colab.research.google.com/drive/1vRHQOD1OUOzySDsNfvsINIWz_LFxucYV?usp=sharing">Google Colab notebook</a></p>

<p><img src="/docs/assets/poster.png" alt="Poster" /></p>]]></content><author><name>Nicolas Cozzarin</name></author><category term="Other" /><summary type="html"><![CDATA[A counterfactual method to test whether a court-outcome prediction model is biased, one case at a time, and what its results do and do not show.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" /><media:content medium="image" url="https://nicolas-cozzarin.github.io/docs/assets/og-image.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>