Skip to content

Automatic thresholds for projectability disentanglement (dis_proj_auto) - #650

Merged
qiaojunfeng merged 17 commits into
wannier-developers:developfrom
elinscott:feature/dis-proj-auto
Jul 31, 2026
Merged

Automatic thresholds for projectability disentanglement (dis_proj_auto)#650
qiaojunfeng merged 17 commits into
wannier-developers:developfrom
elinscott:feature/dis-proj-auto

Conversation

@elinscott

Copy link
Copy Markdown
Contributor

Motivation

Projectability disentanglement (Qiao et al. 2023) selects and freezes states based on the per-band projectabilities $p_{n\mathbf{k}} = \sum_j |A_{nj}(\mathbf{k})|^2$, controlled by dis_proj_min and dis_proj_max. Good values for these thresholds are far less material-dependent than those for energy-based disentanglement, permitting high-throughput Wannierisation, but there is still some dependence that can't be ignored, especially when the PAO basis is not used and consequently projectabilities are lower than they would be otherwise (see pw2wannier90's atom_proj_ext). Default thresholds can give rise to concrete failures (see e.g. Supplementary Figure 20 of the original paper; replicated below).

This PR adds an option to determine both thresholds automatically at runtime from the projectability distribution itself.

What this PR does

Setting

dis_proj_auto = .true.

pools the projectabilities of all bands and k-points, histograms them into 64 equal-width bins, and splits the distribution into dis_proj_auto_classes classes (default 5, minimum 3) by multi-level Otsu thresholding, i.e. the class boundaries that maximise the between-class variance. dis_proj_min is set to the lowest threshold and dis_proj_max to the highest. More classes widen the disentanglement window between the two thresholds, so dis_proj_auto_classes becomes the one remaining tunable parameter (larger = fewer bands frozen and discarded; smaller = more).

Design decisions

  • If the requested class count exceeds the number of populated bins, the count is reduced to what the data supports and a note is printed. Physically, each class needs its own cluster of values, and thresholds can only fall in the gaps between clusters; small systems can have few clusters (the graphene test populates only 12 of 64 bins). If fewer than three clusters exist - for example a manifold with no entanglement, where all projectabilities are nearly equal - the thresholds are genuinely undefined and the run stops with an error telling the user to set the thresholds manually.
  • dis_proj_auto requires dis_froz_proj = .true. and is an input error in combination with explicit dis_proj_min/dis_proj_max.
  • thresholding is performed over $[0, 1]$ rather than $[p_{min}, p_{max}]$ so that we loudly fail upon edge cases where the range of projectabilities is very narrow

Examples

Graphene

OpenMX s+p+s atomic projectors on C, comparing....

  • the default dis_proj_min and _max ("default")
  • values I found by trial-and-error ("manual")
  • values generated by the new algorithm with dis_proj_auto = True ("auto")
comparison
p_min p_max η(E_F+2) (meV) disentangle steps minimisation steps t_total (min)
default 0.01 0.95 23.8 1186 3253 12.2
manual 0.05 0.70 3.9 61 1042 2.7
auto 0.039 0.742 4.0 152 1163 3.2

Bulk Si

OpenMX s+p+d atomic projectors on Si

With dis_froz_max = CBM + 2 eV

comparison
p_min p_max η(E_F+2) (meV) disentangle steps minimisation steps t_total (min)
default 0.01 0.95 13.9 398 1052 40.9
auto 0.086 0.742 6.5 415 771 17.5
auto with dis_proj_auto_classes=7 0.055 0.805 5.1 751 824 26.7

Without energy disentanglement

N.B. the left-hand-panel is the fail-case given in the Supp. Mat. of Qiao et al. 2023. In that case the authors painfully created a new pseudopotential in order to get a reliable Wannierization. Here instead we get a reliable Wannierization automatically.

comparison_nofroz
p_min p_max η(E_F+2) (meV) disentangle steps minimisation steps t_total (min)
default 0.01 0.95 3754.8 1165 807 78.2
auto 0.086 0.742 6.6 415 1155 25.8
auto with dis_proj_auto_classes=7 0.055 0.805 160.2 785 693 25.0

Takeaways

  • the thresholds generated by Otsu thresholding match hand-tuned quality on graphene and beat the defaults everywhere
  • Without an energy-window safety net the defaults can fail outright with even the valence manifold wrong — while dis_proj_auto is robust
  • Poorly chosen thresholds waste compute: the default runs consistently need the most disentanglement/minimisation steps and the longest wall time

Validation

  • The Fortran Otsu implementation was compared against and matched an independent reference implementation of the same specification on synthetic data (clustered, uniform, degenerate, etc) across class counts 3-6 and 32/64/128 bins
  • For the existing testw90_graphene_pdwf, the automatic thresholds come out as [0.039, 0.942] (the hand-tuned values in the test are [0.01, 0.85]), and the total spread improves from 15.80 to 14.00 Ų.
  • The full serial test suite passes (95/95).
  • All error paths (missing dis_froz_proj, invalid class count, conflicting explicit thresholds, degenerate distribution) exit with clear messages.

Tests and documentation

  • New test testw90_graphene_dis_proj_auto (inputs symlinked from testw90_graphene_pdwf)
  • dis_proj_auto and dis_proj_auto_classes documented in the user guide, parameter tables and parameters.xml.
  • CHANGELOG entry added

Decision for the maintainers

Do we set dis_proj_auto to True by default? As we have seen, the algorithm outperforms the default thresholds.

AI Declaration

I used Claude to help me write and test this code. I have reviewed the code myself.

elinscott and others added 9 commits July 13, 2026 13:08
New keywords dis_proj_auto (logical, default .false.) and
dis_proj_auto_classes (integer, default 5, >= 3) determine
dis_proj_min/dis_proj_max automatically for projectability
disentanglement: the per-band projectabilities sum_j |A_nj(k)|^2 are
pooled over all bands and k-points, histogrammed into 64 equal-width
bins, and split into dis_proj_auto_classes classes by maximising the
between-class variance (multi-Otsu); dis_proj_min/max are set to the
first/last thresholds. The algorithm follows
skimage.filters.threshold_multiotsu (bin-centre thresholds,
index-weighted moments, exhaustive ascending search with first-found
tie-break), verified term-by-term against the scikit-image source.

The pooling is rank-uniform (a_matrix is replicated), so thresholds are
identical on all ranks with no communication. dis_proj_auto requires
dis_froz_proj = .true. and is incompatible with explicit
dis_proj_min/dis_proj_max (input error); a distribution that cannot
support the requested number of classes is a fatal error, not a silent
fallback.

Validation: Fortran thresholds match a pure-python reference of the
same specification exactly on synthetic fixtures (classes 3-6, bins
32/64/128) including degenerate and exactly-N-populated-bins cases;
graphene PDWF control run is byte-identical to the committed benchmark;
auto mode on graphene selects [0.03895, 0.94248] (hand-tuned reference
[0.01, 0.85]) with total spread improving 15.80 -> 14.00 A^2; all four
input-error paths and the end-to-end degenerate path exit cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The number of resolvable classes is bounded by the number of populated
histogram bins: each class needs its own cluster of projectability
values, and cuts can only fall in the gaps between clusters. Requesting
more classes than clusters (easy to do on small systems - the graphene
test populates only 12 of 64 bins) previously died with a fatal error.

dis_otsu_thresholds now returns nclasses_eff = min(requested, populated
bins) and computes that many classes; dis_windows_proj prints a
root-only note whenever the count is reduced, so the adjustment is
never silent. The fatal error remains only where thresholds are
genuinely undefined: fewer than three populated bins, or all
projectabilities equal.

Verified: graphene classes=13 reduces to 12 with the printed note and
thresholds byte-identical to an explicit classes=12 run; the default
classes=5 case is unchanged; synthetic fixtures (including an
exactly-3-populated-bins case reducing from 5) match the python
reference of the same specification exactly; all-equal and
two-cluster distributions still fail loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New test testw90_graphene_dis_proj_auto exercises the default
dis_proj_auto path on the graphene PDWF system (inputs symlinked from
testw90_graphene_pdwf); the benchmark was negative-tested by perturbing
a spread and confirming the comparison fails.

Document dis_proj_auto and dis_proj_auto_classes in the user guide,
parameter tables and parameters.xml, including the adaptive
class-count reduction and the orthonormal-projector assumption of
projectability disentanglement (previously undocumented; guaranteed by
pw2wannier90's default atom_proj_ortho = .true.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Independent verification against scikit-image 0.25.2 showed two things.
First, our moment weighting does not reproduce threshold_multiotsu: the
scikit-image lookup table initialises first_moment[0] = prob[0], giving
effective bin weights (1,1,2,3,...) rather than the textbook 0-based
indices, and the two conventions select different thresholds on about
11% of clustered test distributions. We keep the textbook weights
deliberately. Second, an adaptive [min,max] histogram range makes
near-degenerate distributions (e.g. a manifold with no entanglement,
projectabilities 0.99 +/- 1e-6) stretch over several bins and yield
meaningless thresholds that slice inside a single physical cluster.

Since projectability has an absolute scale, histogram over a fixed
[0,1] range instead: bins acquire a physical resolution (1/64 ~ 0.016),
values closer than one bin are deliberately indistinguishable, and the
near-degenerate case now collapses into one populated bin and stops
with the existing fewer-than-three-clusters error. Both deviations from
scikit-image are documented in the code and the user guide, which no
longer claims equivalence with threshold_multiotsu.

The graphene thresholds move to the absolute bin grid ([0.03906,
0.94531], previously [0.03895, 0.94248]); the test benchmark is
regenerated accordingly and re-verified, including the negative test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
otsu_lower_bound, otsu_upper_bound and otsu_nbins replace the hardwired
0/1/64 and are threaded through dis_otsu_thresholds as arguments, so
exposing them as input keywords later only requires parser wiring. Not
user-visible; graphene benchmarks unchanged (1/64 is a power of two, so
the arithmetic is bit-identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elinscott
elinscott marked this pull request as ready for review July 14, 2026 16:08
@elinscott

Copy link
Copy Markdown
Contributor Author

@qiaojunfeng

The omegaI and omegaOD of testw90_graphene_dis_proj_auto differ from
the serial benchmark by ~1e-6 in parallel runs, right at the default
tolerance. Use per-test tolerances of 5e-6 for these quantities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elinscott
elinscott force-pushed the feature/dis-proj-auto branch from eacba87 to e4aa872 Compare July 14, 2026 16:58
…proj_max

The disentangle summary now prints the Disentangled window upper bound as
dis_manifold%proj_max (commit b4a6d80) instead of a hardcoded 1.00000. Both
committed benchmarks still showed "Disentangled: ... to 1.00000", internally
inconsistent with their own "Frozen: proj_max to 1.00000" line
(dis_proj_auto: 0.94531; pdwf: 0.85000). Updated the one stale print line in
each. Verified via testcode that all parser-compared physics
(omegaI/omegaD/omegaOD, final centres/spreads) is byte-identical to the old
benchmarks; both tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JeromeCCP9 JeromeCCP9 added this to the v4.1 milestone Jul 17, 2026
The exhaustive multi-Otsu search enumerates C(nbins-1, classes-1) tuples
(nbins = 64): classes = 8 is ~5.5e8 evaluations (seconds-to-minutes),
classes = 9 ~3.9e9, classes = 10 ~2.4e10, and classes ~16 effectively hangs.
readwrite only checked classes >= 3, so a large value silently hung the run.
Reject classes > 8 at parse time with an io_error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qiaojunfeng

Copy link
Copy Markdown
Collaborator

Thanks @elinscott, very nice results!

Do we set dis_proj_auto to True by default? As we have seen, the algorithm outperforms the default thresholds.

Yes I'd like to see it set as default, making things more automated is better.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here it should be 1.0 not 0.85? Because all states with projectability between 0.01 and 1.0 participate into disentanglement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch -- fixed

qiaojunfeng and others added 3 commits July 28, 2026 20:21
The projectability-windows banner again reports the disentangled window
as [dis_proj_min, 1.0], as on develop: frozen high-projectability states
are part of the disentanglement window (PR wannier-developers#650 review). The
testw90_graphene_pdwf benchmark is restored to the develop version and
the testw90_graphene_dis_proj_auto benchmark regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elinscott
elinscott force-pushed the feature/dis-proj-auto branch from b97f74a to 12d4095 Compare July 30, 2026 14:50
- dis_proj_min/dis_proj_max no longer have default values: they are
  either set explicitly or determined automatically
- explicit thresholds disable the (defaulted) automatic thresholding;
  combining them with an explicit dis_proj_auto = .true. is an input
  error
- dis_froz_proj with automatic thresholding disabled requires both
  thresholds; providing only one of them is an input error
- dis_proj_auto without dis_froz_proj is no longer an error (it simply
  has no effect)
- dis_proj_auto_classes is renamed to dis_proj_auto_num_classes, and
  the local flags recording which projectability keywords were provided
  are given descriptive names
- documentation, parameter tables and CHANGELOG updated; the CHANGELOG
  entry is shortened to match the style of existing entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elinscott
elinscott force-pushed the feature/dis-proj-auto branch from 12d4095 to b773377 Compare July 30, 2026 14:52
@elinscott

Copy link
Copy Markdown
Contributor Author

Thanks @elinscott, very nice results!

Do we set dis_proj_auto to True by default? As we have seen, the algorithm outperforms the default thresholds.

Yes I'd like to see it set as default, making things more automated is better.

Thanks @qiaojunfeng !

OK, if we're going to enable this by default then I have modified the keyword precedence so that this change gives minimal surprising behaviour:

  • dis_proj_auto is enabled by default.
  • If dis_proj_min/dis_proj_max are explicitly provided, they take precedence and disable the automatic thresholding. This means that...
    • existing PDWF input files with specified thresholds behave as before
    • existing PDWF input files without thresholds specified now use auto rather than defaults (changed behaviour!)
  • We give errors if...
    • both dis_proj_auto = .true. and the thresholds are set explicitly
    • with dis_froz_proj = .true., the user sets dis_proj_min xor dis_proj_max
    • with dis_froz_proj = .true., the user sets dis_proj_auto = .false. without providing the thresholds
  • dis_proj_auto is silently ignored if dis_froz_proj = .false. (previously a hard error)
  • dis_proj_min/dis_proj_max no longer have default values (previously 0.01/0.95): with dis_froz_proj = .true. the thresholds are either determined automatically or set explicitly.

Other changes:

  • renamed dis_proj_auto_classes to dis_proj_auto_num_classes to be more explicit
  • tweaked the hint in the tutorial to reflect the fact dis_proj_auto runs if the thresholds are not provided

The looser omegaI/omegaOD tolerances were the only inline tolerance
override in jobconfig; they now live in userconfig as a named program
section, as for the other tests that need non-default tolerances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qiaojunfeng

Copy link
Copy Markdown
Collaborator

Thanks @elinscott, very nice results!

Do we set dis_proj_auto to True by default? As we have seen, the algorithm outperforms the default thresholds.

Yes I'd like to see it set as default, making things more automated is better.

Thanks @qiaojunfeng !

OK, if we're going to enable this by default then I have modified the keyword precedence so that this change gives minimal surprising behaviour:

  • dis_proj_auto is enabled by default.

  • If dis_proj_min/dis_proj_max are explicitly provided, they take precedence and disable the automatic thresholding. This means that...

    • existing PDWF input files with specified thresholds behave as before
    • existing PDWF input files without thresholds specified now use auto rather than defaults (changed behaviour!)
  • We give errors if...

    • both dis_proj_auto = .true. and the thresholds are set explicitly
    • with dis_froz_proj = .true., the user sets dis_proj_min xor dis_proj_max
    • with dis_froz_proj = .true., the user sets dis_proj_auto = .false. without providing the thresholds
  • dis_proj_auto is silently ignored if dis_froz_proj = .false. (previously a hard error)

  • dis_proj_min/dis_proj_max no longer have default values (previously 0.01/0.95): with dis_froz_proj = .true. the thresholds are either determined automatically or set explicitly.

Other changes:

  • renamed dis_proj_auto_classes to dis_proj_auto_num_classes to be more explicit
  • tweaked the hint in the tutorial to reflect the fact dis_proj_auto runs if the thresholds are not provided

Thanks for the explanation, all looks good!

@qiaojunfeng
qiaojunfeng merged commit e9f448e into wannier-developers:develop Jul 31, 2026
11 checks passed
@elinscott
elinscott deleted the feature/dis-proj-auto branch July 31, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants