Skip to main content

Python bindings for the odis Formal Concept Analysis library

Project description

odis-python

PyPI

Python bindings for the odis Formal Concept Analysis library, powered by Rust and PyO3.

Background

Formal Concept Analysis (FCA) works on formal contexts — cross-tables pairing objects with attributes via a binary incidence relation — and derives the complete lattice of formal concepts from them. odis implements the core FCA algorithms in Rust and exposes them through this Python interface. For an introduction to FCA see Uta Priss's FCA page.

Installation

Released package (PyPI)

pip install odis-python

Development build (from source)

Requires a Rust toolchain and maturin.

git clone https://github.com/odis-rs/odis
cd odis/odis-python
pip install maturin
maturin develop --release

Quick Start

from odis import FormalContext

ctx = FormalContext.from_file("odis/test_data/living_beings_and_water.cxt")
print(f"Objects: {ctx.objects}")
print(f"Attributes: {ctx.attributes}")
concepts = list(ctx.concepts())
print(f"Number of concepts: {len(concepts)}")

FormalContext

FormalContext stores a set of objects, a set of attributes, and a binary incidence relation mapping object–attribute pairs.

Construction

from odis import FormalContext

# Empty context
ctx = FormalContext()

# From a .cxt (Burmeister) file
ctx = FormalContext.from_file("odis/test_data/living_beings_and_water.cxt")

# From a dict mapping each object to its set of attributes
ctx = FormalContext.from_dict({
    "cat":  {"has_legs", "has_fur", "can_move"},
    "fish": {"lives_in_water", "can_move"},
    "fern": {"needs_chlorophyll"},
})

Introspection

n_objects, n_attributes = ctx.shape   # e.g. (8, 9)
n = len(ctx)                          # same as ctx.shape[0] — number of objects
print(ctx.objects)                    # ['fish', 'leech', 'bream', ...]
print(ctx.attributes)                 # ['needs water to live', ...]
print("frog" in ctx)                  # True — tests object membership
print(repr(ctx))                      # human-readable summary

Incidence Access

# Read: does object have attribute?
val = ctx["frog", "lives in water"]   # True
val = ctx["frog", "breast feeds"]     # False

# Write
ctx["frog", "lives in water"] = False
ctx["frog", "lives in water"] = True

Mutation

# Add an object with no attributes
ctx.add_object("whale")

# Add an object with some pre-set attributes
ctx.add_object("whale", {"needs water to live", "can move", "breast feeds"})

# Add a new attribute column
ctx.add_attribute("is_endangered")

# Remove
ctx.remove_object("whale")
ctx.remove_attribute("is_endangered")

# Rename
ctx.rename_object("frog", "toad")
ctx.rename_attribute("needs water to live", "aquatic")

Serialisation

# Save to .cxt file
ctx.to_file("/tmp/my_context.cxt")

# Deep copy — mutations to the copy do not affect the original
copy = ctx.copy()
copy.add_object("clone_only")
assert "clone_only" not in ctx.objects

Derivation Operators

# Extent: the set of all objects sharing every given attribute
extent = ctx.extent(["needs water to live", "can move"])

# Intent: the set of all attributes shared by every given object
intent = ctx.intent(["fish", "leech", "bream"])

# Attribute hull (closure of an attribute set under the Galois connection)
hull = ctx.attribute_hull(["needs water to live"])

# Object hull (closure of an object set)
ohull = ctx.object_hull(["frog"])

# Upper neighbor: the extent of the concept directly above the given concept
# in the lattice (the least concept with a strictly larger extent)
neighbor = ctx.upper_neighbor(["frog"])

# All results are LabelSets — iterate or convert freely
print(list(extent))        # ['fish', 'leech', 'bream', ...]
print("frog" in extent)    # True or False

Drawing Shortcut

FormalContext provides convenience methods to draw the concept lattice without instantiating a Drawing object; see Drawing for the full API.

svg_str = ctx.draw_svg("dimdraw", width=800, height=600)
drawing  = ctx.draw("dimdraw")

Concepts

FormalContext.concepts() returns a ConceptCollection (eager, indexable) or a ConceptGenerator (lazy, forward-only). Each element is a Concept with .extent and .intent properties.

# Eager (default) — all concepts materialised at once
concepts = ctx.concepts()
print(f"Found {len(concepts)} concepts")

# Access by index
first = concepts[0]
print(list(first.extent))   # objects in this concept
print(list(first.intent))   # attributes in this concept

# Iteration with unpacking
for extent, intent in concepts:
    print(list(extent), "→", list(intent))

Lazy concepts are covered under Lazy Generators & Mutation Guard.


Implications

The canonical implication basis (Duquenne–Guigues basis) is the smallest set of implications that logically entails all implications valid in the context.

basis = ctx.canonical_basis()
print(f"Basis size: {len(basis)}")

for impl in basis:
    print(list(impl.premise), "→", list(impl.conclusion))

# Access by index
imp = basis[0]
print(list(imp.premise))     # antecedent attributes
print(list(imp.conclusion))  # consequent attributes

# Optimised variant (same result, faster in practice)
basis_opt = ctx.canonical_basis_optimised()

Iterating pseudo-intents one at a time with next_preclosure:

# next_preclosure(basis, current) returns the next closed attribute set in
# lectic order. Terminates naturally when len(result) == number of attributes.
n_attrs = len(ctx.attributes)
current = frozenset()
while len(current) < n_attrs:
    nxt = ctx.next_preclosure(basis, current)
    if len(nxt) == n_attrs:
        break
    print(list(nxt))
    current = nxt

Attribute Exploration

Attribute exploration is an interactive algorithm that discovers the canonical basis by consulting an oracle (a Python callback) about whether proposed implications hold. The oracle may reject an implication by supplying a counterexample.

def my_oracle(premise, conclusion):
    """Called for each proposed implication.

    premise and conclusion are LabelSets (iterable over strings).
    Return True to accept; return (name, attrs) to reject with a counterexample.
    """
    print(f"Does: {list(premise)}{list(conclusion)}?")

    if list(premise) == []:
        return True  # accept empty-premise implications unconditionally

    # Reject: supply a counterexample object that has the premise but not the conclusion
    return ("robin", {"can move", "needs water to live"})

basis = ctx.attribute_exploration(my_oracle)
print(f"Discovered {len(basis)} implications")

The callback receives two LabelSet arguments — premise and conclusion:

  • Return any truthy non-tuple value (e.g. True) to accept the implication.
  • Return (name: str, attributes: Iterable[str]) to reject it with a counterexample.

When a counterexample is provided, attribute_exploration adds that object (with the given attributes) to the context and continues.


Drawing

odis can draw the concept lattice as a directed graph. Two layout algorithms are available: "dimdraw" (dimension-based, default) and "sugiyama" (hierarchical).

# Quick SVG string — no intermediate Drawing object required
svg = ctx.draw_svg("dimdraw", width=800, height=600)
with open("lattice.svg", "w") as f:
    f.write(svg)
# Full Drawing object for programmatic access
drawing = ctx.draw("dimdraw")
if drawing is not None:
    print(f"Nodes: {len(drawing.nodes)}")
    print(f"Edges: {drawing.edges}")              # list of (from_idx, to_idx) tuples
    print(f"Coordinates: {drawing.coordinates}")  # raw layout (x, y) per node

    for node in drawing.nodes:
        print(f"  node {node.index}: ({node.x:.1f}, {node.y:.1f})")
        print(f"    reduced objects:    {node.object_labels}")
        print(f"    reduced attributes: {node.attribute_labels}")

    # Convert to SVG from Drawing object (useful for custom sizes)
    svg2 = drawing.to_svg(ctx, width=1200, height=800)
    with open("large_lattice.svg", "w") as f:
        f.write(svg2)

# Jupyter notebook: display inline (requires IPython)
try:
    from IPython.display import SVG, display
    display(SVG(data=svg))
except ImportError:
    pass  # not running in a notebook

draw() returns None for the empty context (no concepts, no lattice to draw).

Poset (Partial Order Drawing)

Poset lets you directly define apartial order. Edges describe the covering relation: (u, v) means node u is directly below node v (u ≺ v), given as 0-based indices into the node list. Cycles are rejected with ValueError.

from odis import Poset

# Diamond lattice
p = Poset(
    ["bottom", "left", "right", "top"],
    [(0, 1), (0, 2), (1, 3), (2, 3)],
)

# Quick SVG
svg = p.draw_svg("dimdraw", width=800, height=600)
with open("order.svg", "w") as f:
    f.write(svg)

# Drawing object for programmatic access
drawing = p.draw("dimdraw")
if drawing is not None:
    for node in drawing.nodes:
        print(f"{node.object_labels[0]}: ({node.x:.1f}, {node.y:.1f})")
    print(drawing.edges)   # list of (u, v) covering-relation pairs

Both "dimdraw" and "sugiyama" are supported. draw() returns None only for an empty poset.

Titanic

The Titanic algorithm enumerates iceberg concepts — concepts whose extent meets a minimum support threshold. Useful for large or sparse contexts where only frequent concepts are of interest.

from odis import FormalContext, Titanic

ctx = FormalContext.from_dict({
    "a": {"x", "y", "z"},
    "b": {"x", "y"},
    "c": {"x", "z"},
    "d": {"y", "z"},
    "e": {"x"},
})

iceberg = Titanic()

# Only enumerate concepts with at least 2 objects in their extent
top_concepts = iceberg.enumerate(ctx, min_support=2)
print(f"Iceberg concepts (support ≥ 2): {len(top_concepts)}")
for c in top_concepts:
    print(f"  extent={list(c.extent)}, intent={list(c.intent)}")

LabelSet

LabelSet is a set-like view of string labels. It is returned by derivation operators (extent, intent, attribute_hull, object_hull, upper_neighbor), implication properties (premise, conclusion), and concept properties (.extent, .intent).

intent = ctx.intent(["fish", "leech"])

# Membership test
print("can move" in intent)   # True

# Iteration — yields strings directly, no index translation needed
for attr in intent:
    print(attr)

# Convert to standard Python containers
as_list = list(intent)
as_set  = set(intent)

Lazy Generators & Mutation Guard

Passing lazy=True to concepts() or canonical_basis() returns a generator that produces one concept/implication at a time without materialising the full collection. Lazy generators hold a shared reference to the context's internal state, so any mutation while a lazy generator is alive raises RuntimeError.

ctx = FormalContext.from_file("odis/test_data/living_beings_and_water.cxt")

# Create a lazy generator
gen = ctx.concepts(lazy=True)

# Iterating is safe
first = next(gen)
print(list(first.extent))

# Mutating while the generator is alive raises RuntimeError
try:
    ctx.add_attribute("new_attr")       # raises RuntimeError
except RuntimeError as e:
    print(f"Caught: {e}")

# Release the generator first, then mutate freely
del gen
ctx.add_attribute("new_attr")          # OK

The same guard applies to canonical_basis(lazy=True) and Titanic().enumerate(ctx, ..., lazy=True).


Error Reference

Exception When raised Example trigger
FileNotFoundError .cxt file path does not exist FormalContext.from_file("missing.cxt")
OSError Other I/O error reading a file Unreadable file permissions
ValueError Malformed .cxt file Invalid Burmeister format
KeyError Unknown object or attribute name ctx["ghost", "flies"]
ValueError Duplicate object or attribute name ctx.add_object("frog") when already present
RuntimeError Mutation while a lazy generator is alive ctx.add_attribute("x") during active generator
ValueError Unknown drawing algorithm ctx.draw("unknown_algo")
ValueError Non-positive SVG dimensions ctx.draw_svg("dimdraw", -1, 600)

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

odis_python-2026.4.0.tar.gz (67.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

odis_python-2026.4.0-cp313-cp313-manylinux_2_34_x86_64.whl (552.8 kB view details)

Uploaded CPython 3.13manylinux: glibc 2.34+ x86-64

File details

Details for the file odis_python-2026.4.0.tar.gz.

File metadata

  • Download URL: odis_python-2026.4.0.tar.gz
  • Upload date:
  • Size: 67.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: maturin/1.12.6

File hashes

Hashes for odis_python-2026.4.0.tar.gz
Algorithm Hash digest
SHA256 043b4f19c1d1cefb058b3bbde316db9cb419331bebc3153cf2883a794ab51ee1
MD5 e2ed6e2c778ca7e8c712dd02cf36b610
BLAKE2b-256 1bcd53207316fa60ec484bab4f20728fa2f10b5e2151f31b95ddfd78336a3852

See more details on using hashes here.

File details

Details for the file odis_python-2026.4.0-cp313-cp313-manylinux_2_34_x86_64.whl.

File metadata

File hashes

Hashes for odis_python-2026.4.0-cp313-cp313-manylinux_2_34_x86_64.whl
Algorithm Hash digest
SHA256 25c2b21ce556f502ec893e4357d6a871163bf2a154802005ffc7292de368750d
MD5 279bff8e562e77d976b09a0549ea543b
BLAKE2b-256 b18772a03e7a214acf66701631c19271c68182c37fa7c2840abb10b365a055ee

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page