Responsible AI is not an ethics slogan on the last slide of a deck. It is a concrete set of engineering tasks: knowing which personal data you use, whether you have the right to use it, whether the model treats customer groups differently, and having evidence ready when someone asks. This article does three things with code that actually runs: it masks personal data before it reaches logs or an LLM, audits an installment-approval model for bias with Fairlearn, and checks the result against Vietnam's legal framework. The framework has changed, so the Decree 13 named in the title needs to be placed correctly, as the legal section below explains.

It follows Monitoring models in production: data drift, concept drift and alerting and LLM evaluation and guardrails: test suites, LLM-as-judge and prompt injection defense. Those two answer "is the model still correct and safe?"; this one answers "is the model allowed to be used, and is it fair?". The outputs are real results on an Apple M4 with Python 3.12, fairlearn==0.13.0 and scikit-learn==1.9.0; the code lives in code/s7-52. The data is simulated and generated on the fly, and the phone and national ID numbers are made up. This is a technical article, not legal advice: check the original texts and ask your legal team before you apply anything.

What responsible AI covers in a real project

In a real company, responsible AI comes down to four questions that a customer or a regulator can ask at any time:

  1. Which data: a responsible AI system must know which personal data the model and the logs contain, what the legal basis is, and how long the data is kept.
  2. Fairness: responsible AI must answer whether approval rates and error rates differ a lot between groups, and where that difference comes from.
  3. Explanation and appeal: in responsible AI, when an application is rejected, who explains the reason and who reviews the decision?
  4. Evidence: is there a record of the data version, the model version and the audit results?

Earlier articles supplied the technical pieces: Explaining models with SHAP: understanding why a model decides explains decisions, and MLOps with MLflow: experiment tracking, the model registry and deployment stores versions. This article adds personal data protection and bias measurement.

The legal framework for responsible AI in Vietnam: what replaced Decree 13

Many older articles and documents about responsible AI still call "Decree 13" the personal data protection rule. That has changed. According to the summary from LexData: Law on Personal Data Protection No. 91/2025/QH15 was passed by the National Assembly on June 26, 2025 and takes effect on January 1, 2026; Decree 356/2025/ND-CP gives the implementing details; and Decree 13/2023/ND-CP ceases to be effective on January 1, 2026 under Clause 2, Article 42 of Decree 356. Some commercial articles interpret the transition period differently, so check the original texts instead of trusting any single summary, this one included: Law 91/2025/QH15 and Decree 356/2025/ND-CP on the Official Gazette.

Points of the law that a responsible AI team needs to know, according to that summary:

  • Consent (Article 9): it must be voluntary, explicit and given per purpose; silence or no response is not consent. Customer data collected for delivery is not automatically usable to train a scoring model.
  • Sensitive data: it carries stricter requirements, for example health, finance, credit, location and biometrics (Articles 26, 27 and 31). Article 27 states that credit information must not be used for scoring or ranking without consent.
  • Data processing impact assessment (Article 21): prepare a dossier and submit it to the specialized authority within 60 days of the first day of processing, then update it when things change.
  • Data subject rights: the right to know, to withdraw consent (not retroactively), to correct, to request deletion or restriction of processing, and to complain.
  • AI processing (Article 30): it must be classified by risk level, with details to be set by the Government.

Beyond the data law, there is also the Law on Artificial Intelligence No. 134/2025/QH15, promulgated on December 10, 2025 and effective from March 1, 2026 (per the Government's legal document portal). According to a roundup on VTV, the law splits AI systems into three risk levels (high, medium, low) based on their impact on human rights, national security and public order, with different obligations and oversight for each level.

An installment-approval or credit-scoring model touches customers' financial interests, so you need to assess which level it falls into; the classification must follow the official catalog and guidance. The transition period for systems that were already running before the law took effect also needs to be read in the original text.

For the engineering team doing responsible AI: ask your legal team three things (the legal basis and purpose, whether an impact assessment dossier is needed, and which risk level the model belongs to) and record the answers in the same repo. The two sections below are what the team can do right away.

Responsible AI starts with minimizing personal data

The first principle of responsible AI: the safest way to handle personal data is not to keep it. Application logs, customer support tickets and text sent to an LLM often contain phone numbers, emails and national IDs that nobody meant to store. The first step of responsible AI is a masking layer at every exit point: writing logs, exporting training data and calling an external LLM. The code below uses regular expressions for three fixed-format types and replaces each value with a pseudonym computed as HMAC-SHA256 with a secret key:

PATTERNS = {
    "CCCD": re.compile(r"(?<!\d)\d{12}(?!\d)"),
    "DIEN_THOAI": re.compile(r"(?<!\d)(?:\+84|0)(?:3|5|7|8|9)\d{8}(?!\d)"),
    "EMAIL": re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+"),
}
KEY = os.environ.get("PSEUDO_KEY", "khoa-demo-khong-dung-that").encode()

def pseudonym(kind, value):
    digest = hmac.new(KEY, f"{kind}:{value}".encode(), hashlib.sha256).hexdigest()[:8]
    return f"<{kind}_{digest}>"

Two details are worth noting. First, (?<!\d) and (?!\d) are required so that a 15-digit string, such as a shipping tracking number, is not mistaken for a 12-digit national ID (CCCD). Second, use keyed HMAC instead of a plain hash: Vietnamese mobile numbers have only a few hundred million possible values, so an unkeyed SHA-256 can be reversed by trying them all. Run demo_pii.py (the sample strings are in unaccented Vietnamese: GOC is the original text, MASK the masked text, nhat quan means consistent):

GOC : Khach Nguyen Van A goi 0912345678 hoi doi tra, CCCD 012345678901.
MASK: Khach Nguyen Van A goi <DIEN_THOAI_d79dab78> hoi doi tra, CCCD <CCCD_4aaa4512>. ['CCCD', 'DIEN_THOAI']

GOC : Gui hoa don cho an.tran@example.com, so dien thoai +84987654321.
MASK: Gui hoa don cho <EMAIL_14777fe8>, so dien thoai <DIEN_THOAI_6bad0203>. ['DIEN_THOAI', 'EMAIL']

GOC : Don 123456789012345 tre 3 ngay
MASK: Don 123456789012345 tre 3 ngay []

nhat quan: <DIEN_THOAI_d79dab78> | lien he <DIEN_THOAI_d79dab78> nhe

The same phone number always maps to the same code, so analytics can still count "how many times this customer got in touch" without exposing the real number.

A note on code length: the pseudonym function keeps only 8 hex characters (32 bits) to stay compact; with a few tens of thousands of distinct values, collisions can already happen, meaning two different numbers get the same code and the counts go wrong. The demo accepts that, but in production keep at least 16 hex characters. On the limits of this approach: personal names (Nguyen Van A is still intact), addresses and indirect descriptions are not caught by regular expressions; you need a named-entity recognition (NER) model and you still have to accept misses. Pseudonymization is not anonymization: as long as the key exists, records can be linked back to a person, so treat the output as personal data and protect the key like a secret.

Sending masked data to an LLM in responsible AI

When you call an external LLM, every piece of text that leaves your system is a transfer of data to a third party. Masking before sending is the cheapest safeguard. The script demo_llm_mask.py masks a ticket and then calls the qwen2.5:1.5b model running on Ollama through the OpenAI-compatible endpoint http://localhost:11434/v1, so no real API key is needed; with a commercial service you only change base_url and read the key from an environment variable. The ticket and the model's reply are in Vietnamese, shown here without diacritics (the sent text means: customer called, ID, email, order DH-1042 delivered 5 days late, demands a refund):

Gui vao LLM: Khach goi <DIEN_THOAI_d79dab78>, CCCD <CCCD_4aaa4512>, email <EMAIL_14777fe8>, bao don DH-1042 giao cham 5 ngay, doi hoan tien.
Loai du lieu da che: ['CCCD', 'DIEN_THOAI', 'EMAIL']
LLM tra ve: Ma khach hang: <DIEN_THOAI_d79dab78>  
Ma CCCD: <CCCD_4aaa4512>  
Email: <EMAIL_14777fe8>  
Bao don: DH-1042  
Gia tri: Giao cham 5 ngay  
Quyet dinh: Da

This output shows two honest things about responsible AI with a small model. A 1.5-billion-parameter model summarizes poorly: it labels the phone number "customer code" and adds a "Decision" line on its own, which is cut off midway by the 80-token limit. That is the quality of a tiny model, not of the masking technique; what needs verifying is that the pseudonyms pass through the LLM intact and that no real number leaks. On the first run, when the prompt had no instructions, the model even refused because it saw the <...> strings; adding one system sentence explaining that these are anonymous codes fixed it.

Bias in responsible AI: deliberately simulated data

To audit bias in the spirit of responsible AI, we need to know the "ground truth". With real data you never know it fully, so the article builds simulated data in data.py with three deliberate mechanisms, all of which exist in real life. A shop approves installment orders, and the sensitive group is the region (urban 65%, rural 35%):

  • True repayment ability does not depend on region: it depends on income, the number of late payments and the order value.
  • Historical labels are skewed (selection bias): in the past, rural orders were rejected more often, so "paid in full" labels are missing. The simulation randomly removes 25% of the good labels in this group.
  • Proxy variable: the distance to the warehouse correlates very strongly with region, even though region is never given to the model.

In a responsible AI audit, skewed labels are the biggest risk; the output of demo_bias.py shows how skewed they became:

Ty le nhan 'tra du' theo khu vuc (nhan quan sat) / (nhan that, chi co trong mo phong):
           paid_ok_observed  paid_ok_true
region                                   
nong_thon        0.396         0.538
thanh_thi        0.593         0.593
Khoang cach trung binh theo khu vuc: {'nong_thon': 37.4, 'thanh_thi': 9.2}

To measure bias in responsible AI, you must compare against the truth: rural customers actually repay in full 53.8% of the time (lower than urban because their income is lower, exactly as the formula says), but only 39.6% remains in the observed data. The model learns from the observed labels, so it learns this skew as well. This is the most common kind of bias in a company's historical data: old human decisions become the "truth" for the new model.

Measuring bias with Fairlearn for responsible AI

Fairlearn provides MetricFrame to compute a metric per group, plus aggregate functions such as demographic_parity_difference (the difference in approval rates) and equalized_odds_difference (the largest difference in true positive rate and false positive rate between groups). We train a logistic regression on the observed labels and evaluate it on the true labels, twice: with and without the distance column.

mf = MetricFrame(
    metrics={"ty_le_duyet": selection_rate, "TPR": true_positive_rate, "FPR": false_positive_rate},
    y_true=y_true_test, y_pred=pred, sensitive_features=region_test)
print(mf.by_group.round(3))
print(demographic_parity_difference(y_true_test, pred, sensitive_features=region_test))
== A. day du dac trung (ke ca khoang cach) | do chinh xac 0.661
           ty_le_duyet    TPR    FPR
region                              
nong_thon        0.279  0.390  0.141
thanh_thi        0.678  0.808  0.479
chenh lech ty le duyet (demographic parity): 0.399
chenh lech equalized odds: 0.418

== B. bo khoang cach (proxy) | do chinh xac 0.676
           ty_le_duyet    TPR    FPR
region                              
nong_thon        0.425  0.573  0.242
thanh_thi        0.605  0.742  0.396
chenh lech ty le duyet (demographic parity): 0.18
chenh lech equalized odds: 0.169

Reading the results of this responsible AI audit. In configuration A, the model never receives the region column, yet it approves 67.8% of urban orders against 27.9% of rural orders. The distance column did the job of the region column, and the model also learned the skew in the labels. Note the TPR in particular: among rural customers who really do repay in full, the model recognizes only 39.0%, against 80.8% for urban. That is an "allocation harm" in Fairlearn's terms: the system withholds opportunity from one group.

Dropping the distance column in configuration B reduces the difference from 0.399 to 0.180, and accuracy even rises slightly (0.661 to 0.676, measured on the true labels). This is one simulated dataset and one split; what to take away is the way of measuring, not the numbers.

A gap of 0.18 remains even after removing the most obvious proxy, because income (a legitimate variable) is also lower in rural areas and because the labels are skewed. Removing the sensitive column does not guarantee fairness, and the difference between groups may partly come from real differences in circumstances. Which part is acceptable is a human decision, not a library's.

Mitigating bias with ThresholdOptimizer: the price of responsible AI

Fairlearn has a post-processing technique, ThresholdOptimizer: it keeps the model unchanged and picks a separate decision threshold per group to satisfy a constraint such as equalized_odds. For responsible AI this is a mitigation step; run demo_mitigate.py on configuration B:

== truoc giam thieu | do chinh xac (nhan that) 0.676
           ty_le_duyet    TPR    FPR
region                              
nong_thon        0.425  0.573  0.242
thanh_thi        0.605  0.742  0.396
DP diff: 0.18 | EO diff: 0.169

== ThresholdOptimizer equalized_odds | do chinh xac (nhan that) 0.675
           ty_le_duyet    TPR    FPR
region                              
nong_thon        0.590  0.784  0.351
thanh_thi        0.637  0.737  0.485
DP diff: 0.047 | EO diff: 0.134

The difference in approval rate drops from 0.180 to 0.047, and accuracy barely changes (0.676 versus 0.675). But four things need a careful read:

  • Equalized odds only drops from 0.169 to 0.134, not close to 0 as you might expect. The FPR column shows why: the TPRs of the two groups are already close (0.784 versus 0.737) but the FPR still differs by 0.134 (0.351 versus 0.485), and equalized_odds_difference takes the larger of the TPR and FPR differences. Part of the reason is that the constraint is solved on the observed labels (already skewed), and the randomization described in the next point also keeps the measured values on the test set from matching exactly.
  • Decisions become randomized. To hit the constraint exactly, ThresholdOptimizer may choose between two thresholds with some probability, so the code must pass random_state=0 to be reproducible. The consequence: two identical applicants in the same group can get different outcomes. For a credit decision, that is a real problem for explaining reasons and handling appeals, and it needs careful thought before use.
  • Using different thresholds per group means the sensitive column must be present at inference time. Treating groups differently may be something you are not allowed to do even when the goal is fairness; this is a question for your legal team, not for engineers.
  • Fairness metrics can conflict with each other. The Fairlearn documentation says plainly that approval-rate parity and equalized odds can conflict and that there is "no mathematical solution" to that conflict; fairness is a sociotechnical problem, and metrics capture only part of it.

ThresholdOptimizer is a tool for choosing a trade-off with a visible cost, not a "make it fair" button. The root-cause fix lies in the data: collect correct labels for the group that was excluded, then measure again, and track metrics per group in monitoring, the way the article on data drift did for the overall metric.

Documenting responsible AI: the model card

Documentation for responsible AI is only valuable when the audit results are reproducible and easy to look up. Every model version pushed to the registry (see MLOps with MLflow) should come with a short "model card" file: purpose and scope of use, data sources with their legal basis, the groups audited and the metrics, known limitations (skewed labels, remaining proxies), the person responsible and the review period. The file lives in the same repo and is updated on every retraining. For a system that may fall into the high-risk group under the Law on Artificial Intelligence, this file is most of the "evidence" you need when asked.

When this approach to responsible AI is not enough

  • High-stakes decisions: responsible AI needs more than a metrics table; loans, insurance, hiring and healthcare need human review and an appeal channel, and a group audit cannot replace case-by-case review.
  • Unknown sensitive groups: in responsible AI work, many companies do not collect (and are not allowed to collect) region, gender or ethnicity; then you can only audit through proxies or a separate survey, with the legal risk of inferring sensitive attributes.
  • Small groups: when auditing responsible AI with only a few dozen samples, a difference of 0.1 may just be noise; use bootstrap confidence intervals before drawing a conclusion.

Common mistakes in responsible AI

  • Believing that dropping the sensitive column is enough: proxies such as distance and postal code still carry information; measure outcomes per group.
  • Using a plain hash as anonymization: an unkeyed SHA-256 of a phone number can be reversed; use keyed HMAC.
  • Reporting only the aggregate metric: a report cannot stop there; an accuracy of 0.676 hides a TPR of 0.573 versus 0.742 between the two groups.

Responsible AI checklist before putting a model into production

  • List the personal data fields, the legal basis, the purpose and the retention period; ask your legal team about the impact assessment dossier and the AI risk level.
  • Put a masking layer at every exit point (logs, data exports, LLM calls); keep the pseudonymization key in a secrets store.
  • Audit per group: approval rate, TPR and FPR for each important group; measure even after the sensitive column has been removed.
  • Responsible AI records: store a model card with every version; track per-group metrics after deployment.
  • Have human review and an appeal channel for decisions with a large impact; that is the minimum condition for a system to call itself responsible AI, and it is what customers usually ask about first when they evaluate a vendor's responsible AI.

Source code for the responsible AI article. Install with python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt, then run demo_pii.py, demo_bias.py, demo_mitigate.py and demo_llm_mask.py in code/s7-52; the *_output.txt files are the pasted outputs. Each script finishes in a few seconds and uses under 160 MB of RAM; demo_llm_mask.py needs Ollama with qwen2.5:1.5b. LLM output can differ between runs, even with seed=0 (when this version was rerun, the model returned a different summary sentence and lost the pseudonym code); the other three scripts produce character-for-character identical output.

References: the Fairlearn guide to fairness in machine learning and the summary of the 2025 Personal Data Protection Law.

Next article

Polars for large datasets: the lazy API compared with pandas. After two articles on operations and responsible AI, the next one returns to data-processing tools: what Polars does when a table outgrows pandas.