Skip to content

daggity_to_dot corrupts node names that contain "exposure", "outcome", "adjusted" or "latent" #1835

Description

@JoeyTan21

Describe the bug

dowhy.utils.graph_operations.daggity_to_dot() (used by build_graph_from_str() and therefore by CausalModel(graph=...) for Dagitty input) removes the Dagitty node flags exposure, outcome, adjusted and latent with plain substring substitutions over the whole graph string:

graph = re.sub("outcome,*,", "", graph)
graph = re.sub("adjusted,*", "", graph)
graph = re.sub("exposure,*", "", graph)
graph = re.sub("latent,*", 'observed="no",', graph)

Because the patterns are not anchored to the [...] attribute list, any node name that contains one of these words is rewritten too:

  • sun_exposure becomes sun_, unadjusted_x becomes un_x (silently renamed, so the node no longer matches the DataFrame column);
  • latent_u becomes observed="no",_u, which is not valid DOT, so parsing fails;
  • a node literally named exposure (or outcome when it carries other attributes) is deleted entirely, so parsing fails.

Naming the treatment exposure and the outcome outcome is the standard convention in epidemiology, which is Dagitty's main audience, so this is easy to hit with a graph exported straight from dagitty.net.

Steps to reproduce the behavior

from dowhy.graph import build_graph_from_str
from dowhy.utils.graph_operations import daggity_to_dot

dag = 'dag {\nexposure [exposure,pos="0,0"]\noutcome [outcome,pos="1,1"]\nexposure -> outcome\n}'
print(daggity_to_dot(dag))
# digraph {  [pos="0,0"]; outcome [pos="1,1"];  -> outcome; }
build_graph_from_str(dag)
# pygraphviz: "Expected rbrace, found '['" -> falls back to pydot -> TypeError: 'NoneType' object is not subscriptable

dag = 'dag {\nsun_exposure [exposure,pos="0.3,0.5"]\nunadjusted_x [adjusted,pos="0.1,0.1"]\nlatent_u [latent,pos="0.5,0.8"]\nsun_exposure -> unadjusted_x\nlatent_u -> sun_exposure\n}'
print(daggity_to_dot(dag))
# digraph { sun_ [pos="0.3,0.5"]; un_x [pos="0.1,0.1"]; observed="no",_u [observed="no",pos="0.5,0.8"]; sun_ -> un_x; observed="no",_u -> sun_; }

Output on current main (b06369e):

case 1 DOT: 'digraph {  [pos="0,0"]; outcome [pos="1,1"];  -> outcome; }'
case 1 FAILED: TypeError 'NoneType' object is not subscriptable
case 2 DOT: 'digraph { sun_ [pos="0.3,0.5"]; un_x [pos="0.1,0.1"]; observed="no",_u [observed="no",pos="0.5,0.8"]; sun_ -> un_x; observed="no",_u -> sun_; }'
case 2 FAILED: TypeError 'NoneType' object is not subscriptable

A side effect of the same regexes: Y [outcome] with no other attribute is not stripped at all (the pattern requires a trailing comma), so outcome is passed through to DOT as a bare attribute.

Expected behavior

Only the Dagitty flags inside a node's [...] attribute list should be removed/translated; node names and edge statements should be left untouched:

digraph { exposure [pos="0,0"]; outcome [pos="1,1"]; exposure -> outcome; }
digraph { sun_exposure [pos="0.3,0.5"]; unadjusted_x [pos="0.1,0.1"]; latent_u [observed="no",pos="0.5,0.8"]; sun_exposure -> unadjusted_x; latent_u -> sun_exposure; }

Version information:

Additional context

The fix is small: parse the content of each [...] block, split it on commas that are outside double quotes (so pos="0.1,0.2" survives), drop exposure/outcome/adjusted and map latent to observed="no" only when they are whole attributes. I have a patch with regression tests ready and can open a PR.

Disclosure: found, reproduced and the draft fix tested locally with the help of an AI coding agent (Claude Code); reviewed before filing.

Activity

  1. github-actions commented on Oct 7, 2026

    @github-actions
  2. ganapriyahs commented on Oct 7, 2026

    @ganapriyahs

    Hi @JoeyTan21 , I’m interested in working on this issue. Are you planning to open a PR, or can I take it?

  3. github-actions commented on Oct 10, 2026

    @github-actions
    Contributor

    🤖 This is an automated response from Repo Assist.

    Thank you for stepping up! I can see your interest in fixing this, which is excellent.

    Here's what would help make your PR smooth:

    Implementation approach (from my earlier analysis):

    1. Parse only the content inside [...] attribute blocks
    2. Split on commas that are outside double quotes (so pos="0.1,0.2" survives)
    3. Remove/replace only complete attribute tokens: exposure, outcome, adjusted, latent
    4. Reassemble the block

    Test coverage (from the issue):

    • Standard epidemiology names: nodes named exposure, outcome
    • Names containing keywords: sun_exposure, unadjusted_x, latent_u
    • Mixed attributes: X [exposure, adjusted, pos="0.1,0.2"]
    • Edge case: Y [outcome] with no trailing comma (currently not stripped)

    Quick checklist before opening your PR:

    • All test cases from the issue reproduction pass
    • Existing tests still pass: poetry run pytest tests/utils/test_graph_operations.py
    • Format passes: poetry run poe format_check && poetry run poe lint
    • Commit includes DCO sign-off (git commit -s)

    Feel free to open a draft PR to get feedback early. The DoWhy team is usually responsive, and I can help review the logic if needed.

    Good luck! 🚀

    Generated by 🌈 Repo Assist, see workflow run. Learn more.

    To install this agentic workflow, run

    gh aw add githubnext/agentics/workflows/repo-assist.md@11c9a2c442e519ff2b427bf58679f5a525353f76
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions