From 0a2c5f2965f4d6c10530e9c7604f1959d0ffd21d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 15:15:09 +0000 Subject: [PATCH] chore: normalise all tracked text files to LF + enforce via .gitattributes 127 tracked text files had drifted to CRLF endings despite the repo's LF rule (surfaced by the 2026-07-25 hygiene crlf tier). Strip the carriage returns in one mechanical pass and add '* text=auto eol=lf' so git normalises every text file at checkin/checkout from now on, subsuming the previous targeted shebang/script rules. Verified: 'git diff --ignore-cr-at-eol' shows zero non-ending content change, no CRLF text files remain, and the full test suite passes post-conversion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PzN9PZfG5zXMVku8ckSqkC --- .gitattributes | 7 +- CODE_OF_CONDUCT.md | 614 ++-- CONTRIBUTING.md | 254 +- LICENSE | 42 +- MANIFEST.in | 42 +- autofit/__init__.py | 374 +-- autofit/aggregator/__init__.py | 12 +- autofit/aggregator/base.py | 342 +-- autofit/aggregator/predicate.py | 746 ++--- autofit/aggregator/search_output.py | 968 +++---- autofit/config/non_linear/GridSearch.yaml | 8 +- autofit/config/visualize/general.yaml | 2 +- autofit/config/visualize/plots_search.yaml | 14 +- autofit/config/visualize/plots_settings.yaml | 12 +- autofit/example/__init__.py | 6 +- autofit/example/model.py | 358 +-- autofit/example/result.py | 30 +- autofit/example/util.py | 106 +- autofit/example/visualize.py | 426 +-- autofit/exc.py | 148 +- autofit/fixtures.py | 68 +- autofit/graphical/factor_graphs/graph.py | 970 +++---- autofit/mapper/mock/mock_model.py | 408 +-- autofit/mapper/model.py | 1096 +++---- autofit/mapper/model_mapper.py | 166 +- autofit/mapper/model_object.py | 698 ++--- autofit/mapper/prior/arithmetic/arithmetic.py | 424 +-- autofit/mapper/prior/arithmetic/assertion.py | 260 +- autofit/mapper/prior/arithmetic/compound.py | 934 +++--- autofit/mapper/prior/deferred.py | 136 +- autofit/mapper/prior/vectorized.py | 386 +-- autofit/mapper/prior_model/annotation.py | 34 +- autofit/mapper/prior_model/attribute_pair.py | 124 +- autofit/mapper/prior_model/collection.py | 698 ++--- autofit/mapper/prior_model/prior_model.py | 1126 ++++---- autofit/mapper/prior_model/recursion.py | 176 +- autofit/mapper/prior_model/util.py | 66 +- autofit/mapper/variable.py | 1182 ++++---- autofit/messages/__init__.py | 26 +- autofit/mock.py | 64 +- autofit/non_linear/fitness.py | 1228 ++++---- autofit/non_linear/grid/grid_list.py | 150 +- .../non_linear/grid/sensitivity/__init__.py | 848 +++--- autofit/non_linear/grid/sensitivity/job.py | 370 +-- autofit/non_linear/grid/sensitivity/result.py | 258 +- autofit/non_linear/initializer.py | 954 +++---- autofit/non_linear/mock/mock_analysis.py | 68 +- autofit/non_linear/mock/mock_result.py | 156 +- autofit/non_linear/mock/mock_samples.py | 154 +- .../non_linear/mock/mock_samples_summary.py | 144 +- autofit/non_linear/parallel/__init__.py | 12 +- autofit/non_linear/paths/directory.py | 1080 +++---- autofit/non_linear/result.py | 1004 +++---- autofit/non_linear/samples/__init__.py | 12 +- autofit/non_linear/search/abstract_search.py | 2506 ++++++++--------- .../search/mcmc/auto_correlations.py | 230 +- .../non_linear/search/mcmc/emcee/search.py | 760 ++--- autofit/non_linear/search/mcmc/zeus/search.py | 830 +++--- .../non_linear/search/mle/drawer/search.py | 358 +-- .../non_linear/search/nest/abstract_nest.py | 152 +- .../non_linear/search/nest/nautilus/search.py | 1144 ++++---- autofit/non_linear/settings.py | 120 +- autofit/non_linear/timer.py | 158 +- autofit/text/__init__.py | 2 +- autofit/text/formatter.py | 514 ++-- autofit/text/samples_text.py | 198 +- autofit/text/text_util.py | 330 +-- autofit/tools/util.py | 270 +- docs/Makefile | 40 +- docs/_templates/custom-class-template.rst | 70 +- docs/_templates/custom_module_template.rst | 130 +- docs/api/analysis.rst | 52 +- docs/api/database.rst | 50 +- docs/api/model.rst | 60 +- docs/api/plot.rst | 52 +- docs/api/priors.rst | 58 +- docs/api/samples.rst | 60 +- docs/api/searches.rst | 186 +- docs/api/source.rst | 48 +- docs/conf.py | 286 +- docs/make.bat | 70 +- files/citation.tex | 96 +- files/citations.bib | 370 +-- files/citations.md | 44 +- paper/README.md | 8 +- paper/paper.bib | 572 ++-- paper/paper.json | 44 +- paper/paper.md | 332 +-- pyproject.toml | 200 +- setup.cfg | 2 +- setup.py | 14 +- test_autofit/aggregator/conftest.py | 74 +- .../files/derived_quantities.csv | 4 +- .../search_output_derived/files/samples.csv | 4 +- test_autofit/aggregator/test_aggregator.py | 128 +- .../config/non_linear/GridSearch.yaml | 8 +- test_autofit/conftest.py | 250 +- test_autofit/graphical/conftest.py | 46 +- test_autofit/graphical/gaussian/model.py | 194 +- .../graphical/gaussian/test_declarative.py | 372 +-- .../functionality/test_from_data_names.py | 52 +- .../functionality/test_take_attributes.py | 580 ++-- .../mapper/model/test_model_instance.py | 410 +-- .../mapper/model/test_model_mapper.py | 1594 +++++------ test_autofit/mapper/model/test_overloading.py | 44 +- test_autofit/mapper/model/test_prior_model.py | 878 +++--- test_autofit/mapper/prior/test_arithmetic.py | 354 +-- test_autofit/mapper/prior/test_assertion.py | 240 +- test_autofit/mapper/prior/test_prior.py | 600 ++-- .../mapper/prior/test_prior_parsing.py | 286 +- test_autofit/mapper/prior/test_vectorized.py | 180 +- test_autofit/mapper/test_abstract.py | 48 +- test_autofit/mapper/test_recursion.py | 92 +- .../grid/test_optimizer_grid_search.py | 642 ++--- test_autofit/non_linear/result/test_result.py | 208 +- test_autofit/non_linear/samples/test_nest.py | 158 +- test_autofit/non_linear/samples/test_pdf.py | 1028 +++---- .../non_linear/samples/test_samples.py | 532 ++-- .../non_linear/search/test_abstract_search.py | 844 +++--- test_autofit/non_linear/test_initializer.py | 738 ++--- test_autofit/non_linear/test_parallel.py | 52 +- test_autofit/non_linear/test_persistance.py | 24 +- test_autofit/test_equality.py | 140 +- test_autofit/text/test_formatter.py | 162 +- test_autofit/text/test_samples_text.py | 148 +- test_autofit/text/test_text_util.py | 206 +- test_autofit/tools/test_path_util.py | 46 +- test_autofit/tools/test_paths.py | 74 +- 128 files changed, 20925 insertions(+), 20922 deletions(-) diff --git a/.gitattributes b/.gitattributes index 9663701ae..2af6fbd66 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ -# Shebang'd, directly-runnable module — keep LF so execution works on HPC/Linux. -autofit/aggregator/aggregator.py eol=lf +# All files use Unix line endings (LF) — see AGENTS.md. text=auto lets git +# detect binaries; eol=lf normalises every text file on checkout and checkin, +# subsuming the earlier targeted shebang/script rules. Tree-wide CRLF +# normalisation landed alongside this file (2026-07-25). +* text=auto eol=lf diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 8f60bb6d3..8afb56400 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,308 +1,308 @@ -# PyAutoFit Code of Conduct - -**Table of Contents** - -- [The Short Version](#the-short-version) -- [The Longer Version](#the-longer-version) - - [PyAutoFit Diversity Statement](#project-diversity-statement) - - [PyAutoFit Code of Conduct: Introduction & Scope](#project-code-of-conduct-introduction--scope) - - [Standards for Behavior](#standards-for-behavior) - - [Unacceptable Behavior](#unacceptable-behavior) - - [Reporting Guidelines](#reporting-guidelines) - - [How to Submit a Report](#how-to-submit-a-report) - - [Person(s) Responsible for Resolving Complaints](#persons-responsible-for-resolving-complaints) - - [Fitlicts of Interest](#Fitlicts-of-interest) - - [What to Include in a Report](#what-to-include-in-a-report) - - [Enforcement: What Happens After a Report is Filed?](#enforcement-what-happens-after-a-report-is-filed) - - [Acknowledgment and Responding to Immediate Needs](#acknowledgment-and-responding-to-immediate-needs) - - [Reviewing the Report](#reviewing-the-report) - - [Contacting the Person Reported](#contacting-the-person-reported) - - [Response and Potential Consequences](#response-and-potential-consequences) - - [Appealing a Decision](#appealing-a-decision) - - [Timeline Summary:](#timeline-summary) - - [Fitirming Receipt](#Fitirming-receipt) - - [Reviewing the Report](#reviewing-the-report-1) - - [Consequences & Resolution](#consequences--resolution) -- [License](#license) - -## The Short Version - -Be kind to others. Do not insult or put down others. Behave professionally. Remember that harassment and sexist, -racist, or exclusionary jokes are not appropriate for PyAutoFit. - -All communication should be appropriate for a professional audience including people of many different backgrounds. -Sexual language and imagery is not appropriate. - -PyAutoFit is dedicated to providing a harassment-free community for everyone, regardless of gender, sexual orientation, -gender identity and expression, disability, physical appearance, body size, race, or religion. We do not tolerate -harassment of community members in any form. - -Thank you for helping make this a welcoming, friendly community for all. - -## The Longer Version - -### PyAutoFit Diversity Statement - -PyAutoFit welcomes and encourages participation in our community by people of all backgrounds and identities. We -are committed to promoting and sustaining a culture that values mutual respect, tolerance, and learning, and we work -together as a community to help each other live out these values. - -We have created this diversity statement because we believe that a diverse community is stronger, more vibrant, -and produces better software and better science. A diverse community where people treat each other with respect has -more potential contributors, more sources for ideas, and fewer shared assumptions that might hinder development -or research. - -Although we have phrased the formal diversity statement generically to make it all-inclusive, we recognize that there -are specific identities that are impacted by systemic discrimination and marginalization. We welcome all people to -participate in the PyAutoFit community regardless of their identity or background. - -### PyAutoFit Code of Conduct: Introduction & Scope - -This code of conduct should be honored by everyone who participates in the PyAutoFit community. It should be -honored in any PyAutoFit-related activities, by anyone claiming affiliation with PyAutoFit, and especially when -someone is representing PyAutoFit in any role (including as an event volunteer or speaker). - -This code of conduct applies to all spaces managed by PyAutoFit, including all public and private mailing lists, -issue trackers, wikis, forums, and any other communication channel used by our community. The code of conduct equally -applies at PyAutoFit events and governs standards of behavior for attendees, speakers, volunteers, booth staff, -and event sponsors. - -This code is not exhaustive or complete. It serves to distill our understanding of a collaborative, inclusive -community culture. Please try to follow this code in spirit as much as in letter, to create a friendly and -productive environment that enriches the PyAutoFit community. - -The PyAutoFit Code of Conduct follows below. - -### Standards for Behavior - -PyAutoFit is a worldwide community. All communication should be appropriate for a professional audience including -people of many different backgrounds. - -**Please always be kind and courteous. There's never a need to be mean or rude or disrespectful.** Thank you for -helping make this a welcoming, friendly community for all. - -We strive to: - -**Be empathetic, welcoming, friendly, and patient.** We remember that PyAutoFit is crafted by human beings who -deserve to be treated with kindness and empathy. We work together to resolve Fitlict and assume good intentions. -We may all experience some frustration from time to time, but we do not allow frustration to turn into a personal -attack. A community where people feel uncomfortable or threatened is not a productive one. - -**Be collaborative.** Our work depends on the participation of many people, and in turn others depend on our work. -Open source communities depend on effective and friendly collaboration to achieve their goals. - -**Be inquisitive.** Nobody knows everything! Asking questions early avoids many problems later, so we encourage -questions, although we may direct them to the appropriate forum. We will try hard to be responsive and helpful. - -**Be careful in the words that we choose.** We are careful and respectful in our communication and we take -responsibility for our own speech. Be kind to others. Do not insult or put down other members of the community. - -#### Unacceptable Behavior - -We are committed to making participation in this community a harassment-free experience. - -We will not accept harassment or other exclusionary behaviours, such as: - -- The use of sexualized language or imagery -- Excessive profanity (please avoid curse words; people differ greatly in their sensitivity to swearing) -- Posting sexually explicit or violent material -- Violent or intimidating threats or language directed against another person -- Inappropriate physical contact and/or unwelcome sexual attention or sexual comments -- Sexist, racist, or otherwise discriminatory jokes and language -- Trolling or insulting and derogatory comments -- Written or verbal comments which have the effect of excluding people on the basis of membership in a specific group, -including level of experience, gender, gender identity and expression, sexual orientation, disability, neurotype, -personal appearance, body size, race, ethnicity, age, religion, or nationality -- Public or private harassment -- Sharing private content, such as emails sent privately or non-publicly, or direct message history, without the -sender's consent -- Continuing to initiate interaction (such as photography, recording, messaging, or conversation) with someone after -being asked to stop -- Sustained disruption of talks, events, or communications, such as heckling of a speaker -- Publishing (or threatening to post) other people's personally identifying information ("doxing"), such as -physical or electronic addresses, without explicit permission -- Other unethical or unprofessional conduct -- Advocating for, or encouraging, any of the above behaviors - -### Reporting Guidelines - -If you believe someone is violating the code of conduct, please report this in a timely manner. Code of conduct -violations reduce the value of the community for everyone. The PyAutoFit leadership team takes reports of misconduct -very seriously and is committed to preserving and maintaining the welcoming nature of our community. - -**All reports will be kept Fitidential.** - -In some cases we may determine that a public statement will need to be made. If that's the case, the identities of -all involved parties and reporters will remain Fitidential unless those individuals instruct us otherwise. - -All complaints will be reviewed and investigated and will result in a response that is deemed necessary and -appropriate to the circumstances. The PyAutoFit team commits to maintaining Fitidentiality with regard to the -reporter of an incident. - -For possibly unintentional breaches of the code of conduct, you may want to respond to the person and point out -this code of conduct (either in public or in private, whatever is most appropriate). If you would prefer not to do -that, please report the issue to PyAutoFit directly, or ask James Nightingale for advice in Fitidence. Complete contact -information is below, under "How to Submit a Report." - -Take care of each other. Alert PyAutoFit if you notice a dangerous situation, someone in distress, or violations of -this code of conduct, even if they seem inconsequential. - -#### How to Submit a Report - -**If you feel your safety is in jeopardy or the situation is an emergency, we urge you to contact local law enforcement -before making a report to PyAutoFit.** (In the U.K., dial 999.) - -PyAutoFit is committed to promptly addressing any reported issues. If you have experienced or witnessed behavior that -violates the PyAutoFit Code of Conduct, please report it by sending an email to one of the members of the PyAutoFit -CoC Enforcement Team. - -#### Person(s) Responsible for Resolving Complaints - -All reports of breaches of the code of conduct will be investigated and handled by the **PyAutoFit Code of Conduct Enforcement Team**. - -The current PyAutoFit Code of Conduct Enforcement Team consists of: - -- James Nightingale - - - [*james.w.nightingale@durham.ac.uk*](mailto:james.w.nightingale@durham.ac.uk) - -#### Fitlicts of Interest - -In the event of any Fitlict of interest, the team member will immediately notify the PyAutoFit Code of Conduct -Enforcement Team and recuse themselves if necessary. - -#### What to Include in a Report - -Our ability to address any code of conduct breaches in a timely and effective manner is impacted by the amount of -information you can provide, so, **our reporting form asks you to include as much of the following information as you can**: - -- **Your contact info** (so we can get in touch with you if we need to follow up). This will be kept Fitidential. -If you wish to remain anonymous, your information will not be shared beyond the person receiving the initial report. -- The **approximate time and location of the incident** (please be as specific as possible) -- **Identifying information** (e.g. name, nickname, screen name, physical description) of the individual whose -behavior is being reported -- **Description of the behavior** (if reporting harassing language, please be specific about the words -used), **your account of what happened**, and any available **supporting records** (e.g. email, GitHub issue, screenshots, etc.) -- **Description of the circumstances/context** surrounding the incident -- Let us know **if the incident is ongoing**, and/or if this is part of an ongoing pattern of behavior -- Names and contact info, if possible, of **anyone else who witnessed** or was involved in this incident. (Did -anyone else observe the incident?) -- **Any other relevant information** you believe we should have - -At PyAutoFit Events: Event staff will attempt to gather and write down the above information from anyone making a -verbal report in-person at an event. Recording the details in writing is exceedingly important in order for us to -effectively respond to reports. If event staff write down a report taken verbally, then the person making the -report will be asked to review the written report for accuracy. - -**If urgent action is needed regarding an incident at an in-person event, we strongly encourage you to reach out to the local event staff for immediate assistance.** - -### Enforcement: What Happens After a Report is Filed? - -What happens after a report is filed? - -#### Acknowledgment and Responding to Immediate Needs - -PyAutoFit and/or our event staff will attempt to ensure your safety and help with any immediate needs, particularly -at an in-person event. PyAutoFit will make every effort to **acknowledge receipt within 24 hours** (and we'll aim -for much more quickly than that). - - - -#### Reviewing the Report - -PyAutoFit will make all efforts to **review the incident within three days** and determine: - -- Whether this is an ongoing situation, or if there is a threat to anyone's physical safety -- What happened -- Whether this event constitutes a code of conduct violation -- Who the bad actor was, if any - -#### Contacting the Person Reported - -After PyAutoFit has had time to review and discuss the report, someone will attempt to contact the person who is the -subject of the report to inform them of what has been reported about them. We will then ask that person for their -account of what happened. - -#### Response and Potential Consequences - -Once PyAutoFit has completed our investigation of the report, we will make a decision as to how to respond. The -person making a report will not normally be consulted as to the proposed resolution of the issue, except insofar as -we need to understand how to help them feel safe. - -Potential consequences for violating the PyAutoFit code of conduct include: - -- Nothing (if we determine that no violation occurred) -- Private feedback or reprimand from PyAutoFit to the individual(s) involved -- Warning the person to cease their behavior and that any further reports will result in sanctions -- A public announcement that an incident occurred -- Mediation (only if both reporter and reportee agree) -- An imposed vacation (e.g. asking someone to "take a week off" from a mailing list) -- A permanent or temporary ban from some or all PyAutoFit spaces (mailing lists, GitHub repos, in-person events, etc.) -- Assistance to the complainant with a report to other bodies, for example, institutional offices or appropriate law enforcement agencies -- Removing a person from PyAutoFit membership or other formal affiliation -- Publishing an account of the harassment and calling for the resignation of the alleged harasser from their -responsibilities (usually pursued by people without formal authority: may be called for if the person is the -event leader, or refuses to stand aside from the Fitlict of interest, or similar) -- Any other response that PyAutoFit deems necessary and appropriate to the situation - -At PyAutoFit events, if a participant engages in behavior that violates this code of conduct, the Fiterence -organizers and staff may take any action they deem appropriate. - -Potential consequences for violating the PyAutoFit Code of Conduct at an in-person event include: - -- Warning the person to cease their behavior and that any further reports will result in sanctions -- Requiring that the person avoid any interaction with, and physical proximity to, the person they are harassing -for the remainder of the event -- Ending a talk that violates the policy early -- Not publishing the video or slides of a talk that violated the policy -- Not allowing a speaker who violated the policy to give (further) talks at the event now or in the future -- Immediately ending any event volunteer responsibilities and privileges the reported person holds -- Requiring that the person not volunteer for future events PyAutoFit runs (either indefinitely or for a certain time period) -- Expelling the person from the event without a refund -- Requiring that the person immediately leave the event and not return -- Banning the person from future events (either indefinitely or for a certain time period) -- Any other response that PyAutoFit deems necessary and appropriate to the situation - -No one espousing views or values contrary to the standards of our code of conduct will be permitted to hold any -position representing PyAutoFit, including volunteer positions. PyAutoFit has the right and responsibility to -remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not -aligned with this code of conduct. - -We aim to **respond within one week** to the original reporter with either a resolution or an explanation of why the -situation is not yet resolved. - -We will contact the person who is the subject of the report to let them know what actions will be taken as a result of -the report, if any. - -Our policy is to make sure that everyone aware of the initial incident is also made aware that official action has -been taken, while still respecting the privacy of individuals. PyAutoFit may choose to make a public report of the -incident, while maintaining the anonymity of those involved. - -#### Appealing a Decision - -To appeal a decision of PyAutoFit, contact James Nightingale via email at -[*james.w.nightingale@durham.ac.uk*](mailto:james.w.nightingale@durham.ac.uk) with your appeal and -the Leadership Team will review the case. - -### Timeline Summary: - -#### Fitirming Receipt - -PyAutoFit will make every effort to acknowledge receipt of a report **within 24 hours** (and we'll aim for much more -quickly than that). - -#### Reviewing the Report - -PyAutoFit will make all efforts to review the incident **within three days**. - -#### Consequences & Resolution - -We aim to respond **within one week** to the original reporter with either a resolution or an explanation of why -the situation is not yet resolved. - -## License - -This code of conduct has been adapted from [*NUMFOCUS code of conduct*](https://numfocus.org/code-of-conduct), -which is adapted from numerous sources, including the [*Geek Feminism wiki, created by the Ada Initiative and other volunteers, which is under a Creative Commons Zero license*](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy), the [*Contributor Covenant version 1.2.0*](http://contributor-covenant.org/version/1/2/0/), the [*Bokeh Code of Conduct*](https://github.com/bokeh/bokeh/blob/main/docs/CODE_OF_CONDUCT.md), the [*SciPy Code of Conduct*](https://github.com/jupyter/governance/blob/main/conduct/enforcement.md), the [*Carpentries Code of Conduct*](https://docs.carpentries.org/topic_folders/policies/code-of-conduct.html#enforcement-manual), and the [*NeurIPS Code of Conduct*](https://neurips.cc/public/CodeOfConduct). - +# PyAutoFit Code of Conduct + +**Table of Contents** + +- [The Short Version](#the-short-version) +- [The Longer Version](#the-longer-version) + - [PyAutoFit Diversity Statement](#project-diversity-statement) + - [PyAutoFit Code of Conduct: Introduction & Scope](#project-code-of-conduct-introduction--scope) + - [Standards for Behavior](#standards-for-behavior) + - [Unacceptable Behavior](#unacceptable-behavior) + - [Reporting Guidelines](#reporting-guidelines) + - [How to Submit a Report](#how-to-submit-a-report) + - [Person(s) Responsible for Resolving Complaints](#persons-responsible-for-resolving-complaints) + - [Fitlicts of Interest](#Fitlicts-of-interest) + - [What to Include in a Report](#what-to-include-in-a-report) + - [Enforcement: What Happens After a Report is Filed?](#enforcement-what-happens-after-a-report-is-filed) + - [Acknowledgment and Responding to Immediate Needs](#acknowledgment-and-responding-to-immediate-needs) + - [Reviewing the Report](#reviewing-the-report) + - [Contacting the Person Reported](#contacting-the-person-reported) + - [Response and Potential Consequences](#response-and-potential-consequences) + - [Appealing a Decision](#appealing-a-decision) + - [Timeline Summary:](#timeline-summary) + - [Fitirming Receipt](#Fitirming-receipt) + - [Reviewing the Report](#reviewing-the-report-1) + - [Consequences & Resolution](#consequences--resolution) +- [License](#license) + +## The Short Version + +Be kind to others. Do not insult or put down others. Behave professionally. Remember that harassment and sexist, +racist, or exclusionary jokes are not appropriate for PyAutoFit. + +All communication should be appropriate for a professional audience including people of many different backgrounds. +Sexual language and imagery is not appropriate. + +PyAutoFit is dedicated to providing a harassment-free community for everyone, regardless of gender, sexual orientation, +gender identity and expression, disability, physical appearance, body size, race, or religion. We do not tolerate +harassment of community members in any form. + +Thank you for helping make this a welcoming, friendly community for all. + +## The Longer Version + +### PyAutoFit Diversity Statement + +PyAutoFit welcomes and encourages participation in our community by people of all backgrounds and identities. We +are committed to promoting and sustaining a culture that values mutual respect, tolerance, and learning, and we work +together as a community to help each other live out these values. + +We have created this diversity statement because we believe that a diverse community is stronger, more vibrant, +and produces better software and better science. A diverse community where people treat each other with respect has +more potential contributors, more sources for ideas, and fewer shared assumptions that might hinder development +or research. + +Although we have phrased the formal diversity statement generically to make it all-inclusive, we recognize that there +are specific identities that are impacted by systemic discrimination and marginalization. We welcome all people to +participate in the PyAutoFit community regardless of their identity or background. + +### PyAutoFit Code of Conduct: Introduction & Scope + +This code of conduct should be honored by everyone who participates in the PyAutoFit community. It should be +honored in any PyAutoFit-related activities, by anyone claiming affiliation with PyAutoFit, and especially when +someone is representing PyAutoFit in any role (including as an event volunteer or speaker). + +This code of conduct applies to all spaces managed by PyAutoFit, including all public and private mailing lists, +issue trackers, wikis, forums, and any other communication channel used by our community. The code of conduct equally +applies at PyAutoFit events and governs standards of behavior for attendees, speakers, volunteers, booth staff, +and event sponsors. + +This code is not exhaustive or complete. It serves to distill our understanding of a collaborative, inclusive +community culture. Please try to follow this code in spirit as much as in letter, to create a friendly and +productive environment that enriches the PyAutoFit community. + +The PyAutoFit Code of Conduct follows below. + +### Standards for Behavior + +PyAutoFit is a worldwide community. All communication should be appropriate for a professional audience including +people of many different backgrounds. + +**Please always be kind and courteous. There's never a need to be mean or rude or disrespectful.** Thank you for +helping make this a welcoming, friendly community for all. + +We strive to: + +**Be empathetic, welcoming, friendly, and patient.** We remember that PyAutoFit is crafted by human beings who +deserve to be treated with kindness and empathy. We work together to resolve Fitlict and assume good intentions. +We may all experience some frustration from time to time, but we do not allow frustration to turn into a personal +attack. A community where people feel uncomfortable or threatened is not a productive one. + +**Be collaborative.** Our work depends on the participation of many people, and in turn others depend on our work. +Open source communities depend on effective and friendly collaboration to achieve their goals. + +**Be inquisitive.** Nobody knows everything! Asking questions early avoids many problems later, so we encourage +questions, although we may direct them to the appropriate forum. We will try hard to be responsive and helpful. + +**Be careful in the words that we choose.** We are careful and respectful in our communication and we take +responsibility for our own speech. Be kind to others. Do not insult or put down other members of the community. + +#### Unacceptable Behavior + +We are committed to making participation in this community a harassment-free experience. + +We will not accept harassment or other exclusionary behaviours, such as: + +- The use of sexualized language or imagery +- Excessive profanity (please avoid curse words; people differ greatly in their sensitivity to swearing) +- Posting sexually explicit or violent material +- Violent or intimidating threats or language directed against another person +- Inappropriate physical contact and/or unwelcome sexual attention or sexual comments +- Sexist, racist, or otherwise discriminatory jokes and language +- Trolling or insulting and derogatory comments +- Written or verbal comments which have the effect of excluding people on the basis of membership in a specific group, +including level of experience, gender, gender identity and expression, sexual orientation, disability, neurotype, +personal appearance, body size, race, ethnicity, age, religion, or nationality +- Public or private harassment +- Sharing private content, such as emails sent privately or non-publicly, or direct message history, without the +sender's consent +- Continuing to initiate interaction (such as photography, recording, messaging, or conversation) with someone after +being asked to stop +- Sustained disruption of talks, events, or communications, such as heckling of a speaker +- Publishing (or threatening to post) other people's personally identifying information ("doxing"), such as +physical or electronic addresses, without explicit permission +- Other unethical or unprofessional conduct +- Advocating for, or encouraging, any of the above behaviors + +### Reporting Guidelines + +If you believe someone is violating the code of conduct, please report this in a timely manner. Code of conduct +violations reduce the value of the community for everyone. The PyAutoFit leadership team takes reports of misconduct +very seriously and is committed to preserving and maintaining the welcoming nature of our community. + +**All reports will be kept Fitidential.** + +In some cases we may determine that a public statement will need to be made. If that's the case, the identities of +all involved parties and reporters will remain Fitidential unless those individuals instruct us otherwise. + +All complaints will be reviewed and investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. The PyAutoFit team commits to maintaining Fitidentiality with regard to the +reporter of an incident. + +For possibly unintentional breaches of the code of conduct, you may want to respond to the person and point out +this code of conduct (either in public or in private, whatever is most appropriate). If you would prefer not to do +that, please report the issue to PyAutoFit directly, or ask James Nightingale for advice in Fitidence. Complete contact +information is below, under "How to Submit a Report." + +Take care of each other. Alert PyAutoFit if you notice a dangerous situation, someone in distress, or violations of +this code of conduct, even if they seem inconsequential. + +#### How to Submit a Report + +**If you feel your safety is in jeopardy or the situation is an emergency, we urge you to contact local law enforcement +before making a report to PyAutoFit.** (In the U.K., dial 999.) + +PyAutoFit is committed to promptly addressing any reported issues. If you have experienced or witnessed behavior that +violates the PyAutoFit Code of Conduct, please report it by sending an email to one of the members of the PyAutoFit +CoC Enforcement Team. + +#### Person(s) Responsible for Resolving Complaints + +All reports of breaches of the code of conduct will be investigated and handled by the **PyAutoFit Code of Conduct Enforcement Team**. + +The current PyAutoFit Code of Conduct Enforcement Team consists of: + +- James Nightingale + + - [*james.w.nightingale@durham.ac.uk*](mailto:james.w.nightingale@durham.ac.uk) + +#### Fitlicts of Interest + +In the event of any Fitlict of interest, the team member will immediately notify the PyAutoFit Code of Conduct +Enforcement Team and recuse themselves if necessary. + +#### What to Include in a Report + +Our ability to address any code of conduct breaches in a timely and effective manner is impacted by the amount of +information you can provide, so, **our reporting form asks you to include as much of the following information as you can**: + +- **Your contact info** (so we can get in touch with you if we need to follow up). This will be kept Fitidential. +If you wish to remain anonymous, your information will not be shared beyond the person receiving the initial report. +- The **approximate time and location of the incident** (please be as specific as possible) +- **Identifying information** (e.g. name, nickname, screen name, physical description) of the individual whose +behavior is being reported +- **Description of the behavior** (if reporting harassing language, please be specific about the words +used), **your account of what happened**, and any available **supporting records** (e.g. email, GitHub issue, screenshots, etc.) +- **Description of the circumstances/context** surrounding the incident +- Let us know **if the incident is ongoing**, and/or if this is part of an ongoing pattern of behavior +- Names and contact info, if possible, of **anyone else who witnessed** or was involved in this incident. (Did +anyone else observe the incident?) +- **Any other relevant information** you believe we should have + +At PyAutoFit Events: Event staff will attempt to gather and write down the above information from anyone making a +verbal report in-person at an event. Recording the details in writing is exceedingly important in order for us to +effectively respond to reports. If event staff write down a report taken verbally, then the person making the +report will be asked to review the written report for accuracy. + +**If urgent action is needed regarding an incident at an in-person event, we strongly encourage you to reach out to the local event staff for immediate assistance.** + +### Enforcement: What Happens After a Report is Filed? + +What happens after a report is filed? + +#### Acknowledgment and Responding to Immediate Needs + +PyAutoFit and/or our event staff will attempt to ensure your safety and help with any immediate needs, particularly +at an in-person event. PyAutoFit will make every effort to **acknowledge receipt within 24 hours** (and we'll aim +for much more quickly than that). + + + +#### Reviewing the Report + +PyAutoFit will make all efforts to **review the incident within three days** and determine: + +- Whether this is an ongoing situation, or if there is a threat to anyone's physical safety +- What happened +- Whether this event constitutes a code of conduct violation +- Who the bad actor was, if any + +#### Contacting the Person Reported + +After PyAutoFit has had time to review and discuss the report, someone will attempt to contact the person who is the +subject of the report to inform them of what has been reported about them. We will then ask that person for their +account of what happened. + +#### Response and Potential Consequences + +Once PyAutoFit has completed our investigation of the report, we will make a decision as to how to respond. The +person making a report will not normally be consulted as to the proposed resolution of the issue, except insofar as +we need to understand how to help them feel safe. + +Potential consequences for violating the PyAutoFit code of conduct include: + +- Nothing (if we determine that no violation occurred) +- Private feedback or reprimand from PyAutoFit to the individual(s) involved +- Warning the person to cease their behavior and that any further reports will result in sanctions +- A public announcement that an incident occurred +- Mediation (only if both reporter and reportee agree) +- An imposed vacation (e.g. asking someone to "take a week off" from a mailing list) +- A permanent or temporary ban from some or all PyAutoFit spaces (mailing lists, GitHub repos, in-person events, etc.) +- Assistance to the complainant with a report to other bodies, for example, institutional offices or appropriate law enforcement agencies +- Removing a person from PyAutoFit membership or other formal affiliation +- Publishing an account of the harassment and calling for the resignation of the alleged harasser from their +responsibilities (usually pursued by people without formal authority: may be called for if the person is the +event leader, or refuses to stand aside from the Fitlict of interest, or similar) +- Any other response that PyAutoFit deems necessary and appropriate to the situation + +At PyAutoFit events, if a participant engages in behavior that violates this code of conduct, the Fiterence +organizers and staff may take any action they deem appropriate. + +Potential consequences for violating the PyAutoFit Code of Conduct at an in-person event include: + +- Warning the person to cease their behavior and that any further reports will result in sanctions +- Requiring that the person avoid any interaction with, and physical proximity to, the person they are harassing +for the remainder of the event +- Ending a talk that violates the policy early +- Not publishing the video or slides of a talk that violated the policy +- Not allowing a speaker who violated the policy to give (further) talks at the event now or in the future +- Immediately ending any event volunteer responsibilities and privileges the reported person holds +- Requiring that the person not volunteer for future events PyAutoFit runs (either indefinitely or for a certain time period) +- Expelling the person from the event without a refund +- Requiring that the person immediately leave the event and not return +- Banning the person from future events (either indefinitely or for a certain time period) +- Any other response that PyAutoFit deems necessary and appropriate to the situation + +No one espousing views or values contrary to the standards of our code of conduct will be permitted to hold any +position representing PyAutoFit, including volunteer positions. PyAutoFit has the right and responsibility to +remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not +aligned with this code of conduct. + +We aim to **respond within one week** to the original reporter with either a resolution or an explanation of why the +situation is not yet resolved. + +We will contact the person who is the subject of the report to let them know what actions will be taken as a result of +the report, if any. + +Our policy is to make sure that everyone aware of the initial incident is also made aware that official action has +been taken, while still respecting the privacy of individuals. PyAutoFit may choose to make a public report of the +incident, while maintaining the anonymity of those involved. + +#### Appealing a Decision + +To appeal a decision of PyAutoFit, contact James Nightingale via email at +[*james.w.nightingale@durham.ac.uk*](mailto:james.w.nightingale@durham.ac.uk) with your appeal and +the Leadership Team will review the case. + +### Timeline Summary: + +#### Fitirming Receipt + +PyAutoFit will make every effort to acknowledge receipt of a report **within 24 hours** (and we'll aim for much more +quickly than that). + +#### Reviewing the Report + +PyAutoFit will make all efforts to review the incident **within three days**. + +#### Consequences & Resolution + +We aim to respond **within one week** to the original reporter with either a resolution or an explanation of why +the situation is not yet resolved. + +## License + +This code of conduct has been adapted from [*NUMFOCUS code of conduct*](https://numfocus.org/code-of-conduct), +which is adapted from numerous sources, including the [*Geek Feminism wiki, created by the Ada Initiative and other volunteers, which is under a Creative Commons Zero license*](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy), the [*Contributor Covenant version 1.2.0*](http://contributor-covenant.org/version/1/2/0/), the [*Bokeh Code of Conduct*](https://github.com/bokeh/bokeh/blob/main/docs/CODE_OF_CONDUCT.md), the [*SciPy Code of Conduct*](https://github.com/jupyter/governance/blob/main/conduct/enforcement.md), the [*Carpentries Code of Conduct*](https://docs.carpentries.org/topic_folders/policies/code-of-conduct.html#enforcement-manual), and the [*NeurIPS Code of Conduct*](https://neurips.cc/public/CodeOfConduct). + **PyAutoFit Code of Conduct is licensed under the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/).** \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 159ef8308..e609985ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,127 +1,127 @@ -# AI-Assisted Development - -This project uses an AI-first development workflow. Most features, bug fixes, and improvements are implemented through AI coding agents (Claude Code, GitHub Copilot, OpenAI Codex) working from structured issue descriptions. - -## How It Works - -1. **Issues are the starting point** — Every task begins as a GitHub issue with a structured format: an overview, a human-readable plan, a detailed implementation plan (in a collapsible block), and optionally the original prompt that generated the issue. - -2. **AI agents pick up issues** — Issues can be assigned to AI coding agents (e.g. GitHub Copilot) which read the issue description, `AGENTS.md`, and `CLAUDE.md` for context, then implement the changes autonomously. - -3. **Human review** — All AI-generated pull requests are reviewed by maintainers before merging. - -## Maintainer Workflow - -Maintainer-driven dev work starts as a prompt file in -**[PyAutoPrompt](https://github.com/PyAutoLabs/PyAutoPrompt)** — the public -workflow repo that hosts the PyAuto task registry and the prompt-coupled -Claude Code skills. The pipeline: - -1. Write the task as `PyAutoPrompt//.md` (free-form markdown - describing what to do, with `@RepoName/path/to/file.py` references). -2. `/start_dev /.md` — reads the prompt, audits the code, - drafts the GitHub issue you see in this repo, and files it. -3. `/start_library` or `/start_workspace` — opens a feature worktree under - `~/Code/PyAutoLabs-wt//`. -4. `/ship_library` / `/ship_workspace` — runs tests, opens the PR, and - tracks state in `PyAutoPrompt/active.md`. - -External contributors don't need PyAutoPrompt access — open an issue using -the templates in this repo and the same machinery handles it on our end. - -## Creating an Issue - -When opening an issue, please use the provided issue templates. The **Feature / Task Request** template follows our standard format: - -- **Overview** — What and why, in 2-4 sentences -- **Plan** — High-level bullet points (human-readable) -- **Detailed implementation plan** — File paths, steps, key files (in a collapsible block) -- **Original Prompt** — If you used an AI to help draft the issue, include the original prompt - -If your feature involves a specific calculation, algorithm, or small piece of functionality — **include example code**. Even a rough script, a working prototype, or a snippet showing the existing behaviour you want to change makes a huge difference. Code examples give AI agents and human contributors concrete context to work from, and dramatically reduce misunderstandings about what you're asking for. - -This structure ensures that both human contributors and AI agents can understand and act on the issue effectively. - -## Contributing Without AI - -Traditional contributions are equally welcome! If you prefer to work without AI tools, simply follow the development setup and pull request guidelines below. The issue templates are helpful for any contributor, AI or human. - ---- - -# Contributing - -Contributions are welcome and greatly appreciated! - -## Types of Contributions - -### Report Bugs - -Report bugs at https://github.com/PyAutoLabs/PyAutoFit/issues - -If you are playing with the PyAutoFit library and find a bug, please -reporting it including: - -* Your operating system name and version. -* Any details about your Python environment. -* Detailed steps to reproduce the bug. - -### Propose New `NonLinearSearch` or Features - -The best way to send feedback is to open an issue at -https://github.com/PyAutoLabs/PyAutoFit/issues -with tag *enhancement*. - -If you are proposing a new `NonLinearSearch` or a new feature: - -* Explain in detail how it should work. -* Keep the scope as narrow as possible, to make it easier to implement. - -### Implement `NonLinearSearch` or Features -Look through the Git issues for operator or feature requests. -Anything tagged with *enhancement* is open to whoever wants to -implement it. - -### Add Examples or improve Documentation -Writing new features is not the only way to get involved and -contribute. Create examples with existing non-linear searches as well -as improving the documentation of existing operators is as important -as making new non-linear searches and very much encouraged. - - -## Getting Started to contribute - -Ready to contribute? - -1. Follow the installation instructions for installing **PyAutoFit** from source root on our -[readthedocs](https://pyautofit.readthedocs.io/en/latest/general/installation.html#forking-cloning>). - -2. Create a branch for local development: - ``` - git checkout -b name-of-your-branch - ``` - Now you can make your changes locally. - -3. When you're done making changes, check that old and new tests pass -succesfully: - ``` - cd PyAutoFit/test_autofit - python3 -m pytest - ``` - -4. Commit your changes and push your branch to GitLab:: - ``` - git add . - git commit -m "Your detailed description of your changes." - git push origin name-of-your-branch - ``` - Remember to add ``-u`` when pushing the branch for the first time. - -5. Submit a pull request through the GitHub website. - - -### Pull Request Guidelines - -Before you submit a pull request, check that it meets these guidelines: - -1. The pull request should include new tests for all the core routines that have been developed. -2. If the pull request adds functionality, the docs should be updated accordingly. +# AI-Assisted Development + +This project uses an AI-first development workflow. Most features, bug fixes, and improvements are implemented through AI coding agents (Claude Code, GitHub Copilot, OpenAI Codex) working from structured issue descriptions. + +## How It Works + +1. **Issues are the starting point** — Every task begins as a GitHub issue with a structured format: an overview, a human-readable plan, a detailed implementation plan (in a collapsible block), and optionally the original prompt that generated the issue. + +2. **AI agents pick up issues** — Issues can be assigned to AI coding agents (e.g. GitHub Copilot) which read the issue description, `AGENTS.md`, and `CLAUDE.md` for context, then implement the changes autonomously. + +3. **Human review** — All AI-generated pull requests are reviewed by maintainers before merging. + +## Maintainer Workflow + +Maintainer-driven dev work starts as a prompt file in +**[PyAutoPrompt](https://github.com/PyAutoLabs/PyAutoPrompt)** — the public +workflow repo that hosts the PyAuto task registry and the prompt-coupled +Claude Code skills. The pipeline: + +1. Write the task as `PyAutoPrompt//.md` (free-form markdown + describing what to do, with `@RepoName/path/to/file.py` references). +2. `/start_dev /.md` — reads the prompt, audits the code, + drafts the GitHub issue you see in this repo, and files it. +3. `/start_library` or `/start_workspace` — opens a feature worktree under + `~/Code/PyAutoLabs-wt//`. +4. `/ship_library` / `/ship_workspace` — runs tests, opens the PR, and + tracks state in `PyAutoPrompt/active.md`. + +External contributors don't need PyAutoPrompt access — open an issue using +the templates in this repo and the same machinery handles it on our end. + +## Creating an Issue + +When opening an issue, please use the provided issue templates. The **Feature / Task Request** template follows our standard format: + +- **Overview** — What and why, in 2-4 sentences +- **Plan** — High-level bullet points (human-readable) +- **Detailed implementation plan** — File paths, steps, key files (in a collapsible block) +- **Original Prompt** — If you used an AI to help draft the issue, include the original prompt + +If your feature involves a specific calculation, algorithm, or small piece of functionality — **include example code**. Even a rough script, a working prototype, or a snippet showing the existing behaviour you want to change makes a huge difference. Code examples give AI agents and human contributors concrete context to work from, and dramatically reduce misunderstandings about what you're asking for. + +This structure ensures that both human contributors and AI agents can understand and act on the issue effectively. + +## Contributing Without AI + +Traditional contributions are equally welcome! If you prefer to work without AI tools, simply follow the development setup and pull request guidelines below. The issue templates are helpful for any contributor, AI or human. + +--- + +# Contributing + +Contributions are welcome and greatly appreciated! + +## Types of Contributions + +### Report Bugs + +Report bugs at https://github.com/PyAutoLabs/PyAutoFit/issues + +If you are playing with the PyAutoFit library and find a bug, please +reporting it including: + +* Your operating system name and version. +* Any details about your Python environment. +* Detailed steps to reproduce the bug. + +### Propose New `NonLinearSearch` or Features + +The best way to send feedback is to open an issue at +https://github.com/PyAutoLabs/PyAutoFit/issues +with tag *enhancement*. + +If you are proposing a new `NonLinearSearch` or a new feature: + +* Explain in detail how it should work. +* Keep the scope as narrow as possible, to make it easier to implement. + +### Implement `NonLinearSearch` or Features +Look through the Git issues for operator or feature requests. +Anything tagged with *enhancement* is open to whoever wants to +implement it. + +### Add Examples or improve Documentation +Writing new features is not the only way to get involved and +contribute. Create examples with existing non-linear searches as well +as improving the documentation of existing operators is as important +as making new non-linear searches and very much encouraged. + + +## Getting Started to contribute + +Ready to contribute? + +1. Follow the installation instructions for installing **PyAutoFit** from source root on our +[readthedocs](https://pyautofit.readthedocs.io/en/latest/general/installation.html#forking-cloning>). + +2. Create a branch for local development: + ``` + git checkout -b name-of-your-branch + ``` + Now you can make your changes locally. + +3. When you're done making changes, check that old and new tests pass +succesfully: + ``` + cd PyAutoFit/test_autofit + python3 -m pytest + ``` + +4. Commit your changes and push your branch to GitLab:: + ``` + git add . + git commit -m "Your detailed description of your changes." + git push origin name-of-your-branch + ``` + Remember to add ``-u`` when pushing the branch for the first time. + +5. Submit a pull request through the GitHub website. + + +### Pull Request Guidelines + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include new tests for all the core routines that have been developed. +2. If the pull request adds functionality, the docs should be updated accordingly. diff --git a/LICENSE b/LICENSE index 3b6203620..cee9a8f2b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2018 Richard Hayes - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2018 Richard Hayes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index 523813de5..74ea7acea 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,22 +1,22 @@ -# MANIFEST.in -exclude .gitignore -include README.md -include setup.cfg -include CITATIONS.md -include LICENSE -include requirements.txt -include optional_requirements.txt - -prune .cache -prune .git -prune build -prune dist - -recursive-exclude *.egg-info * - -recursive-include autofit/config * - -exclude docs - -global-exclude test_autofit +# MANIFEST.in +exclude .gitignore +include README.md +include setup.cfg +include CITATIONS.md +include LICENSE +include requirements.txt +include optional_requirements.txt + +prune .cache +prune .git +prune build +prune dist + +recursive-exclude *.egg-info * + +recursive-include autofit/config * + +exclude docs + +global-exclude test_autofit recursive-exclude test_autofit * \ No newline at end of file diff --git a/autofit/__init__.py b/autofit/__init__.py index 2b666b250..5544e9bfa 100644 --- a/autofit/__init__.py +++ b/autofit/__init__.py @@ -1,187 +1,187 @@ -from autonerves import jax_wrapper -from autonerves.dictable import register_parser -from . import conf - -conf.instance.register(__file__) - -import abc -import pickle -from dill import register - -from . import exc -from . import mock as m -from .non_linear.grid.grid_search import GridSearch as SearchGridSearch -from .aggregator.base import AggBase -from .database.aggregator.aggregator import GridSearchAggregator -from .graphical.expectation_propagation.history import EPHistory -from .graphical.declarative.factor.analysis import AnalysisFactor -from .graphical.declarative.factor.analysis import EPAnalysisFactor -from .graphical.declarative.collection import FactorGraphModel -from .graphical.declarative.factor.hierarchical import HierarchicalFactor -from .graphical.laplace import LaplaceOptimiser -from .non_linear.grid.grid_list import GridList -from .non_linear.samples.summary import SamplesSummary -from .non_linear.samples import SamplesMCMC -from .non_linear.samples import SamplesNest -from .non_linear.samples import Samples -from .non_linear.samples import SamplesPDF -from .non_linear.samples import Sample -from .non_linear.samples import load_from_table -from .non_linear.samples import SamplesStored -from .database.aggregator import Aggregator -from .aggregator.summary.aggregate_csv import AggregateCSV -from .aggregator.summary.aggregate_csv import ValueType -from .aggregator.summary.aggregate_images import AggregateImages -from .aggregator.summary.aggregate_fits import AggregateFITS -from .database.aggregator import Query -from autofit.aggregator.fit_interface import Fit -from .aggregator.search_output import SearchOutput -from .mapper import prior -from .mapper.model import AbstractModel -from .mapper.model import ModelInstance -from .mapper.model import ModelInstance as Instance -from .mapper.model import path_instances_of_class -from .mapper.model_mapper import ModelMapper -from .mapper.model_mapper import ModelMapper as Mapper -from .mapper.model_object import ModelObject -from .mapper.operator import DiagonalMatrix -from .mapper.prior.constant import Constant -from .mapper.prior.arithmetic.assertion import ComparisonAssertion -from .mapper.prior.arithmetic.assertion import ComparisonAssertion -from .mapper.prior.arithmetic.assertion import GreaterThanLessThanAssertion -from .mapper.prior.arithmetic.assertion import GreaterThanLessThanEqualAssertion -from .mapper.prior.deferred import DeferredArgument -from .mapper.prior.deferred import DeferredInstance -from .mapper.prior.width_modifier import AbsoluteWidthModifier -from .mapper.prior.width_modifier import RelativeWidthModifier -from .mapper.prior.width_modifier import WidthModifier -from .mapper.prior import GaussianPrior -from .mapper.prior import LogGaussianPrior -from .mapper.prior import LogUniformPrior -from .mapper.prior import TruncatedGaussianPrior -from .mapper.prior.vectorized import PriorVectorized -from .mapper.prior.abstract import Prior -from .mapper.prior.tuple_prior import TuplePrior -from .mapper.prior import UniformPrior -from .mapper.prior_model.abstract import AbstractPriorModel -from .mapper.prior_model.annotation import AnnotationPriorModel -from .mapper.prior_model.collection import Collection -from .mapper.prior_model.prior_model import Model -from .mapper.prior_model.array import Array -from .non_linear.search.abstract_search import NonLinearSearch -from .non_linear.analysis.visualize import Visualizer -from .non_linear.analysis.latent import Latent -from .non_linear.analysis.analysis import Analysis -from .non_linear.grid.grid_search import GridSearchResult -from .non_linear.grid.sensitivity import Sensitivity -from .non_linear.initializer import InitializerBall -from .non_linear.initializer import InitializerPrior -from .non_linear.initializer import InitializerParamBounds -from .non_linear.initializer import InitializerParamStartPoints -from .non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings -from .non_linear.search.mcmc.blackjax.nuts.search import BlackJAXNUTS -from .non_linear.search.mcmc.emcee.search import Emcee -from .non_linear.search.mcmc.zeus.search import Zeus -from .non_linear.search.nest.nautilus.search import Nautilus -from .non_linear.search.nest.dynesty.search.dynamic import DynestyDynamic -from .non_linear.search.nest.dynesty.search.static import DynestyStatic -from .non_linear.search.mle.drawer.search import Drawer -from .non_linear.search.mle.bfgs.search import BFGS -from .non_linear.search.mle.bfgs.search import LBFGS -from .non_linear.search.mle.multi_start_gradient.search import MultiStartAdam -from .non_linear.search.mle.multi_start_gradient.search import MultiStartADABelief -from .non_linear.search.mle.multi_start_gradient.search import MultiStartLion -from .non_linear.search.mle.multi_start_gradient.search import MultiStartProdigy -from .non_linear.search.mle.multi_start_gradient.convergence import ( - MultiStartGradientConvergence, -) -from .non_linear.paths.abstract import AbstractPaths -from .non_linear.paths import DirectoryPaths -from .non_linear.paths import DatabasePaths -from .non_linear.result import Result -from .non_linear.result import ResultsCollection -from .non_linear.settings import SettingsSearch -from .non_linear.samples.pdf import marginalize -from .text import formatter -from .text import samples_text -from .visualise import VisualiseGraph -from .interpolator import ( - LinearInterpolator, - SplineInterpolator, - CovarianceInterpolator, - LinearRelationship, -) -from .tools import util - -from autofit.mapper.prior.arithmetic.compound import SumPrior as Add -from autofit.mapper.prior.arithmetic.compound import MultiplePrior as Multiply -from autofit.mapper.prior.arithmetic.compound import DivisionPrior as Divide -from autofit.mapper.prior.arithmetic.compound import ModPrior as Mod -from autofit.mapper.prior.arithmetic.compound import PowerPrior as Power -from autofit.mapper.prior.arithmetic.compound import AbsolutePrior as Abs -from autofit.mapper.prior.arithmetic.compound import Log -from autofit.mapper.prior.arithmetic.compound import Log10 - -from . import example as ex -from . import database as db - - -for type_ in ( - "model", - "collection", - "tuple_prior", - "dict", - "instance", - "Uniform", - "LogUniform", - "Gaussian", - "LogGaussian", - "TruncatedGaussian", - "compound", - "Constant", -): - register_parser(type_, ModelObject.from_dict) - - -@register(abc.ABCMeta) -def save_abc(pickler, obj): - pickle._Pickler.save_type(pickler, obj) - - -__version__ = "2026.7.23.1" - -from autonerves import check_version - -check_version(__version__) - -# --------------------------------------------------------------------------- -# Public re-export of the autonerves configuration / serialization surface. -# -# Workspaces, tutorials and downstream code import these names from the science -# library (e.g. ``from autolens import conf``) rather than depending on the -# ``autonerves`` package directly, so the underlying configuration / serialization -# layer stays an implementation detail of the library. -# --------------------------------------------------------------------------- -# ``conf`` is already exported above (``from . import conf``); the names below -# complete the surface. -from autonerves import jax_wrapper -from autonerves import fitsable -from autonerves import setup_colab -from autonerves import setup_notebook -from autonerves.conf import with_config -from autonerves.dictable import from_dict, from_json, to_dict, output_to_json -from autonerves.fitsable import ( - output_to_fits, - hdu_list_for_output_from, - ndarray_via_fits_from, - ndarray_via_hdu_from, - header_obj_from, -) -from autonerves.test_mode import ( - with_test_mode_segment, - skip_visualization, - skip_fit_output, - skip_checks, - is_test_mode, - test_mode_level, -) +from autonerves import jax_wrapper +from autonerves.dictable import register_parser +from . import conf + +conf.instance.register(__file__) + +import abc +import pickle +from dill import register + +from . import exc +from . import mock as m +from .non_linear.grid.grid_search import GridSearch as SearchGridSearch +from .aggregator.base import AggBase +from .database.aggregator.aggregator import GridSearchAggregator +from .graphical.expectation_propagation.history import EPHistory +from .graphical.declarative.factor.analysis import AnalysisFactor +from .graphical.declarative.factor.analysis import EPAnalysisFactor +from .graphical.declarative.collection import FactorGraphModel +from .graphical.declarative.factor.hierarchical import HierarchicalFactor +from .graphical.laplace import LaplaceOptimiser +from .non_linear.grid.grid_list import GridList +from .non_linear.samples.summary import SamplesSummary +from .non_linear.samples import SamplesMCMC +from .non_linear.samples import SamplesNest +from .non_linear.samples import Samples +from .non_linear.samples import SamplesPDF +from .non_linear.samples import Sample +from .non_linear.samples import load_from_table +from .non_linear.samples import SamplesStored +from .database.aggregator import Aggregator +from .aggregator.summary.aggregate_csv import AggregateCSV +from .aggregator.summary.aggregate_csv import ValueType +from .aggregator.summary.aggregate_images import AggregateImages +from .aggregator.summary.aggregate_fits import AggregateFITS +from .database.aggregator import Query +from autofit.aggregator.fit_interface import Fit +from .aggregator.search_output import SearchOutput +from .mapper import prior +from .mapper.model import AbstractModel +from .mapper.model import ModelInstance +from .mapper.model import ModelInstance as Instance +from .mapper.model import path_instances_of_class +from .mapper.model_mapper import ModelMapper +from .mapper.model_mapper import ModelMapper as Mapper +from .mapper.model_object import ModelObject +from .mapper.operator import DiagonalMatrix +from .mapper.prior.constant import Constant +from .mapper.prior.arithmetic.assertion import ComparisonAssertion +from .mapper.prior.arithmetic.assertion import ComparisonAssertion +from .mapper.prior.arithmetic.assertion import GreaterThanLessThanAssertion +from .mapper.prior.arithmetic.assertion import GreaterThanLessThanEqualAssertion +from .mapper.prior.deferred import DeferredArgument +from .mapper.prior.deferred import DeferredInstance +from .mapper.prior.width_modifier import AbsoluteWidthModifier +from .mapper.prior.width_modifier import RelativeWidthModifier +from .mapper.prior.width_modifier import WidthModifier +from .mapper.prior import GaussianPrior +from .mapper.prior import LogGaussianPrior +from .mapper.prior import LogUniformPrior +from .mapper.prior import TruncatedGaussianPrior +from .mapper.prior.vectorized import PriorVectorized +from .mapper.prior.abstract import Prior +from .mapper.prior.tuple_prior import TuplePrior +from .mapper.prior import UniformPrior +from .mapper.prior_model.abstract import AbstractPriorModel +from .mapper.prior_model.annotation import AnnotationPriorModel +from .mapper.prior_model.collection import Collection +from .mapper.prior_model.prior_model import Model +from .mapper.prior_model.array import Array +from .non_linear.search.abstract_search import NonLinearSearch +from .non_linear.analysis.visualize import Visualizer +from .non_linear.analysis.latent import Latent +from .non_linear.analysis.analysis import Analysis +from .non_linear.grid.grid_search import GridSearchResult +from .non_linear.grid.sensitivity import Sensitivity +from .non_linear.initializer import InitializerBall +from .non_linear.initializer import InitializerPrior +from .non_linear.initializer import InitializerParamBounds +from .non_linear.initializer import InitializerParamStartPoints +from .non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings +from .non_linear.search.mcmc.blackjax.nuts.search import BlackJAXNUTS +from .non_linear.search.mcmc.emcee.search import Emcee +from .non_linear.search.mcmc.zeus.search import Zeus +from .non_linear.search.nest.nautilus.search import Nautilus +from .non_linear.search.nest.dynesty.search.dynamic import DynestyDynamic +from .non_linear.search.nest.dynesty.search.static import DynestyStatic +from .non_linear.search.mle.drawer.search import Drawer +from .non_linear.search.mle.bfgs.search import BFGS +from .non_linear.search.mle.bfgs.search import LBFGS +from .non_linear.search.mle.multi_start_gradient.search import MultiStartAdam +from .non_linear.search.mle.multi_start_gradient.search import MultiStartADABelief +from .non_linear.search.mle.multi_start_gradient.search import MultiStartLion +from .non_linear.search.mle.multi_start_gradient.search import MultiStartProdigy +from .non_linear.search.mle.multi_start_gradient.convergence import ( + MultiStartGradientConvergence, +) +from .non_linear.paths.abstract import AbstractPaths +from .non_linear.paths import DirectoryPaths +from .non_linear.paths import DatabasePaths +from .non_linear.result import Result +from .non_linear.result import ResultsCollection +from .non_linear.settings import SettingsSearch +from .non_linear.samples.pdf import marginalize +from .text import formatter +from .text import samples_text +from .visualise import VisualiseGraph +from .interpolator import ( + LinearInterpolator, + SplineInterpolator, + CovarianceInterpolator, + LinearRelationship, +) +from .tools import util + +from autofit.mapper.prior.arithmetic.compound import SumPrior as Add +from autofit.mapper.prior.arithmetic.compound import MultiplePrior as Multiply +from autofit.mapper.prior.arithmetic.compound import DivisionPrior as Divide +from autofit.mapper.prior.arithmetic.compound import ModPrior as Mod +from autofit.mapper.prior.arithmetic.compound import PowerPrior as Power +from autofit.mapper.prior.arithmetic.compound import AbsolutePrior as Abs +from autofit.mapper.prior.arithmetic.compound import Log +from autofit.mapper.prior.arithmetic.compound import Log10 + +from . import example as ex +from . import database as db + + +for type_ in ( + "model", + "collection", + "tuple_prior", + "dict", + "instance", + "Uniform", + "LogUniform", + "Gaussian", + "LogGaussian", + "TruncatedGaussian", + "compound", + "Constant", +): + register_parser(type_, ModelObject.from_dict) + + +@register(abc.ABCMeta) +def save_abc(pickler, obj): + pickle._Pickler.save_type(pickler, obj) + + +__version__ = "2026.7.23.1" + +from autonerves import check_version + +check_version(__version__) + +# --------------------------------------------------------------------------- +# Public re-export of the autonerves configuration / serialization surface. +# +# Workspaces, tutorials and downstream code import these names from the science +# library (e.g. ``from autolens import conf``) rather than depending on the +# ``autonerves`` package directly, so the underlying configuration / serialization +# layer stays an implementation detail of the library. +# --------------------------------------------------------------------------- +# ``conf`` is already exported above (``from . import conf``); the names below +# complete the surface. +from autonerves import jax_wrapper +from autonerves import fitsable +from autonerves import setup_colab +from autonerves import setup_notebook +from autonerves.conf import with_config +from autonerves.dictable import from_dict, from_json, to_dict, output_to_json +from autonerves.fitsable import ( + output_to_fits, + hdu_list_for_output_from, + ndarray_via_fits_from, + ndarray_via_hdu_from, + header_obj_from, +) +from autonerves.test_mode import ( + with_test_mode_segment, + skip_visualization, + skip_fit_output, + skip_checks, + is_test_mode, + test_mode_level, +) diff --git a/autofit/aggregator/__init__.py b/autofit/aggregator/__init__.py index 88ef4fc78..fd98a8acc 100644 --- a/autofit/aggregator/__init__.py +++ b/autofit/aggregator/__init__.py @@ -1,6 +1,6 @@ -from .aggregator import * - - -class PickledChild: - def __init__(self, age): - self.age = age +from .aggregator import * + + +class PickledChild: + def __init__(self, age): + self.age = age diff --git a/autofit/aggregator/base.py b/autofit/aggregator/base.py index fcffaf325..a1214ca97 100644 --- a/autofit/aggregator/base.py +++ b/autofit/aggregator/base.py @@ -1,171 +1,171 @@ -from __future__ import annotations -from abc import ABC, abstractmethod -from functools import partial -from typing import List, Optional, Generator - -import autofit as af - - -class AggBase(ABC): - def __init__(self, aggregator: af.Aggregator): - """ - Base aggregator wrapper, which makes it straight forward to compute generators of instances of objects from - specific samples of a non-linear search. - - The stadard aggregator makes it straight forward to create instances from the model. However, if there - are other classes which are generated from the model. but not part of the model itself, creating - instances of them with samples from the non-linear, via a generator, requires manual code to be written. - - The base aggregator can be used to streamline this process and create a concise API for generating these - instances. - - This is achieved by overwriting the `object_via_gen_from` method, which is used to create the object from - the non-linear search samples. This method is then used to create generators of the object from the - non-linear search samples. - - Parameters - ---------- - aggregator - An PyAutoFit aggregator containing the results of non-linear searches performed by PyAutoFit. - """ - self.aggregator = aggregator - - @abstractmethod - def object_via_gen_from( - self, fit: af.Fit, instance: Optional[af.ModelInstance] = None - ) -> object: - """ - For example, in the `GalaxiesAgg` object, this function is overwritten such that it creates a `Plane` from a - `ModelInstance` that contains the galaxies of a sample from a non-linear search. - - Parameters - ---------- - fit - A PyAutoFit database Fit object containing the generators of the results of PyAutoGalaxy model-fits. - instance - A manual instance that overwrites the max log likelihood instance in fit (e.g. for drawing the instance - randomly from the PDF). - - Returns - ------- - Generator - A generator that creates an object used in the model-fitting process of a non-linear search. - """ - - def max_log_likelihood_gen_from(self) -> Generator: - """ - Returns a generator using the maximum likelihood instance of a non-linear search. - - This generator creates a list containing the maximum log instance of every result loaded in the aggregator. - - For example, in **PyAutoLens**, by overwriting the `make_gen_from` method this returns a generator - of `Plane` objects from a PyAutoFit aggregator. This generator then generates a list of the maximum log - likelihood `Plane` objects for all aggregator results. - """ - - def func_gen(fit: af.Fit) -> Generator: - return self.object_via_gen_from(fit=fit) - - return self.aggregator.map(func=func_gen) - - def weights_above_gen_from(self, minimum_weight: float) -> List: - """ - Returns a list of all weights above a minimum weight for every result. - - Parameters - ---------- - minimum_weight - The minimum weight of a non-linear sample, such that samples with a weight below this value are discarded - and not included in the generator. - """ - - def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]: - samples = fit.samples - - weight_list = [] - - for sample in samples.sample_list: - if sample.weight > minimum_weight: - weight_list.append(sample.weight) - - return weight_list - - func = partial(func_gen, minimum_weight=minimum_weight) - - return self.aggregator.map(func=func) - - def all_above_weight_gen_from(self, minimum_weight: float) -> Generator: - """ - Returns a generator which for every result generates a list of objects whose parameter values are all those - in the non-linear search with a weight about an input `minimum_weight` value. This enables straight forward - error estimation. - - This generator creates lists containing instances whose non-linear sample weight are above the value of - `minimum_weight`. For example, if the aggregator contains 10 results and each result has 100 samples above the - `minimum_weight`, a list of 10 entries will be returned, where each entry in this list contains 100 object's - paired with each non-linear sample. - - For example, in **PyAutoLens**, by overwriting the `make_gen_from` method this returns a generator - of `Plane` objects from a PyAutoFit aggregator. This generator then generates lists of `Plane` objects - corresponding to all non-linear search samples above the `minimum_weight`. - - Parameters - ---------- - minimum_weight - The minimum weight of a non-linear sample, such that samples with a weight below this value are discarded - and not included in the generator. - """ - - def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]: - samples = fit.samples - - all_above_weight_list = [] - - for sample in samples.sample_list: - if sample.weight > minimum_weight: - instance = sample.instance_for_model(model=samples.model) - - all_above_weight_list.append( - self.object_via_gen_from(fit=fit, instance=instance) - ) - - return all_above_weight_list - - func = partial(func_gen, minimum_weight=minimum_weight) - - return self.aggregator.map(func=func) - - def randomly_drawn_via_pdf_gen_from(self, total_samples: int): - """ - Returns a generator which for every result generates a list of objects whose parameter values are drawn - randomly from the PDF. This enables straight forward error estimation. - - This generator creates lists containing instances that are drawn randomly from the PDF for every result loaded - in the aggregator. For example, the aggregator contains 10 results and if `total_samples=100`, a list of 10 - entries will be returned, where each entry in this list contains 100 object's paired with non-linear samples - randomly drawn from the PDF. - - For example, in **PyAutoLens**, by overwriting the `make_gen_from` method this returns a generator - of `Plane` objects from a PyAutoFit aggregator. This generator then generates lists of `Plane` objects - corresponding to non-linear search samples randomly drawn from the PDF. - - Parameters - ---------- - total_samples - The total number of non-linear search samples that should be randomly drawn from the PDF. - """ - - def func_gen(fit: af.Fit, total_samples: int) -> List[object]: - samples = fit.samples - - return [ - self.object_via_gen_from( - fit=fit, - instance=samples.draw_randomly_via_pdf(), - ) - for i in range(total_samples) - ] - - func = partial(func_gen, total_samples=total_samples) - - return self.aggregator.map(func=func) +from __future__ import annotations +from abc import ABC, abstractmethod +from functools import partial +from typing import List, Optional, Generator + +import autofit as af + + +class AggBase(ABC): + def __init__(self, aggregator: af.Aggregator): + """ + Base aggregator wrapper, which makes it straight forward to compute generators of instances of objects from + specific samples of a non-linear search. + + The stadard aggregator makes it straight forward to create instances from the model. However, if there + are other classes which are generated from the model. but not part of the model itself, creating + instances of them with samples from the non-linear, via a generator, requires manual code to be written. + + The base aggregator can be used to streamline this process and create a concise API for generating these + instances. + + This is achieved by overwriting the `object_via_gen_from` method, which is used to create the object from + the non-linear search samples. This method is then used to create generators of the object from the + non-linear search samples. + + Parameters + ---------- + aggregator + An PyAutoFit aggregator containing the results of non-linear searches performed by PyAutoFit. + """ + self.aggregator = aggregator + + @abstractmethod + def object_via_gen_from( + self, fit: af.Fit, instance: Optional[af.ModelInstance] = None + ) -> object: + """ + For example, in the `GalaxiesAgg` object, this function is overwritten such that it creates a `Plane` from a + `ModelInstance` that contains the galaxies of a sample from a non-linear search. + + Parameters + ---------- + fit + A PyAutoFit database Fit object containing the generators of the results of PyAutoGalaxy model-fits. + instance + A manual instance that overwrites the max log likelihood instance in fit (e.g. for drawing the instance + randomly from the PDF). + + Returns + ------- + Generator + A generator that creates an object used in the model-fitting process of a non-linear search. + """ + + def max_log_likelihood_gen_from(self) -> Generator: + """ + Returns a generator using the maximum likelihood instance of a non-linear search. + + This generator creates a list containing the maximum log instance of every result loaded in the aggregator. + + For example, in **PyAutoLens**, by overwriting the `make_gen_from` method this returns a generator + of `Plane` objects from a PyAutoFit aggregator. This generator then generates a list of the maximum log + likelihood `Plane` objects for all aggregator results. + """ + + def func_gen(fit: af.Fit) -> Generator: + return self.object_via_gen_from(fit=fit) + + return self.aggregator.map(func=func_gen) + + def weights_above_gen_from(self, minimum_weight: float) -> List: + """ + Returns a list of all weights above a minimum weight for every result. + + Parameters + ---------- + minimum_weight + The minimum weight of a non-linear sample, such that samples with a weight below this value are discarded + and not included in the generator. + """ + + def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]: + samples = fit.samples + + weight_list = [] + + for sample in samples.sample_list: + if sample.weight > minimum_weight: + weight_list.append(sample.weight) + + return weight_list + + func = partial(func_gen, minimum_weight=minimum_weight) + + return self.aggregator.map(func=func) + + def all_above_weight_gen_from(self, minimum_weight: float) -> Generator: + """ + Returns a generator which for every result generates a list of objects whose parameter values are all those + in the non-linear search with a weight about an input `minimum_weight` value. This enables straight forward + error estimation. + + This generator creates lists containing instances whose non-linear sample weight are above the value of + `minimum_weight`. For example, if the aggregator contains 10 results and each result has 100 samples above the + `minimum_weight`, a list of 10 entries will be returned, where each entry in this list contains 100 object's + paired with each non-linear sample. + + For example, in **PyAutoLens**, by overwriting the `make_gen_from` method this returns a generator + of `Plane` objects from a PyAutoFit aggregator. This generator then generates lists of `Plane` objects + corresponding to all non-linear search samples above the `minimum_weight`. + + Parameters + ---------- + minimum_weight + The minimum weight of a non-linear sample, such that samples with a weight below this value are discarded + and not included in the generator. + """ + + def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]: + samples = fit.samples + + all_above_weight_list = [] + + for sample in samples.sample_list: + if sample.weight > minimum_weight: + instance = sample.instance_for_model(model=samples.model) + + all_above_weight_list.append( + self.object_via_gen_from(fit=fit, instance=instance) + ) + + return all_above_weight_list + + func = partial(func_gen, minimum_weight=minimum_weight) + + return self.aggregator.map(func=func) + + def randomly_drawn_via_pdf_gen_from(self, total_samples: int): + """ + Returns a generator which for every result generates a list of objects whose parameter values are drawn + randomly from the PDF. This enables straight forward error estimation. + + This generator creates lists containing instances that are drawn randomly from the PDF for every result loaded + in the aggregator. For example, the aggregator contains 10 results and if `total_samples=100`, a list of 10 + entries will be returned, where each entry in this list contains 100 object's paired with non-linear samples + randomly drawn from the PDF. + + For example, in **PyAutoLens**, by overwriting the `make_gen_from` method this returns a generator + of `Plane` objects from a PyAutoFit aggregator. This generator then generates lists of `Plane` objects + corresponding to non-linear search samples randomly drawn from the PDF. + + Parameters + ---------- + total_samples + The total number of non-linear search samples that should be randomly drawn from the PDF. + """ + + def func_gen(fit: af.Fit, total_samples: int) -> List[object]: + samples = fit.samples + + return [ + self.object_via_gen_from( + fit=fit, + instance=samples.draw_randomly_via_pdf(), + ) + for i in range(total_samples) + ] + + func = partial(func_gen, total_samples=total_samples) + + return self.aggregator.map(func=func) diff --git a/autofit/aggregator/predicate.py b/autofit/aggregator/predicate.py index 2d2c73bd6..d691f6ed3 100644 --- a/autofit/aggregator/predicate.py +++ b/autofit/aggregator/predicate.py @@ -1,373 +1,373 @@ -from abc import ABC, abstractmethod -from typing import List, Iterator - -from .search_output import SearchOutput - - -class AttributePredicate: - def __init__(self, *path): - """ - Used to produce predicate objects for filtering in the aggregator. - - When an unrecognised attribute is called on an aggregator an instance - of this object is created. This object implements comparison methods - facilitating construction of predicates. - - Parameters - ---------- - path - A series of names of attributes that can be used to get a value. - For example, (mask, pixel_size) would get the pixel size of a mask - when evaluated for a given search. - """ - self.path = path - - def value_for_search_output( - self, - search_output: SearchOutput - ): - """ - Recurse the search output by iterating the attributes in the path - and getting a value for each attribute. - """ - value = search_output - for attribute in self.path: - value = getattr( - value, - attribute - ) - return value - - def __eq__(self, value): - """ - Returns a predicate which asks whether the given value is equal to - the attribute of a search. - """ - return EqualityPredicate( - self, - value - ) - - def __le__(self, other): - return OrPredicate( - self == other, - self < other - ) - - def __ge__(self, other): - return OrPredicate( - self == other, - self > other - ) - - def __getattr__(self, item: str) -> "AttributePredicate": - """ - Adds another item to the path - """ - return AttributePredicate( - *self.path, item - ) - - def __gt__(self, other): - """ - Is the value of this attribute for a given search greater than some - other value? - """ - return GreaterThanPredicate( - self, other - ) - - def __lt__(self, other): - """ - Is the value of this attribute for a given search less than some - other value? - """ - return LessThanPredicate( - self, other - ) - - def __ne__(self, other): - """ - Returns a predicate which asks whether the given value is not equal to - the attribute of a search. - """ - return ~(self == other) - - def contains(self, value): - """ - Returns a predicate which asks whether the given is contained within - the attribute of a search. - """ - return ContainsPredicate( - self, - value - ) - - -class AbstractPredicate(ABC): - """ - Comparison between a value and some attribute of a search - """ - - def filter( - self, - search_outputs: List[SearchOutput] - ) -> Iterator[SearchOutput]: - """ - Only return searchs for which this predicate evaluates to True - - Parameters - ---------- - search_outputs - - Returns - ------- - - """ - return filter( - lambda search_output: self(search_output), - search_outputs - ) - - def __invert__(self) -> "NotPredicate": - """ - A predicate that evaluates to `True` when this predicate evaluates - to False - """ - return NotPredicate( - self - ) - - def __or__(self, other: "AbstractPredicate") -> "OrPredicate": - """ - Returns a predicate that is true if either predicate is true - for a given search. - """ - return OrPredicate(self, other) - - def __and__(self, other: "AbstractPredicate") -> "AndPredicate": - """ - Returns a predicate that is true if both predicates are true - for a given search. - """ - return AndPredicate(self, other) - - @abstractmethod - def __call__(self, search_output: SearchOutput) -> bool: - """ - Does the attribute of the search match the requirement of this predicate? - """ - - -class CombinationPredicate(AbstractPredicate, ABC): - def __init__( - self, - one: AbstractPredicate, - two: AbstractPredicate - ): - """ - Abstract predicate combining two other predicates. - - Parameters - ---------- - one - two - Child predicates - """ - self.one = one - self.two = two - - -class OrPredicate(CombinationPredicate): - def __call__(self, search_output: SearchOutput): - """ - The disjunction of two predicates. - - Parameters - ---------- - search_output - An object representing the output of a given search. - - Returns - ------- - True if either predicate is `True` for the search - """ - return self.one(search_output) or self.two(search_output) - - -class AndPredicate(CombinationPredicate): - def __call__(self, search_output: SearchOutput): - """ - The conjunction of two predicates. - - Parameters - ---------- - search_output - An object representing the output of a given search. - - Returns - ------- - True if both predicates are `True` for the search - """ - return self.one(search_output) and self.two(search_output) - - -class ComparisonPredicate(AbstractPredicate, ABC): - def __init__( - self, - attribute_predicate: AttributePredicate, - value - ): - """ - Compare an attribute of a search with a value. - - Parameters - ---------- - attribute_predicate - An attribute path of a search - value - A value to which the attribute is compared - """ - self.attribute_predicate = attribute_predicate - self._value = value - - def value( - self, - search_output - ): - if isinstance(self._value, AttributePredicate): - return self._value.value_for_search_output( - search_output - ) - return self._value - - -class GreaterThanPredicate(ComparisonPredicate): - def __call__( - self, - search_output: SearchOutput - ) -> bool: - """ - Parameters - ---------- - search_output - An object representing the output of a given search. - - Returns - ------- - True iff the value of the attribute of the search is greater than - the value associated with this predicate - """ - - return self.attribute_predicate.value_for_search_output( - search_output - ) > self.value( - search_output - ) - - -class LessThanPredicate(ComparisonPredicate): - def __call__( - self, - search_output: SearchOutput - ) -> bool: - """ - Parameters - ---------- - search_output - An object representing the output of a given search. - - Returns - ------- - True iff the value of the attribute of the search is less than - the value associated with this predicate - """ - return self.attribute_predicate.value_for_search_output( - search_output - ) < self.value( - search_output - ) - - -class ContainsPredicate(ComparisonPredicate): - def __call__( - self, - search_output: SearchOutput - ) -> bool: - """ - Parameters - ---------- - search_output - An object representing the output of a given search. - - Returns - ------- - True iff the value of the attribute of the search contains - the value associated with this predicate - """ - return self.value( - search_output - ) in self.attribute_predicate.value_for_search_output( - search_output - ) - - -class EqualityPredicate(ComparisonPredicate): - def __call__(self, search_output): - """ - Parameters - ---------- - search_output - An object representing the output of a given search. - - Returns - ------- - True iff the value of the attribute of the search is equal to - the value associated with this predicate - """ - try: - value = self.value( - search_output - ) - except AttributeError: - value = self.value - return self.attribute_predicate.value_for_search_output( - search_output - ) == value - - -class NotPredicate(AbstractPredicate): - def __init__( - self, - predicate: AbstractPredicate - ): - """ - Negates the output of a predicate. - - If the predicate would have returned `True` for a given search - it now returns `False` and vice-versa. - - Parameters - ---------- - predicate - A predicate that is negated - """ - self.predicate = predicate - - def __call__(self, search_output: SearchOutput) -> bool: - """ - Evaluate the predicate for the search and return the negation - of the result. - - Parameters - ---------- - search_output - The output of an AutoFit search - - Returns - ------- - The negation of the underlying predicate - """ - return not self.predicate( - search_output - ) +from abc import ABC, abstractmethod +from typing import List, Iterator + +from .search_output import SearchOutput + + +class AttributePredicate: + def __init__(self, *path): + """ + Used to produce predicate objects for filtering in the aggregator. + + When an unrecognised attribute is called on an aggregator an instance + of this object is created. This object implements comparison methods + facilitating construction of predicates. + + Parameters + ---------- + path + A series of names of attributes that can be used to get a value. + For example, (mask, pixel_size) would get the pixel size of a mask + when evaluated for a given search. + """ + self.path = path + + def value_for_search_output( + self, + search_output: SearchOutput + ): + """ + Recurse the search output by iterating the attributes in the path + and getting a value for each attribute. + """ + value = search_output + for attribute in self.path: + value = getattr( + value, + attribute + ) + return value + + def __eq__(self, value): + """ + Returns a predicate which asks whether the given value is equal to + the attribute of a search. + """ + return EqualityPredicate( + self, + value + ) + + def __le__(self, other): + return OrPredicate( + self == other, + self < other + ) + + def __ge__(self, other): + return OrPredicate( + self == other, + self > other + ) + + def __getattr__(self, item: str) -> "AttributePredicate": + """ + Adds another item to the path + """ + return AttributePredicate( + *self.path, item + ) + + def __gt__(self, other): + """ + Is the value of this attribute for a given search greater than some + other value? + """ + return GreaterThanPredicate( + self, other + ) + + def __lt__(self, other): + """ + Is the value of this attribute for a given search less than some + other value? + """ + return LessThanPredicate( + self, other + ) + + def __ne__(self, other): + """ + Returns a predicate which asks whether the given value is not equal to + the attribute of a search. + """ + return ~(self == other) + + def contains(self, value): + """ + Returns a predicate which asks whether the given is contained within + the attribute of a search. + """ + return ContainsPredicate( + self, + value + ) + + +class AbstractPredicate(ABC): + """ + Comparison between a value and some attribute of a search + """ + + def filter( + self, + search_outputs: List[SearchOutput] + ) -> Iterator[SearchOutput]: + """ + Only return searchs for which this predicate evaluates to True + + Parameters + ---------- + search_outputs + + Returns + ------- + + """ + return filter( + lambda search_output: self(search_output), + search_outputs + ) + + def __invert__(self) -> "NotPredicate": + """ + A predicate that evaluates to `True` when this predicate evaluates + to False + """ + return NotPredicate( + self + ) + + def __or__(self, other: "AbstractPredicate") -> "OrPredicate": + """ + Returns a predicate that is true if either predicate is true + for a given search. + """ + return OrPredicate(self, other) + + def __and__(self, other: "AbstractPredicate") -> "AndPredicate": + """ + Returns a predicate that is true if both predicates are true + for a given search. + """ + return AndPredicate(self, other) + + @abstractmethod + def __call__(self, search_output: SearchOutput) -> bool: + """ + Does the attribute of the search match the requirement of this predicate? + """ + + +class CombinationPredicate(AbstractPredicate, ABC): + def __init__( + self, + one: AbstractPredicate, + two: AbstractPredicate + ): + """ + Abstract predicate combining two other predicates. + + Parameters + ---------- + one + two + Child predicates + """ + self.one = one + self.two = two + + +class OrPredicate(CombinationPredicate): + def __call__(self, search_output: SearchOutput): + """ + The disjunction of two predicates. + + Parameters + ---------- + search_output + An object representing the output of a given search. + + Returns + ------- + True if either predicate is `True` for the search + """ + return self.one(search_output) or self.two(search_output) + + +class AndPredicate(CombinationPredicate): + def __call__(self, search_output: SearchOutput): + """ + The conjunction of two predicates. + + Parameters + ---------- + search_output + An object representing the output of a given search. + + Returns + ------- + True if both predicates are `True` for the search + """ + return self.one(search_output) and self.two(search_output) + + +class ComparisonPredicate(AbstractPredicate, ABC): + def __init__( + self, + attribute_predicate: AttributePredicate, + value + ): + """ + Compare an attribute of a search with a value. + + Parameters + ---------- + attribute_predicate + An attribute path of a search + value + A value to which the attribute is compared + """ + self.attribute_predicate = attribute_predicate + self._value = value + + def value( + self, + search_output + ): + if isinstance(self._value, AttributePredicate): + return self._value.value_for_search_output( + search_output + ) + return self._value + + +class GreaterThanPredicate(ComparisonPredicate): + def __call__( + self, + search_output: SearchOutput + ) -> bool: + """ + Parameters + ---------- + search_output + An object representing the output of a given search. + + Returns + ------- + True iff the value of the attribute of the search is greater than + the value associated with this predicate + """ + + return self.attribute_predicate.value_for_search_output( + search_output + ) > self.value( + search_output + ) + + +class LessThanPredicate(ComparisonPredicate): + def __call__( + self, + search_output: SearchOutput + ) -> bool: + """ + Parameters + ---------- + search_output + An object representing the output of a given search. + + Returns + ------- + True iff the value of the attribute of the search is less than + the value associated with this predicate + """ + return self.attribute_predicate.value_for_search_output( + search_output + ) < self.value( + search_output + ) + + +class ContainsPredicate(ComparisonPredicate): + def __call__( + self, + search_output: SearchOutput + ) -> bool: + """ + Parameters + ---------- + search_output + An object representing the output of a given search. + + Returns + ------- + True iff the value of the attribute of the search contains + the value associated with this predicate + """ + return self.value( + search_output + ) in self.attribute_predicate.value_for_search_output( + search_output + ) + + +class EqualityPredicate(ComparisonPredicate): + def __call__(self, search_output): + """ + Parameters + ---------- + search_output + An object representing the output of a given search. + + Returns + ------- + True iff the value of the attribute of the search is equal to + the value associated with this predicate + """ + try: + value = self.value( + search_output + ) + except AttributeError: + value = self.value + return self.attribute_predicate.value_for_search_output( + search_output + ) == value + + +class NotPredicate(AbstractPredicate): + def __init__( + self, + predicate: AbstractPredicate + ): + """ + Negates the output of a predicate. + + If the predicate would have returned `True` for a given search + it now returns `False` and vice-versa. + + Parameters + ---------- + predicate + A predicate that is negated + """ + self.predicate = predicate + + def __call__(self, search_output: SearchOutput) -> bool: + """ + Evaluate the predicate for the search and return the negation + of the result. + + Parameters + ---------- + search_output + The output of an AutoFit search + + Returns + ------- + The negation of the underlying predicate + """ + return not self.predicate( + search_output + ) diff --git a/autofit/aggregator/search_output.py b/autofit/aggregator/search_output.py index d91aacab9..09c5888cf 100644 --- a/autofit/aggregator/search_output.py +++ b/autofit/aggregator/search_output.py @@ -1,484 +1,484 @@ -import csv -import json -import logging -import pickle -from abc import ABC -from pathlib import Path -from typing import Generator, Tuple, Optional, List, cast, Type - -import dill -from PIL import Image - -from autonerves import cached_property -from autonerves.class_path import get_class -from autofit.non_linear.samples import Samples - -from autofit.non_linear.samples.pdf import SamplesPDF -from autofit.aggregator.file_output import ( - JSONOutput, - FileOutput, -) -from autofit.mapper.identifier import Identifier -from autofit.non_linear.samples.sample import samples_from_iterator -from autonerves.dictable import from_dict -from autofit.non_linear.samples.summary import SamplesSummary -from autofit.non_linear.samples.util import simple_model_for_kwargs -from . import fit_interface - -# noinspection PyProtectedMember -original_create_file_handle = dill._dill._create_filehandle - - -def _create_file_handle(*args, **kwargs): - """ - Handle FileNotFoundError when attempting to deserialize pickles - using dill and return None instead. - """ - try: - return original_create_file_handle(*args, **kwargs) - except pickle.UnpicklingError as e: - if not isinstance(e.args[0], FileNotFoundError): - raise e - logging.warning( - f"Could not create a handler for {e.args[0].filename} as it does not exist" - ) - return None - - -dill._dill._create_filehandle = _create_file_handle - - -class AbstractSearchOutput(ABC): - def __init__(self, directory: Path, reference: Optional[dict] = None): - self.directory = directory - self._reference = reference - - @property - def is_complete(self) -> bool: - """ - Whether the search has completed - """ - return (self.directory / ".completed").exists() - - @property - def parent_identifier(self) -> Optional[str]: - """ - Read the parent identifier for a fit in a directory. - - Defaults to None if no .parent_identifier file is found. - """ - try: - return (self.directory / ".parent_identifier").read_text() - except FileNotFoundError: - return None - - @property - def files_path(self): - return self.directory / "files" - - @cached_property - def _outputs_by_suffix(self) -> dict: - """ - All output files in the files and image directories, grouped by suffix. - - A single traversal of each directory serves every suffix — building the - json/pickle/csv/fits lists separately makes eight sweeps per search output, - which dominates load time when aggregating many results. - """ - outputs = {".json": [], ".pickle": [], ".csv": [], ".fits": []} - for directory_name in ("files", "image"): - files_path = self.directory / directory_name - for file_path in files_path.rglob("*"): - if file_path.suffix in outputs: - name = ".".join( - file_path.relative_to(files_path).with_suffix("").parts - ) - outputs[file_path.suffix].append(FileOutput(name, file_path)) - return outputs - - @cached_property - def jsons(self) -> List[JSONOutput]: - """ - The json files in the search output files directory - """ - return cast(List[JSONOutput], self._outputs_by_suffix[".json"]) - - @cached_property - def arrays(self): - """ - The csv files in the search output files directory - """ - return self._outputs_by_suffix[".csv"] - - @cached_property - def pickles(self): - """ - The pickle files in the search output files directory - """ - return self._outputs_by_suffix[".pickle"] - - @cached_property - def fits(self): - """ - The fits files in the search output files directory - """ - return self._outputs_by_suffix[".fits"] - - @property - def max_log_likelihood(self) -> Optional[float]: - """ - The log likelihood of the maximum log likelihood sample - """ - try: - return self.samples.max_log_likelihood_sample.log_likelihood - except AttributeError: - return None - - def __getattr__(self, name): - """ - Attempt to load a pickle by the same name from the search output directory. - - dataset.pickle, meta_dataset.pickle etc. - """ - return self.value(name) - - def image(self, name: str) -> Image.Image: - """ - Load an image from the files directory for the search. - - Parameters - ---------- - name - The name of the file to load without a file suffix. - - Returns - ------- - The loaded image - """ - return Image.open(self.directory / "image" / f"{name}.png") - - def value(self, name: str): - """ - Load the value of some object in the files directory for the search. - - This may be a pickle, json, csv or fits file. - - If the JSON has a specified type it is parsed as that type. See dictable.py - in autonerves. - - Returns None if the file does not exist. - - Parameters - ---------- - name - The name of the file to load without a file suffix. - - Returns - ------- - The loaded object - """ - for item in self.jsons: - if item.name == name: - return item.value_using_reference(self._reference) - for item in self.pickles + self.arrays + self.fits: - if item.name == name: - return item.value - - return None - - -class SearchOutput(AbstractSearchOutput, fit_interface.Fit): - """ - @DynamicAttrs - """ - - is_grid_search = False - - def __init__(self, directory: Path, reference: dict = None): - """ - Represents the output of a single search. Comprises a metadata file and other dataset files. - - Parameters - ---------- - directory - The directory of the search - """ - super().__init__(directory, reference) - self.__search = None - self.__model = None - self._samples = None - self._latent_samples = None - self.__samples_summary = None - self.__latent_summary = None - - self.directory = directory - - self.file_path = directory / "metadata" - - try: - with open(self.file_path) as f: - self.text = f.read() - pairs = [ - line.split("=") for line in self.text.split("\n") if "=" in line - ] - self.__dict__.update({pair[0]: pair[1] for pair in pairs}) - except FileNotFoundError: - pass - - @property - def samples_summary(self) -> SamplesSummary: - """ - The summary of the samples, which includes the maximum log likelihood sample and the log evidence. - - This is loaded from a JSON file. - """ - if self.__samples_summary is None: - summary = self.value("samples_summary") - summary.model = self.model - self.__samples_summary = summary - return self.__samples_summary - - @property - def latent_summary(self) -> SamplesSummary: - """ - The summary of the samples, which includes the maximum log likelihood sample and the log evidence. - - This is loaded from a JSON file. - """ - if self.__latent_summary is None: - summary = self.value("latent.latent_summary") - summary.model = self.model - self.__latent_summary = summary - return self.__latent_summary - - @property - def instance(self): - """ - The instance of the maximum log likelihood sample i.e. the instance - with the greatest likelihood. - - None if samples cannot be loaded. - """ - try: - return self.samples.max_log_likelihood() - except (AttributeError, NotImplementedError): - return self.samples_summary.instance - - @cached_property - def id(self) -> str: - """ - The unique identifier of the search. - - This is used as a directory name and as a database identifier. - """ - return str(Identifier([self.search, self.model, self.unique_tag])) - - @property - def model(self): - """ - The model used by the search - """ - if self.__model is None: - self.__model = self.value("model") - return self.__model - - @property - def samples(self) -> SamplesPDF: - """ - The samples of the search, parsed from a CSV containing individual samples - and a JSON containing metadata. - """ - if not self._samples: - self._samples = self._load_samples( - model=self.model, - ) - return self._samples - - @property - def latent_samples(self): - """ - The latent variables of the search, parsed from a CSV file. - """ - if not self._latent_samples: - self._latent_samples = self._load_samples("latent") - return self._latent_samples - - def _load_samples(self, name=None, model=None): - if name: - directory = self.files_path / name - else: - directory = self.files_path - try: - info_json = JSONOutput("info", directory / "samples_info.json").dict - - with open(directory / "samples.csv") as f: - sample_list = samples_from_iterator(csv.reader(f)) - - if model is None: - try: - model = simple_model_for_kwargs(sample_list[0].kwargs) - except IndexError: - model = None - - cls = cast( - Type[Samples], - get_class(info_json["class_path"]), - ) - - return cls.from_list_info_and_model( - sample_list=sample_list, - samples_info=info_json, - model=model, - ) - except FileNotFoundError: - raise AttributeError(f"No {name} found") - - def names_and_paths( - self, - suffix: str, - ) -> Generator[Tuple[str, Path], None, None]: - """ - Get the names and paths of files with a given suffix. - - Parameters - ---------- - suffix - The suffix of the files to retrieve (e.g. ".json") - - Returns - ------- - A generator of tuples of the form (name, path) where name is the path to the file - joined by . without the suffix and path is the path to the file - """ - for file in list(self.files_path.rglob(f"*{suffix}")): - name = ".".join(file.relative_to(self.files_path).with_suffix("").parts) - yield name, file - - @property - def children(self): - """ - A list of child analyses loaded from the analyses directory - """ - return list(map(SearchOutput, Path(self.directory).glob("analyses/*"))) - - @property - def model_results(self) -> str: - """ - Reads the model.results file - """ - with open(self.directory / "model.results") as f: - return f.read() - - @property - def mask(self): - """ - A pickled mask object - """ - with open(self.files_path / "mask.pickle", "rb") as f: - return dill.load(f) - - @property - def header(self) -> str: - """ - A header created by joining the search name - """ - phase = self.phase or "" - dataset_name = self.dataset_name or "" - return str(Path(phase) / dataset_name) if dataset_name else phase - - @property - def search(self): - """ - The search object that was used in this phase - """ - if self.__search is None: - try: - with open(self.files_path / "search.json") as f: - self.__search = from_dict(json.load(f)) - except (FileNotFoundError, ModuleNotFoundError): - try: - with open(self.files_path / "search.pickle", "rb") as f: - self.__search = pickle.load(f) - except (FileNotFoundError, ModuleNotFoundError): - logging.warning("Could not load search") - return self.__search - - def child_values(self, name): - """ - Get the values of a given key for all children - """ - return [getattr(child, name) for child in self.children] - - @property - def path_prefix(self): - return self.search.paths.path_prefix - - @property - def name(self): - """ - The name of the search - """ - return self.search.name - - @property - def unique_tag(self): - """ - The unique tag of the search - """ - return self.search.unique_tag - - def __str__(self): - return self.text - - def __repr__(self): - return "".format(self) - - -class GridSearchOutput(AbstractSearchOutput): - is_grid_search = True - - @property - def unique_tag(self) -> str: - """ - The unique tag of the grid search. - """ - with open(self.directory / ".is_grid_search") as f: - return f.read() - - @property - def id(self) -> str: - """ - Use the unique tag of the grid search as an identifier. - """ - return self.unique_tag - - -class GridSearch: - def __init__( - self, - grid_search_output: GridSearchOutput, - children: List[SearchOutput], - ): - """ - Represents the output of a grid search. Comprises overall information from the grid search - and output from each individual search. - - Parameters - ---------- - grid_search_output - The output of the grid search - children - The outputs of each individual search performed as part of the grid search - """ - self.grid_search_output = grid_search_output - self.children = children - - @property - def best_fit(self) -> SearchOutput: - """ - The output for the search in the grid search that had the greatest log likelihood - """ - return max(self.children, key=lambda x: x.instance.log_likelihood) - - def __getattr__(self, item): - return getattr(self.grid_search_output, item) +import csv +import json +import logging +import pickle +from abc import ABC +from pathlib import Path +from typing import Generator, Tuple, Optional, List, cast, Type + +import dill +from PIL import Image + +from autonerves import cached_property +from autonerves.class_path import get_class +from autofit.non_linear.samples import Samples + +from autofit.non_linear.samples.pdf import SamplesPDF +from autofit.aggregator.file_output import ( + JSONOutput, + FileOutput, +) +from autofit.mapper.identifier import Identifier +from autofit.non_linear.samples.sample import samples_from_iterator +from autonerves.dictable import from_dict +from autofit.non_linear.samples.summary import SamplesSummary +from autofit.non_linear.samples.util import simple_model_for_kwargs +from . import fit_interface + +# noinspection PyProtectedMember +original_create_file_handle = dill._dill._create_filehandle + + +def _create_file_handle(*args, **kwargs): + """ + Handle FileNotFoundError when attempting to deserialize pickles + using dill and return None instead. + """ + try: + return original_create_file_handle(*args, **kwargs) + except pickle.UnpicklingError as e: + if not isinstance(e.args[0], FileNotFoundError): + raise e + logging.warning( + f"Could not create a handler for {e.args[0].filename} as it does not exist" + ) + return None + + +dill._dill._create_filehandle = _create_file_handle + + +class AbstractSearchOutput(ABC): + def __init__(self, directory: Path, reference: Optional[dict] = None): + self.directory = directory + self._reference = reference + + @property + def is_complete(self) -> bool: + """ + Whether the search has completed + """ + return (self.directory / ".completed").exists() + + @property + def parent_identifier(self) -> Optional[str]: + """ + Read the parent identifier for a fit in a directory. + + Defaults to None if no .parent_identifier file is found. + """ + try: + return (self.directory / ".parent_identifier").read_text() + except FileNotFoundError: + return None + + @property + def files_path(self): + return self.directory / "files" + + @cached_property + def _outputs_by_suffix(self) -> dict: + """ + All output files in the files and image directories, grouped by suffix. + + A single traversal of each directory serves every suffix — building the + json/pickle/csv/fits lists separately makes eight sweeps per search output, + which dominates load time when aggregating many results. + """ + outputs = {".json": [], ".pickle": [], ".csv": [], ".fits": []} + for directory_name in ("files", "image"): + files_path = self.directory / directory_name + for file_path in files_path.rglob("*"): + if file_path.suffix in outputs: + name = ".".join( + file_path.relative_to(files_path).with_suffix("").parts + ) + outputs[file_path.suffix].append(FileOutput(name, file_path)) + return outputs + + @cached_property + def jsons(self) -> List[JSONOutput]: + """ + The json files in the search output files directory + """ + return cast(List[JSONOutput], self._outputs_by_suffix[".json"]) + + @cached_property + def arrays(self): + """ + The csv files in the search output files directory + """ + return self._outputs_by_suffix[".csv"] + + @cached_property + def pickles(self): + """ + The pickle files in the search output files directory + """ + return self._outputs_by_suffix[".pickle"] + + @cached_property + def fits(self): + """ + The fits files in the search output files directory + """ + return self._outputs_by_suffix[".fits"] + + @property + def max_log_likelihood(self) -> Optional[float]: + """ + The log likelihood of the maximum log likelihood sample + """ + try: + return self.samples.max_log_likelihood_sample.log_likelihood + except AttributeError: + return None + + def __getattr__(self, name): + """ + Attempt to load a pickle by the same name from the search output directory. + + dataset.pickle, meta_dataset.pickle etc. + """ + return self.value(name) + + def image(self, name: str) -> Image.Image: + """ + Load an image from the files directory for the search. + + Parameters + ---------- + name + The name of the file to load without a file suffix. + + Returns + ------- + The loaded image + """ + return Image.open(self.directory / "image" / f"{name}.png") + + def value(self, name: str): + """ + Load the value of some object in the files directory for the search. + + This may be a pickle, json, csv or fits file. + + If the JSON has a specified type it is parsed as that type. See dictable.py + in autonerves. + + Returns None if the file does not exist. + + Parameters + ---------- + name + The name of the file to load without a file suffix. + + Returns + ------- + The loaded object + """ + for item in self.jsons: + if item.name == name: + return item.value_using_reference(self._reference) + for item in self.pickles + self.arrays + self.fits: + if item.name == name: + return item.value + + return None + + +class SearchOutput(AbstractSearchOutput, fit_interface.Fit): + """ + @DynamicAttrs + """ + + is_grid_search = False + + def __init__(self, directory: Path, reference: dict = None): + """ + Represents the output of a single search. Comprises a metadata file and other dataset files. + + Parameters + ---------- + directory + The directory of the search + """ + super().__init__(directory, reference) + self.__search = None + self.__model = None + self._samples = None + self._latent_samples = None + self.__samples_summary = None + self.__latent_summary = None + + self.directory = directory + + self.file_path = directory / "metadata" + + try: + with open(self.file_path) as f: + self.text = f.read() + pairs = [ + line.split("=") for line in self.text.split("\n") if "=" in line + ] + self.__dict__.update({pair[0]: pair[1] for pair in pairs}) + except FileNotFoundError: + pass + + @property + def samples_summary(self) -> SamplesSummary: + """ + The summary of the samples, which includes the maximum log likelihood sample and the log evidence. + + This is loaded from a JSON file. + """ + if self.__samples_summary is None: + summary = self.value("samples_summary") + summary.model = self.model + self.__samples_summary = summary + return self.__samples_summary + + @property + def latent_summary(self) -> SamplesSummary: + """ + The summary of the samples, which includes the maximum log likelihood sample and the log evidence. + + This is loaded from a JSON file. + """ + if self.__latent_summary is None: + summary = self.value("latent.latent_summary") + summary.model = self.model + self.__latent_summary = summary + return self.__latent_summary + + @property + def instance(self): + """ + The instance of the maximum log likelihood sample i.e. the instance + with the greatest likelihood. + + None if samples cannot be loaded. + """ + try: + return self.samples.max_log_likelihood() + except (AttributeError, NotImplementedError): + return self.samples_summary.instance + + @cached_property + def id(self) -> str: + """ + The unique identifier of the search. + + This is used as a directory name and as a database identifier. + """ + return str(Identifier([self.search, self.model, self.unique_tag])) + + @property + def model(self): + """ + The model used by the search + """ + if self.__model is None: + self.__model = self.value("model") + return self.__model + + @property + def samples(self) -> SamplesPDF: + """ + The samples of the search, parsed from a CSV containing individual samples + and a JSON containing metadata. + """ + if not self._samples: + self._samples = self._load_samples( + model=self.model, + ) + return self._samples + + @property + def latent_samples(self): + """ + The latent variables of the search, parsed from a CSV file. + """ + if not self._latent_samples: + self._latent_samples = self._load_samples("latent") + return self._latent_samples + + def _load_samples(self, name=None, model=None): + if name: + directory = self.files_path / name + else: + directory = self.files_path + try: + info_json = JSONOutput("info", directory / "samples_info.json").dict + + with open(directory / "samples.csv") as f: + sample_list = samples_from_iterator(csv.reader(f)) + + if model is None: + try: + model = simple_model_for_kwargs(sample_list[0].kwargs) + except IndexError: + model = None + + cls = cast( + Type[Samples], + get_class(info_json["class_path"]), + ) + + return cls.from_list_info_and_model( + sample_list=sample_list, + samples_info=info_json, + model=model, + ) + except FileNotFoundError: + raise AttributeError(f"No {name} found") + + def names_and_paths( + self, + suffix: str, + ) -> Generator[Tuple[str, Path], None, None]: + """ + Get the names and paths of files with a given suffix. + + Parameters + ---------- + suffix + The suffix of the files to retrieve (e.g. ".json") + + Returns + ------- + A generator of tuples of the form (name, path) where name is the path to the file + joined by . without the suffix and path is the path to the file + """ + for file in list(self.files_path.rglob(f"*{suffix}")): + name = ".".join(file.relative_to(self.files_path).with_suffix("").parts) + yield name, file + + @property + def children(self): + """ + A list of child analyses loaded from the analyses directory + """ + return list(map(SearchOutput, Path(self.directory).glob("analyses/*"))) + + @property + def model_results(self) -> str: + """ + Reads the model.results file + """ + with open(self.directory / "model.results") as f: + return f.read() + + @property + def mask(self): + """ + A pickled mask object + """ + with open(self.files_path / "mask.pickle", "rb") as f: + return dill.load(f) + + @property + def header(self) -> str: + """ + A header created by joining the search name + """ + phase = self.phase or "" + dataset_name = self.dataset_name or "" + return str(Path(phase) / dataset_name) if dataset_name else phase + + @property + def search(self): + """ + The search object that was used in this phase + """ + if self.__search is None: + try: + with open(self.files_path / "search.json") as f: + self.__search = from_dict(json.load(f)) + except (FileNotFoundError, ModuleNotFoundError): + try: + with open(self.files_path / "search.pickle", "rb") as f: + self.__search = pickle.load(f) + except (FileNotFoundError, ModuleNotFoundError): + logging.warning("Could not load search") + return self.__search + + def child_values(self, name): + """ + Get the values of a given key for all children + """ + return [getattr(child, name) for child in self.children] + + @property + def path_prefix(self): + return self.search.paths.path_prefix + + @property + def name(self): + """ + The name of the search + """ + return self.search.name + + @property + def unique_tag(self): + """ + The unique tag of the search + """ + return self.search.unique_tag + + def __str__(self): + return self.text + + def __repr__(self): + return "".format(self) + + +class GridSearchOutput(AbstractSearchOutput): + is_grid_search = True + + @property + def unique_tag(self) -> str: + """ + The unique tag of the grid search. + """ + with open(self.directory / ".is_grid_search") as f: + return f.read() + + @property + def id(self) -> str: + """ + Use the unique tag of the grid search as an identifier. + """ + return self.unique_tag + + +class GridSearch: + def __init__( + self, + grid_search_output: GridSearchOutput, + children: List[SearchOutput], + ): + """ + Represents the output of a grid search. Comprises overall information from the grid search + and output from each individual search. + + Parameters + ---------- + grid_search_output + The output of the grid search + children + The outputs of each individual search performed as part of the grid search + """ + self.grid_search_output = grid_search_output + self.children = children + + @property + def best_fit(self) -> SearchOutput: + """ + The output for the search in the grid search that had the greatest log likelihood + """ + return max(self.children, key=lambda x: x.instance.log_likelihood) + + def __getattr__(self, item): + return getattr(self.grid_search_output, item) diff --git a/autofit/config/non_linear/GridSearch.yaml b/autofit/config/non_linear/GridSearch.yaml index cf3f56693..af6daba84 100644 --- a/autofit/config/non_linear/GridSearch.yaml +++ b/autofit/config/non_linear/GridSearch.yaml @@ -1,5 +1,5 @@ -# The settings of a parallelized grid search of non-linear searches. - -parallel: - number_of_cores: 3 # The number of cores the search is parallelized over by default, using Python multiprocessing. +# The settings of a parallelized grid search of non-linear searches. + +parallel: + number_of_cores: 3 # The number of cores the search is parallelized over by default, using Python multiprocessing. step_size: 0.1 # The default step size of each grid search parameter, in terms of unit values of the priors. \ No newline at end of file diff --git a/autofit/config/visualize/general.yaml b/autofit/config/visualize/general.yaml index f23eed209..e8c3a4956 100644 --- a/autofit/config/visualize/general.yaml +++ b/autofit/config/visualize/general.yaml @@ -1,2 +1,2 @@ -general: +general: backend: default # The matploblib backend used for visualization. `default` uses the system default, can specifiy specific backend (e.g. TKAgg, Qt5Agg, WXAgg). \ No newline at end of file diff --git a/autofit/config/visualize/plots_search.yaml b/autofit/config/visualize/plots_search.yaml index f99dc58f2..24f805e1d 100644 --- a/autofit/config/visualize/plots_search.yaml +++ b/autofit/config/visualize/plots_search.yaml @@ -1,8 +1,8 @@ -nest: - corner_anesthetic: true # Output corner figure (using anestetic) during a non-linear search fit? -mcmc: - corner_cornerpy: true # Output corner figure (using corner.py) during a non-linear search fit? -mle: - subplot_parameters: true # Output a subplot of the best-fit parameters of the model? - log_likelihood_vs_iteration: true # Output a plot of the log likelihood versus iteration number? +nest: + corner_anesthetic: true # Output corner figure (using anestetic) during a non-linear search fit? +mcmc: + corner_cornerpy: true # Output corner figure (using corner.py) during a non-linear search fit? +mle: + subplot_parameters: true # Output a subplot of the best-fit parameters of the model? + log_likelihood_vs_iteration: true # Output a plot of the log likelihood versus iteration number? figure_of_merit_vs_iteration: true # Output the global-best figure-of-merit trace (auto-convergence gradient searches)? \ No newline at end of file diff --git a/autofit/config/visualize/plots_settings.yaml b/autofit/config/visualize/plots_settings.yaml index 15d8e8193..63ff3b3cf 100644 --- a/autofit/config/visualize/plots_settings.yaml +++ b/autofit/config/visualize/plots_settings.yaml @@ -1,7 +1,7 @@ -corner_anesthetic: - figsize_per_parammeter: 4 # The figsize of the matplotlib figure is given as the number of parameters times this value, for exmaple with 3 free parameters and a value 4 the figsize will be 3*4 = 12. - fontsize: 20 # The size of the font of the tick values and parameter labels on the x and y axis of the corner plot. - facecolor: white # The facecolor of the corner plot. - alpha: 0.9 # The alpha value of the corner plot. -corner_cornerpy: +corner_anesthetic: + figsize_per_parammeter: 4 # The figsize of the matplotlib figure is given as the number of parameters times this value, for exmaple with 3 free parameters and a value 4 the figsize will be 3*4 = 12. + fontsize: 20 # The size of the font of the tick values and parameter labels on the x and y axis of the corner plot. + facecolor: white # The facecolor of the corner plot. + alpha: 0.9 # The alpha value of the corner plot. +corner_cornerpy: fontsize: 14 # The size of the font of the tick values and parameter labels on the x and y axis of the corner plot. \ No newline at end of file diff --git a/autofit/example/__init__.py b/autofit/example/__init__.py index 6519cb0be..6a6b9e41e 100644 --- a/autofit/example/__init__.py +++ b/autofit/example/__init__.py @@ -1,4 +1,4 @@ -from .analysis import Analysis -from .model import Gaussian -from .model import Exponential +from .analysis import Analysis +from .model import Gaussian +from .model import Exponential from .util import plot_profile_1d \ No newline at end of file diff --git a/autofit/example/model.py b/autofit/example/model.py index 11d34bf05..0ea434178 100644 --- a/autofit/example/model.py +++ b/autofit/example/model.py @@ -1,179 +1,179 @@ -import math -import numpy as np -from typing import Tuple - -""" -The `Gaussian` class in this module is the model components that is fitted to data using a non-linear search. The -inputs of its __init__ constructor are the parameters which can be fitted for. - -The log_likelihood_function in the Analysis class receives an instance of this classes where the values of its -parameters have been set up according to the non-linear search. Because instances of the classes are used, this means -their methods (e.g. model_data_from) can be used in the log likelihood function. -""" - - -class Gaussian: - def __init__( - self, - centre: float = 0.0, # <- PyAutoFit recognises these constructor arguments - normalization: float = 0.1, # <- are the Gaussian`s model parameters. - sigma: float = 0.01, - ): - """ - Represents a 1D `Gaussian` profile, which may be treated as a model-component of PyAutoFit the - parameters of which are fitted for by a non-linear search. - - Parameters - ---------- - centre - The x coordinate of the profile centre. - normalization - Overall normalization normalisation of the `Gaussian` profile. - sigma - The sigma value controlling the size of the Gaussian. - """ - self.centre = centre - self.normalization = normalization - self.sigma = sigma - - @property - def fwhm(self) -> float: - """ - The full-width half-maximum of the Gaussian profile. - - This is used to illustrate latent variables in **PyAutoFit**, which are values that can be inferred from - the free parameters of the model which we are interested and may want to store the full samples information - on (e.g. to create posteriors). - """ - return 2 * np.sqrt(2 * np.log(2)) * self.sigma - - def _tree_flatten(self): - return (self.centre, self.normalization, self.sigma), None - - @classmethod - def _tree_unflatten(cls, aux_data, children): - return Gaussian(*children) - - def __eq__(self, other): - return ( - isinstance(other, Gaussian) - and self.centre == other.centre - and self.normalization == other.normalization - and self.sigma == other.sigma - ) - - def model_data_from(self, xvalues: np.ndarray, xp=np) -> np.ndarray: - """ - Calculate the normalization of the profile on a 1D grid of Cartesian x coordinates. - - The input xvalues are translated to a coordinate system centred on the Gaussian, using its centre. - - Parameters - ---------- - xvalues - The x coordinates in the original reference frame of the grid. - """ - transformed_xvalues = xvalues - self.centre - - return xp.multiply( - xp.divide(self.normalization, self.sigma * xp.sqrt(2.0 * xp.pi)), - xp.exp(-0.5 * xp.square(xp.divide(transformed_xvalues, self.sigma))), - ) - - def f(self, x: float, xp=np): - return ( - self.normalization - / (self.sigma * xp.sqrt(2 * math.pi)) - * xp.exp(-0.5 * ((x - self.centre) / self.sigma) ** 2) - ) - - def __call__(self, xvalues: np.ndarray) -> np.ndarray: - """ - For certain graphical models, the `__call__` function is overwritten for producing the model-fit. - We include this here so these examples work, but it should not be important for most PyAutoFit users. - - Parameters - ---------- - xvalues - The x coordinates in the original reference frame of the grid. - """ - return self.model_data_from(xvalues=xvalues) - - def inverse(self, y): - """ - For graphical models, the inverse of the Gaussian is used to test certain aspects of the calculation. - """ - - a = self.normalization / (y * self.sigma * math.sqrt(2 * math.pi)) - - b = 2 * math.log(a) - - return self.centre + self.sigma * math.sqrt(b) - - -class Exponential: - def __init__( - self, - centre: float = 0.0, # <- PyAutoFit recognises these constructor arguments are the model - normalization: float = 0.1, # <- parameters of the Gaussian. - rate: float = 0.01, - ): - """ - Represents a 1D Exponential profile, which may be treated as a model-component of PyAutoFit the - parameters of which are fitted for by a `NonLinearSearch`. - - Parameters - ---------- - centre - The x coordinate of the profile centre. - normalization - Overall normalization normalisation of the `Gaussian` profile. - rate - The decay rate controlling has fast the Exponential declines. - """ - self.centre = centre - self.normalization = normalization - self.rate = rate - - def model_data_from(self, xvalues: np.ndarray, xp=np) -> np.ndarray: - """ - Calculate the 1D Gaussian profile on a 1D grid of Cartesian x coordinates. - - The input xvalues are translated to a coordinate system centred on the Exponential, using its centre. - - Parameters - ---------- - values - The x coordinates in the original reference frame of the grid. - """ - transformed_xvalues = xp.subtract(xvalues, self.centre) - return self.normalization * xp.multiply( - self.rate, xp.exp(-1.0 * self.rate * abs(transformed_xvalues)) - ) - - def __call__(self, xvalues: np.ndarray) -> np.ndarray: - """ - Calculate the 1D Gaussian profile on a 1D grid of Cartesian x coordinates. - - The input xvalues are translated to a coordinate system centred on the Exponential, using its centre. - - Parameters - ---------- - values - The x coordinates in the original reference frame of the grid. - """ - return self.model_data_from(xvalues=xvalues) - - -class PhysicalNFW: - def __init__( - self, - centre: Tuple[float, float], - ell_comps: Tuple[float, float], - log10m: float, - concentration: float, - ): - self.centre = centre - self.ell_comps = ell_comps - self.log10m = log10m - self.concentration = concentration +import math +import numpy as np +from typing import Tuple + +""" +The `Gaussian` class in this module is the model components that is fitted to data using a non-linear search. The +inputs of its __init__ constructor are the parameters which can be fitted for. + +The log_likelihood_function in the Analysis class receives an instance of this classes where the values of its +parameters have been set up according to the non-linear search. Because instances of the classes are used, this means +their methods (e.g. model_data_from) can be used in the log likelihood function. +""" + + +class Gaussian: + def __init__( + self, + centre: float = 0.0, # <- PyAutoFit recognises these constructor arguments + normalization: float = 0.1, # <- are the Gaussian`s model parameters. + sigma: float = 0.01, + ): + """ + Represents a 1D `Gaussian` profile, which may be treated as a model-component of PyAutoFit the + parameters of which are fitted for by a non-linear search. + + Parameters + ---------- + centre + The x coordinate of the profile centre. + normalization + Overall normalization normalisation of the `Gaussian` profile. + sigma + The sigma value controlling the size of the Gaussian. + """ + self.centre = centre + self.normalization = normalization + self.sigma = sigma + + @property + def fwhm(self) -> float: + """ + The full-width half-maximum of the Gaussian profile. + + This is used to illustrate latent variables in **PyAutoFit**, which are values that can be inferred from + the free parameters of the model which we are interested and may want to store the full samples information + on (e.g. to create posteriors). + """ + return 2 * np.sqrt(2 * np.log(2)) * self.sigma + + def _tree_flatten(self): + return (self.centre, self.normalization, self.sigma), None + + @classmethod + def _tree_unflatten(cls, aux_data, children): + return Gaussian(*children) + + def __eq__(self, other): + return ( + isinstance(other, Gaussian) + and self.centre == other.centre + and self.normalization == other.normalization + and self.sigma == other.sigma + ) + + def model_data_from(self, xvalues: np.ndarray, xp=np) -> np.ndarray: + """ + Calculate the normalization of the profile on a 1D grid of Cartesian x coordinates. + + The input xvalues are translated to a coordinate system centred on the Gaussian, using its centre. + + Parameters + ---------- + xvalues + The x coordinates in the original reference frame of the grid. + """ + transformed_xvalues = xvalues - self.centre + + return xp.multiply( + xp.divide(self.normalization, self.sigma * xp.sqrt(2.0 * xp.pi)), + xp.exp(-0.5 * xp.square(xp.divide(transformed_xvalues, self.sigma))), + ) + + def f(self, x: float, xp=np): + return ( + self.normalization + / (self.sigma * xp.sqrt(2 * math.pi)) + * xp.exp(-0.5 * ((x - self.centre) / self.sigma) ** 2) + ) + + def __call__(self, xvalues: np.ndarray) -> np.ndarray: + """ + For certain graphical models, the `__call__` function is overwritten for producing the model-fit. + We include this here so these examples work, but it should not be important for most PyAutoFit users. + + Parameters + ---------- + xvalues + The x coordinates in the original reference frame of the grid. + """ + return self.model_data_from(xvalues=xvalues) + + def inverse(self, y): + """ + For graphical models, the inverse of the Gaussian is used to test certain aspects of the calculation. + """ + + a = self.normalization / (y * self.sigma * math.sqrt(2 * math.pi)) + + b = 2 * math.log(a) + + return self.centre + self.sigma * math.sqrt(b) + + +class Exponential: + def __init__( + self, + centre: float = 0.0, # <- PyAutoFit recognises these constructor arguments are the model + normalization: float = 0.1, # <- parameters of the Gaussian. + rate: float = 0.01, + ): + """ + Represents a 1D Exponential profile, which may be treated as a model-component of PyAutoFit the + parameters of which are fitted for by a `NonLinearSearch`. + + Parameters + ---------- + centre + The x coordinate of the profile centre. + normalization + Overall normalization normalisation of the `Gaussian` profile. + rate + The decay rate controlling has fast the Exponential declines. + """ + self.centre = centre + self.normalization = normalization + self.rate = rate + + def model_data_from(self, xvalues: np.ndarray, xp=np) -> np.ndarray: + """ + Calculate the 1D Gaussian profile on a 1D grid of Cartesian x coordinates. + + The input xvalues are translated to a coordinate system centred on the Exponential, using its centre. + + Parameters + ---------- + values + The x coordinates in the original reference frame of the grid. + """ + transformed_xvalues = xp.subtract(xvalues, self.centre) + return self.normalization * xp.multiply( + self.rate, xp.exp(-1.0 * self.rate * abs(transformed_xvalues)) + ) + + def __call__(self, xvalues: np.ndarray) -> np.ndarray: + """ + Calculate the 1D Gaussian profile on a 1D grid of Cartesian x coordinates. + + The input xvalues are translated to a coordinate system centred on the Exponential, using its centre. + + Parameters + ---------- + values + The x coordinates in the original reference frame of the grid. + """ + return self.model_data_from(xvalues=xvalues) + + +class PhysicalNFW: + def __init__( + self, + centre: Tuple[float, float], + ell_comps: Tuple[float, float], + log10m: float, + concentration: float, + ): + self.centre = centre + self.ell_comps = ell_comps + self.log10m = log10m + self.concentration = concentration diff --git a/autofit/example/result.py b/autofit/example/result.py index 7cb628a4b..4348128d0 100644 --- a/autofit/example/result.py +++ b/autofit/example/result.py @@ -1,15 +1,15 @@ -import numpy as np - -import autofit as af - -class ResultExample(af.Result): - - @property - def max_log_likelihood_model_data_1d(self) -> np.ndarray: - """ - Returns the maximum log likelihood model's 1D model data. - - This is an example of how we can pass the `Analysis` class a custom `Result` object and extend this result - object with new properties that are specific to the model-fit we are performing. - """ - return self.analysis.model_data_1d_from(instance=self.instance) +import numpy as np + +import autofit as af + +class ResultExample(af.Result): + + @property + def max_log_likelihood_model_data_1d(self) -> np.ndarray: + """ + Returns the maximum log likelihood model's 1D model data. + + This is an example of how we can pass the `Analysis` class a custom `Result` object and extend this result + object with new properties that are specific to the model-fit we are performing. + """ + return self.analysis.model_data_1d_from(instance=self.instance) diff --git a/autofit/example/util.py b/autofit/example/util.py index 95470529a..b506ca4ff 100644 --- a/autofit/example/util.py +++ b/autofit/example/util.py @@ -1,54 +1,54 @@ -import os -from os import path -import numpy as np -from typing import Optional - -def plot_profile_1d( - xvalues : np.ndarray, - profile_1d: np.ndarray, - title:Optional[str]=None, - ylabel:Optional[str]=None, - errors:Optional[np.ndarray]=None, - color:Optional[str]="k", - output_path:Optional[str]=None, - output_filename:Optional[str]=None, -): - """ - Plot a 1D image of data on a plot of x versus y, where the x-axis is the x coordinate of the 1D profile - and the y-axis is the value of the 1D profile at that coordinate. - - The function include options to output the image to the hard-disk as a .png. - - Parameters - ---------- - xvalues - The x-coordinates the profile is defined on. - profile_1d - The normalization values of the profile which are plotted. - ylabel - The y-label of the plot. - errors - The errors on each data point, which are related to its noise-map. - output_path - The path the image is to be output to hard-disk as a .png. - output_filename - The filename of the file if it is output as a .png. - output_format - Determines where the plot is displayed on your screen ("show") or output to the hard-disk as a png ("png"). - """ - import matplotlib.pyplot as plt - - plt.errorbar( - x=xvalues, y=profile_1d, yerr=errors, color=color, ecolor="k", elinewidth=1, capsize=2 - ) - plt.title(title) - plt.xlabel("x value of profile") - plt.ylabel(ylabel) - if output_filename is None: - plt.show() - else: - if not path.exists(output_path): - os.makedirs(output_path) - plt.savefig(output_path / f"{output_filename}.png") - plt.clf() +import os +from os import path +import numpy as np +from typing import Optional + +def plot_profile_1d( + xvalues : np.ndarray, + profile_1d: np.ndarray, + title:Optional[str]=None, + ylabel:Optional[str]=None, + errors:Optional[np.ndarray]=None, + color:Optional[str]="k", + output_path:Optional[str]=None, + output_filename:Optional[str]=None, +): + """ + Plot a 1D image of data on a plot of x versus y, where the x-axis is the x coordinate of the 1D profile + and the y-axis is the value of the 1D profile at that coordinate. + + The function include options to output the image to the hard-disk as a .png. + + Parameters + ---------- + xvalues + The x-coordinates the profile is defined on. + profile_1d + The normalization values of the profile which are plotted. + ylabel + The y-label of the plot. + errors + The errors on each data point, which are related to its noise-map. + output_path + The path the image is to be output to hard-disk as a .png. + output_filename + The filename of the file if it is output as a .png. + output_format + Determines where the plot is displayed on your screen ("show") or output to the hard-disk as a png ("png"). + """ + import matplotlib.pyplot as plt + + plt.errorbar( + x=xvalues, y=profile_1d, yerr=errors, color=color, ecolor="k", elinewidth=1, capsize=2 + ) + plt.title(title) + plt.xlabel("x value of profile") + plt.ylabel(ylabel) + if output_filename is None: + plt.show() + else: + if not path.exists(output_path): + os.makedirs(output_path) + plt.savefig(output_path / f"{output_filename}.png") + plt.clf() plt.close() \ No newline at end of file diff --git a/autofit/example/visualize.py b/autofit/example/visualize.py index 722d4c670..736f9d5c6 100644 --- a/autofit/example/visualize.py +++ b/autofit/example/visualize.py @@ -1,214 +1,214 @@ -import os -import numpy as np -from typing import List - -import autofit as af - - -class VisualizerExample(af.Visualizer): - """ - Methods associated with visualising analysis, model and data before, during - or after an optimisation. - """ - - @staticmethod - def visualize_before_fit( - analysis, - paths: af.AbstractPaths, - model: af.AbstractPriorModel, - ): - """ - Before a model-fit begins, the `visualize_before_fit` method is called and is used to output images - of quantities that do not change during the fit (e.g. the data). - - The function receives as input an instance of the `Analysis` class which is being used to perform the fit, - which is used to perform the visualization (e.g. it contains the data and noise map which are plotted). - - For your model-fitting problem this function will be overwritten with plotting functions specific to your - problem. - - Parameters - ---------- - analysis - The analysis class used to perform the model-fit whose quantities are being visualized. - paths - The paths object which manages all paths, e.g. where the non-linear search outputs are stored, - visualization, and the pickled objects used by the aggregator output by this function. - model - The model which is fitted to the data, which may be used to customize the visualization. - """ - - import matplotlib.pyplot as plt - - xvalues = np.arange(analysis.data.shape[0]) - - plt.errorbar( - x=xvalues, - y=analysis.data, - yerr=analysis.noise_map, - color="k", - ecolor="k", - elinewidth=1, - capsize=2, - ) - plt.title("The 1D Dataset.") - plt.xlabel("x values of profile") - plt.ylabel("Profile normalization") - - os.makedirs(paths.image_path, exist_ok=True) - plt.savefig(paths.image_path / "data.png") - plt.clf() - plt.close() - - @staticmethod - def visualize( - analysis, - paths: af.DirectoryPaths, - instance: af.ModelInstance, - during_analysis : bool - ): - """ - During a model-fit, the `visualize` method is called throughout the non-linear search and is used to output - images indicating the quality of the fit so far. - - The function receives as input an instance of the `Analysis` class which is being used to perform the fit, - which is used to perform the visualization (e.g. it generates the model data which is plotted). - - The `instance` passed into the visualize method is maximum log likelihood solution obtained by the model-fit - so far which can output on-the-fly images showing the best-fit model so far. - - For your model-fitting problem this function will be overwritten with plotting functions specific to your - problem. - - Parameters - ---------- - analysis - The analysis class used to perform the model-fit whose quantities are being visualized. - paths - The paths object which manages all paths, e.g. where the non-linear search outputs are stored, - visualization, and the pickled objects used by the aggregator output by this function. - instance - An instance of the model that is being fitted to the data by this analysis (whose parameters have been set - via a non-linear search). - during_analysis - If True the visualization is being performed midway through the non-linear search before it is finished, - which may change which images are output. - """ - - import matplotlib.pyplot as plt - - xvalues = np.arange(analysis.data.shape[0]) - model_data_1d_list = [] - - try: - for profile in instance: - try: - model_data_1d_list.append(profile.model_data_from(xvalues=xvalues)) - except AttributeError: - pass - except TypeError: - model_data_1d_list.append(instance.model_data_from(xvalues=xvalues)) - - model_data_1d = sum(model_data_1d_list) - - plt.errorbar( - x=xvalues, - y=analysis.data, - yerr=analysis.noise_map, - color="k", - ecolor="k", - elinewidth=1, - capsize=2, - ) - plt.plot(xvalues, model_data_1d, color="r") - - for model_data_1d_profile in model_data_1d_list: - - plt.plot(xvalues, model_data_1d_profile, color="b", linestyle="--") - - plt.title("Model fit to multiple 1D profiles dataset.") - plt.xlabel("x values of profile") - plt.ylabel("Profile normalization") - - os.makedirs(paths.image_path, exist_ok=True) - plt.savefig(paths.image_path / "model_fit.png") - plt.clf() - plt.close() - - @staticmethod - def visualize_before_fit_combined( - analyses, - paths: af.AbstractPaths, - model: af.AbstractPriorModel, - ): - """ - Multiple instances of the `Analysis` class can be summed together, meaning that the model is fitted to all - datasets simultaneously via a summed likelihood function. - - The function receives as input a list of instances of every `Analysis` class which is being used to perform - the summed analysis fit. This is used which is used to perform the visualization which combines the - information spread across all analyses (e.g. plotting the data of each analysis on the same subplot). - - The `visualize_before_fit_combined` method is called before the model-fit begins and is used to output images - of quantities that do not change during the fit (e.g. the data). - - When summed analysis is used, the `visualize_before_fit` method is also called for each individual analysis. - Each individual dataset may therefore also be visualized in that function. This method is specifically for - visualizing the combined information of all datasets. - - For your model-fitting problem this function will be overwritten with plotting functions specific to your - problem. - - The example does not use analysis summing and therefore this function is not implemented. - - Parameters - ---------- - analyses - A list of the analysis classes used to perform the model-fit whose quantities are being visualized. - paths - The paths object which manages all paths, e.g. where the non-linear search outputs are stored, - visualization, and the pickled objects used by the aggregator output by this function. - model - The model which is fitted to the data, which may be used to customize the visualization. - """ - pass - - @staticmethod - def visualize_combined( - analyses: List[af.Analysis], - paths: af.DirectoryPaths, - instance: af.ModelInstance, - during_analysis: bool, - quick_update: bool = False, - ): - """ - Multiple instances of the `Analysis` class can be summed together, meaning that the model is fitted to all - datasets simultaneously via a summed likelihood function. - - The function receives as input a list of instances of every `Analysis` class which is being used to perform - the summed analysis fit. This is used which is used to perform the visualization which combines the - information spread across all analyses (e.g. plotting the data of each analysis on the same subplot). - - The `visualize_combined` method is called throughout the non-linear search and is used to output images - indicating the quality of the fit so far. - - When summed analysis is used, the `visualize_before_fit` method is also called for each individual analysis. - Each individual dataset may therefore also be visualized in that function. This method is specifically for - visualizing the combined information of all datasets. - - For your model-fitting problem this function will be overwritten with plotting functions specific to your - problem. - - The example does not use analysis summing and therefore this function is not implemented. - - Parameters - ---------- - analyses - A list of the analysis classes used to perform the model-fit whose quantities are being visualized. - paths - The paths object which manages all paths, e.g. where the non-linear search outputs are stored, - visualization, and the pickled objects used by the aggregator output by this function. - model - The model which is fitted to the data, which may be used to customize the visualization. - """ +import os +import numpy as np +from typing import List + +import autofit as af + + +class VisualizerExample(af.Visualizer): + """ + Methods associated with visualising analysis, model and data before, during + or after an optimisation. + """ + + @staticmethod + def visualize_before_fit( + analysis, + paths: af.AbstractPaths, + model: af.AbstractPriorModel, + ): + """ + Before a model-fit begins, the `visualize_before_fit` method is called and is used to output images + of quantities that do not change during the fit (e.g. the data). + + The function receives as input an instance of the `Analysis` class which is being used to perform the fit, + which is used to perform the visualization (e.g. it contains the data and noise map which are plotted). + + For your model-fitting problem this function will be overwritten with plotting functions specific to your + problem. + + Parameters + ---------- + analysis + The analysis class used to perform the model-fit whose quantities are being visualized. + paths + The paths object which manages all paths, e.g. where the non-linear search outputs are stored, + visualization, and the pickled objects used by the aggregator output by this function. + model + The model which is fitted to the data, which may be used to customize the visualization. + """ + + import matplotlib.pyplot as plt + + xvalues = np.arange(analysis.data.shape[0]) + + plt.errorbar( + x=xvalues, + y=analysis.data, + yerr=analysis.noise_map, + color="k", + ecolor="k", + elinewidth=1, + capsize=2, + ) + plt.title("The 1D Dataset.") + plt.xlabel("x values of profile") + plt.ylabel("Profile normalization") + + os.makedirs(paths.image_path, exist_ok=True) + plt.savefig(paths.image_path / "data.png") + plt.clf() + plt.close() + + @staticmethod + def visualize( + analysis, + paths: af.DirectoryPaths, + instance: af.ModelInstance, + during_analysis : bool + ): + """ + During a model-fit, the `visualize` method is called throughout the non-linear search and is used to output + images indicating the quality of the fit so far. + + The function receives as input an instance of the `Analysis` class which is being used to perform the fit, + which is used to perform the visualization (e.g. it generates the model data which is plotted). + + The `instance` passed into the visualize method is maximum log likelihood solution obtained by the model-fit + so far which can output on-the-fly images showing the best-fit model so far. + + For your model-fitting problem this function will be overwritten with plotting functions specific to your + problem. + + Parameters + ---------- + analysis + The analysis class used to perform the model-fit whose quantities are being visualized. + paths + The paths object which manages all paths, e.g. where the non-linear search outputs are stored, + visualization, and the pickled objects used by the aggregator output by this function. + instance + An instance of the model that is being fitted to the data by this analysis (whose parameters have been set + via a non-linear search). + during_analysis + If True the visualization is being performed midway through the non-linear search before it is finished, + which may change which images are output. + """ + + import matplotlib.pyplot as plt + + xvalues = np.arange(analysis.data.shape[0]) + model_data_1d_list = [] + + try: + for profile in instance: + try: + model_data_1d_list.append(profile.model_data_from(xvalues=xvalues)) + except AttributeError: + pass + except TypeError: + model_data_1d_list.append(instance.model_data_from(xvalues=xvalues)) + + model_data_1d = sum(model_data_1d_list) + + plt.errorbar( + x=xvalues, + y=analysis.data, + yerr=analysis.noise_map, + color="k", + ecolor="k", + elinewidth=1, + capsize=2, + ) + plt.plot(xvalues, model_data_1d, color="r") + + for model_data_1d_profile in model_data_1d_list: + + plt.plot(xvalues, model_data_1d_profile, color="b", linestyle="--") + + plt.title("Model fit to multiple 1D profiles dataset.") + plt.xlabel("x values of profile") + plt.ylabel("Profile normalization") + + os.makedirs(paths.image_path, exist_ok=True) + plt.savefig(paths.image_path / "model_fit.png") + plt.clf() + plt.close() + + @staticmethod + def visualize_before_fit_combined( + analyses, + paths: af.AbstractPaths, + model: af.AbstractPriorModel, + ): + """ + Multiple instances of the `Analysis` class can be summed together, meaning that the model is fitted to all + datasets simultaneously via a summed likelihood function. + + The function receives as input a list of instances of every `Analysis` class which is being used to perform + the summed analysis fit. This is used which is used to perform the visualization which combines the + information spread across all analyses (e.g. plotting the data of each analysis on the same subplot). + + The `visualize_before_fit_combined` method is called before the model-fit begins and is used to output images + of quantities that do not change during the fit (e.g. the data). + + When summed analysis is used, the `visualize_before_fit` method is also called for each individual analysis. + Each individual dataset may therefore also be visualized in that function. This method is specifically for + visualizing the combined information of all datasets. + + For your model-fitting problem this function will be overwritten with plotting functions specific to your + problem. + + The example does not use analysis summing and therefore this function is not implemented. + + Parameters + ---------- + analyses + A list of the analysis classes used to perform the model-fit whose quantities are being visualized. + paths + The paths object which manages all paths, e.g. where the non-linear search outputs are stored, + visualization, and the pickled objects used by the aggregator output by this function. + model + The model which is fitted to the data, which may be used to customize the visualization. + """ + pass + + @staticmethod + def visualize_combined( + analyses: List[af.Analysis], + paths: af.DirectoryPaths, + instance: af.ModelInstance, + during_analysis: bool, + quick_update: bool = False, + ): + """ + Multiple instances of the `Analysis` class can be summed together, meaning that the model is fitted to all + datasets simultaneously via a summed likelihood function. + + The function receives as input a list of instances of every `Analysis` class which is being used to perform + the summed analysis fit. This is used which is used to perform the visualization which combines the + information spread across all analyses (e.g. plotting the data of each analysis on the same subplot). + + The `visualize_combined` method is called throughout the non-linear search and is used to output images + indicating the quality of the fit so far. + + When summed analysis is used, the `visualize_before_fit` method is also called for each individual analysis. + Each individual dataset may therefore also be visualized in that function. This method is specifically for + visualizing the combined information of all datasets. + + For your model-fitting problem this function will be overwritten with plotting functions specific to your + problem. + + The example does not use analysis summing and therefore this function is not implemented. + + Parameters + ---------- + analyses + A list of the analysis classes used to perform the model-fit whose quantities are being visualized. + paths + The paths object which manages all paths, e.g. where the non-linear search outputs are stored, + visualization, and the pickled objects used by the aggregator output by this function. + model + The model which is fitted to the data, which may be used to customize the visualization. + """ pass \ No newline at end of file diff --git a/autofit/exc.py b/autofit/exc.py index e23e69c1e..7e7de9711 100644 --- a/autofit/exc.py +++ b/autofit/exc.py @@ -1,75 +1,75 @@ -from autonerves.exc import PriorException - - -class MessageException(PriorException): - """ - Raised when some assertion about the parameterization of a message is not met - """ - - -class PathsException(Exception): - pass - - -class FitException(Exception): - """ - An exception to be thrown if the non linear search must resample; equivalent to returning an infinitely bad fit - """ - - pass - - -class PipelineException(Exception): - pass - - -class DeferredInstanceException(Exception): - """ - Exception raised when an attempt is made to access an attribute or function of a - deferred instance prior to instantiation - """ - - pass - - -class AggregatorException(Exception): - pass - - -class GridSearchException(Exception): - pass - - -class HistoryException(Exception): - """ - Thrown when insufficient factor history is present for a given operation - """ - - -class InitializerException(Exception): - """ - Raises exceptions associated with the `non_linear.initializer` module and `Initializer` classes. - - For example if all initial samples have identical figures of merit. - """ - - -class SamplesException(Exception): - pass - - -class SearchException(Exception): - pass - - -class SamplesWarning(Warning): - """ - Raises warnings associated with the `non_linear` module and `NonLinearSearch` classes. - - For example if the search is parallel but enviromental variables controlling multithreading are sub-optimal. - """ - pass - - -class SearchWarning(Warning): +from autonerves.exc import PriorException + + +class MessageException(PriorException): + """ + Raised when some assertion about the parameterization of a message is not met + """ + + +class PathsException(Exception): + pass + + +class FitException(Exception): + """ + An exception to be thrown if the non linear search must resample; equivalent to returning an infinitely bad fit + """ + + pass + + +class PipelineException(Exception): + pass + + +class DeferredInstanceException(Exception): + """ + Exception raised when an attempt is made to access an attribute or function of a + deferred instance prior to instantiation + """ + + pass + + +class AggregatorException(Exception): + pass + + +class GridSearchException(Exception): + pass + + +class HistoryException(Exception): + """ + Thrown when insufficient factor history is present for a given operation + """ + + +class InitializerException(Exception): + """ + Raises exceptions associated with the `non_linear.initializer` module and `Initializer` classes. + + For example if all initial samples have identical figures of merit. + """ + + +class SamplesException(Exception): + pass + + +class SearchException(Exception): + pass + + +class SamplesWarning(Warning): + """ + Raises warnings associated with the `non_linear` module and `NonLinearSearch` classes. + + For example if the search is parallel but enviromental variables controlling multithreading are sub-optimal. + """ + pass + + +class SearchWarning(Warning): pass \ No newline at end of file diff --git a/autofit/fixtures.py b/autofit/fixtures.py index 1e08bd391..27c34b17b 100644 --- a/autofit/fixtures.py +++ b/autofit/fixtures.py @@ -1,35 +1,35 @@ -import autofit as af - -from autofit.mapper.mock.mock_model import MockClassx4 -from autofit.non_linear.mock.mock_samples import MockSamples - - -def make_model_gaussian_x1(): - - return af.Model( - af.ex.Gaussian - ) - - -def make_samples_x5(): - - model = af.ModelMapper(mock_class_1=MockClassx4) - - parameters = [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - [0.0, 1.0, 2.0, 3.0], - ] - - return MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], - ), +import autofit as af + +from autofit.mapper.mock.mock_model import MockClassx4 +from autofit.non_linear.mock.mock_samples import MockSamples + + +def make_model_gaussian_x1(): + + return af.Model( + af.ex.Gaussian + ) + + +def make_samples_x5(): + + model = af.ModelMapper(mock_class_1=MockClassx4) + + parameters = [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + [0.0, 1.0, 2.0, 3.0], + ] + + return MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], + ), ) \ No newline at end of file diff --git a/autofit/graphical/factor_graphs/graph.py b/autofit/graphical/factor_graphs/graph.py index 11cdde19c..a7d32cf34 100644 --- a/autofit/graphical/factor_graphs/graph.py +++ b/autofit/graphical/factor_graphs/graph.py @@ -1,485 +1,485 @@ -from collections import Counter, defaultdict -from functools import reduce -from itertools import count -from typing import Tuple, Dict, Collection, List, Type - -import numpy as np - -from autonerves import cached_property -from autofit.graphical.factor_graphs.abstract import FactorValue, AbstractNode -from autofit.graphical.factor_graphs.factor import Factor -from autofit.graphical.factor_graphs.jacobians import VectorJacobianProduct -from autofit.graphical.utils import rescale_to_artists -from autofit.mapper.variable import Variable - - -class FactorGraph(AbstractNode): - def __init__( - self, - factors: Collection[Factor], - ): - """ - A graph relating factors - - Parameters - ---------- - factors - Nodes wrapping individual factors in a model - """ - self._name = "(%s)" % "*".join(f.name for f in factors) - - self._factors = tuple(factors) - - self._factor_all_variables = {f: f.all_variables for f in self._factors} - - self._call_sequence = self._get_call_sequence() - - self._validate() - - _kwargs = {variable.name: variable for variable in self.variables} - - super().__init__(**_kwargs) - - def copy(self): - return type(self)(self.factors) - - def related_factors(self, variable: Variable, excluded_factor=None) -> List[Factor]: - """ - A list of factors which contain the variable. - - Parameters - ---------- - excluded_factor - A factor that should be excluded from the list - variable - A variable in the graph which will be related to one - or more factors - - Returns - ------- - The factors associated with the variable - """ - return sorted( - { - factor - for factor in self._factors - if variable in factor.variables - and (excluded_factor is None or factor != excluded_factor) - } - ) - - def _factors_with_type(self, factor_type: Type[Factor]) -> List[Factor]: - """ - Find all factors with a given type - """ - return [factor for factor in self._factors if isinstance(factor, factor_type)] - - def factors_by_type(self) -> Dict[Type[Factor], List[Factor]]: - """ - A dictionary mapping types of factor to all factors of that type - """ - factors_by_type = defaultdict(list) - for factor in self._factors: - factors_by_type[type(factor)].append(factor) - return factors_by_type - - @property - def info(self) -> str: - """ - Describes the graph. Output in graph.info - """ - string = "" - for factor_type, factors in self.factors_by_type().items(): - factor_info = "\n\n".join(factor.info for factor in factors) - string = f"{string}{factor_type.__name__}s\n\n{factor_info}\n\n" - return string - - def make_results_text(self, model_approx) -> str: - """ - Generate text describing the graph w.r.t. a given model approximation - """ - results_text = "\n\n".join( - factor.make_results_text(model_approx) for factor in self._factors - ) - return f"{self.name}\n\n{results_text}" - - @property - def name(self): - return self._name - - def _validate(self): - """ - Raises - ------ - If there is an inconsistency with this graph - """ - det_var_counts = ", ".join( - map( - str, - [ - v - for v, c in Counter( - v for f in self.factors for v in f.deterministic_variables - ).items() - if c > 1 - ], - ) - ) - if det_var_counts: - raise ValueError( - "Improper FactorGraph, " - f"Deterministic variables {det_var_counts} appear in " - "multiple factors" - ) - - @cached_property - def all_variables(self): - return reduce( - frozenset.union, (factor.all_variables for factor in self.factors) - ) - - @cached_property - def deterministic_variables(self): - return reduce( - frozenset.union, (factor.deterministic_variables for factor in self.factors) - ) - - @cached_property - def variables(self): - return self.all_variables - self.deterministic_variables - - def _get_call_sequence(self) -> List[List[Factor]]: - """ - Compute the order in which the factors must be evaluated. This is done by checking whether - all variables required to call a factor are present in the set of variables encapsulated - by all factors, not including deterministic variables. - - Deterministic variables must be computed before the dependent factors can be computed. - """ - call_sets = defaultdict(list) - for factor in self.factors: - missing_vars = frozenset(factor.variables.difference(self.variables)) - call_sets[missing_vars].append(factor) - - call_sequence = [] - while call_sets: - # the factors that can be evaluated have no missing variables - factors = call_sets.pop(frozenset(())) - # if there's a KeyError then the FactorGraph is improper - calls = [] - new_variables = set() - for factor in factors: - det_vars = factor.deterministic_variables - calls.append(factor) - # TODO: this might cause problems - # TODO: if det_vars appear more than once - new_variables.update(det_vars) - - call_sequence.append(calls) - - # update to include newly calculated factors - for missing in list(call_sets.keys()): - if missing.intersection(new_variables): - factors = call_sets.pop(missing) - call_sets[missing.difference(new_variables)].extend(factors) - - return call_sequence - - def _call_args(self, *args): - return self(dict(zip(self.args, args))) - - def __call__( - self, - variable_dict: Dict[Variable, np.ndarray], - ) -> FactorValue: - """ - Call each function in the graph in the correct order, adding the logarithmic results. - - Deterministic values computed in initial factor calls are added to a dictionary and - passed to subsequent factor calls. - - Parameters - ---------- - variable_dict - Positional arguments - axis - Keyword arguments - - Returns - ------- - Object comprising the log value of the computation and a dictionary containing - the values of deterministic variables. - """ - - # generate set of factors to call, these are indexed by the - # missing deterministic variables that need to be calculated - log_value = 0.0 - det_values = {} - variables = {**self.fixed_values, **variable_dict} - - missing = set(v.name for v in self.variables).difference( - v.name for v in variables - ) - if missing: - n_miss = len(missing) - missing_str = ", ".join(missing) - raise ValueError( - f"{self} missing {n_miss} arguments: {missing_str}" - f"factor graph call signature: {self.call_signature}" - ) - - for calls in self._call_sequence: - # TODO parallelise this part? - for factor in calls: - ret = factor(variables) - log_value += np.sum(ret) - det_values.update(ret.deterministic_values) - variables.update(ret.deterministic_values) - - return FactorValue(log_value, det_values) - - def func_jacobian( - self, variable_dict: Dict[Variable, np.ndarray], **kwargs - ) -> FactorValue: - - # generate set of factors to call, these are indexed by the - # missing deterministic variables that need to be calculated - log_value = 0.0 - det_values = {} - factor_jacs = {} - variables = {**self.fixed_values, **variable_dict} - - missing = set(v.name for v in self.variables).difference( - v.name for v in variables - ) - if missing: - n_miss = len(missing) - missing_str = ", ".join(missing) - raise ValueError( - f"{self} missing {n_miss} arguments: {missing_str}" - f"factor graph call signature: {self.call_signature}" - ) - - for calls in self._call_sequence: - # TODO parallelise this part? - for factor in calls: - ret, factor_jacs[factor] = factor.func_jacobian(variables, **kwargs) - log_value += ret - - det_values.update(ret.deterministic_values) - variables.update(ret.deterministic_values) - - jac_out = (FactorValue,) + tuple(det_values) - jac_variables = tuple(variables) - - def graph_vjp(args): - args = args if isinstance(args, tuple) else (args,) - grads = {} - grads.update(zip(jac_out, args)) - for calls in self._call_sequence[::-1]: - for factor in calls: - factor_grad = factor_jacs[factor](grads) - for v, v_grad in factor_grad.items(): - grads[v] = grads.get(v, 0) + v_grad - - return tuple(grads[v] for v in jac_variables) - - fval = FactorValue(log_value, det_values) - graph_vjp = VectorJacobianProduct( - jac_out, - graph_vjp, - *jac_variables, - out_shapes=fval.to_dict().shapes, - ) - - return fval, graph_vjp - - def __mul__(self, other: AbstractNode) -> "FactorGraph": - """ - Combine this object with another factor node or graph, creating - a new graph that comprises all of the factors of the two objects. - """ - factors = self.factors - - if isinstance(other, FactorGraph): - factors += other.factors - elif isinstance(other, Factor): - factors += (other,) - else: - raise TypeError( - f"type of passed element {(type(other))} " - "does not match required types, (`FactorGraph`, `FactorNode`)" - ) - - return type(self)(factors) - - def __repr__(self) -> str: - factors_str = " * ".join(map(repr, self.factors)) - return f"({factors_str})" - - @property - def factors(self) -> Tuple[Factor, ...]: - return self._factors - - @property - def factor_all_variables(self) -> Dict[Factor, List[Variable]]: - return self._factor_all_variables - - @property - def graph(self): - try: - import networkx as nx - except ImportError as e: - raise ImportError("networkx required for graph") from e - - G = nx.Graph() - G.add_nodes_from(self.factors, bipartite="factor") - G.add_nodes_from(self.all_variables, bipartite="variable") - G.add_edges_from( - (f, v) for f, vs in self.factor_all_variables.items() for v in vs - ) - - return G - - def draw_graph( - self, - pos=None, - ax=None, - size=20, - color="k", - fill="w", - factor_shape="s", - variable_shape="o", - factor_labels=None, - variable_labels=None, - factor_kws=None, - variable_kws=None, - edge_kws=None, - factors=None, - draw_labels=False, - label_kws=None, - **kwargs, - ): - try: - import matplotlib.pyplot as plt - import networkx as nx - except ImportError as e: - raise ImportError( - "Matplotlib and networkx required for draw_graph()" - ) from e - except RuntimeError as e: - print("Matplotlib unable to open display") - raise e - - if ax is None: - ax = plt.gca() - - G = self.graph - if pos is None: - pos = bipartite_layout(factors or self.factors) - - kwargs.setdefault("ms", size) - kwargs.setdefault("c", color) - kwargs.setdefault("mec", color) - kwargs.setdefault("mfc", fill) - kwargs.setdefault("ls", "") - - factor_kws = factor_kws or {} - factor_kws.setdefault("marker", factor_shape) - - variable_kws = variable_kws or {} - variable_kws.setdefault("marker", variable_shape) - - # draw factors - xy = np.array([pos[f] for f in self.factors]).T - fs = ax.plot(*xy, **{**kwargs, **factor_kws}) - # draw variables - xy = np.array([pos[f] for f in self.all_variables]).T - vs = ax.plot(*xy, **{**kwargs, **variable_kws}) - # draw edges - edges = nx.draw_networkx_edges(G, pos, **(edge_kws or {})) - - # remove ticks from axes - ax.tick_params( - axis="both", - which="both", - bottom=False, - left=False, - labelbottom=False, - labelleft=False, - ) - if draw_labels: - self.draw_graph_labels( - pos, - ax=ax, - factor_labels=factor_labels, - variable_labels=variable_labels, - **(label_kws or {}) - ) - return pos, fs, vs, edges - - def draw_graph_labels( - self, - pos, - factor_labels=None, - variable_labels=None, - shift=0.1, - f_shift=None, - v_shift=None, - f_horizontalalignment="right", - v_horizontalalignment="left", - f_kws=None, - v_kws=None, - graph=None, - ax=None, - rescale=True, - **kwargs, - ): - try: - import matplotlib.pyplot as plt - import networkx as nx - except ImportError as e: - raise ImportError( - "Matplotlib and networkx required for draw_graph()" - ) from e - ax = ax or plt.gca() - graph = graph or self.graph - factor_labels = factor_labels or {f: f.name for f in self.factors} - variable_labels = variable_labels or {v: v.name for v in self.all_variables} - f_kws = f_kws or {"horizontalalignment": f_horizontalalignment} - v_kws = v_kws or {"horizontalalignment": v_horizontalalignment} - - f_shift = f_shift or shift - f_pos = {f: (x - f_shift, y) for f, (x, y) in pos.items()} - v_shift = v_shift or shift - v_pos = {f: (x + v_shift, y) for f, (x, y) in pos.items()} - - text = { - **nx.draw_networkx_labels( - graph, f_pos, labels=factor_labels, ax=ax, **f_kws, **kwargs - ), - **nx.draw_networkx_labels( - graph, v_pos, labels=variable_labels, ax=ax, **v_kws, **kwargs - ), - } - if rescale: - rescale_to_artists(text.values(), ax=ax) - - return text - - -def bipartite_layout(factors): - n_factors = len(factors) - n_variables = len(set().union(*(f.variables for f in factors))) - n = max(n_factors, n_variables) - factor_count = count() - variable_count = count() - - pos = {} - for factor in factors: - pos[factor] = 0, next(factor_count) * n / n_factors - for v in factor.variables: - if v not in pos: - pos[v] = 1, next(variable_count) * n / n_variables - - return pos +from collections import Counter, defaultdict +from functools import reduce +from itertools import count +from typing import Tuple, Dict, Collection, List, Type + +import numpy as np + +from autonerves import cached_property +from autofit.graphical.factor_graphs.abstract import FactorValue, AbstractNode +from autofit.graphical.factor_graphs.factor import Factor +from autofit.graphical.factor_graphs.jacobians import VectorJacobianProduct +from autofit.graphical.utils import rescale_to_artists +from autofit.mapper.variable import Variable + + +class FactorGraph(AbstractNode): + def __init__( + self, + factors: Collection[Factor], + ): + """ + A graph relating factors + + Parameters + ---------- + factors + Nodes wrapping individual factors in a model + """ + self._name = "(%s)" % "*".join(f.name for f in factors) + + self._factors = tuple(factors) + + self._factor_all_variables = {f: f.all_variables for f in self._factors} + + self._call_sequence = self._get_call_sequence() + + self._validate() + + _kwargs = {variable.name: variable for variable in self.variables} + + super().__init__(**_kwargs) + + def copy(self): + return type(self)(self.factors) + + def related_factors(self, variable: Variable, excluded_factor=None) -> List[Factor]: + """ + A list of factors which contain the variable. + + Parameters + ---------- + excluded_factor + A factor that should be excluded from the list + variable + A variable in the graph which will be related to one + or more factors + + Returns + ------- + The factors associated with the variable + """ + return sorted( + { + factor + for factor in self._factors + if variable in factor.variables + and (excluded_factor is None or factor != excluded_factor) + } + ) + + def _factors_with_type(self, factor_type: Type[Factor]) -> List[Factor]: + """ + Find all factors with a given type + """ + return [factor for factor in self._factors if isinstance(factor, factor_type)] + + def factors_by_type(self) -> Dict[Type[Factor], List[Factor]]: + """ + A dictionary mapping types of factor to all factors of that type + """ + factors_by_type = defaultdict(list) + for factor in self._factors: + factors_by_type[type(factor)].append(factor) + return factors_by_type + + @property + def info(self) -> str: + """ + Describes the graph. Output in graph.info + """ + string = "" + for factor_type, factors in self.factors_by_type().items(): + factor_info = "\n\n".join(factor.info for factor in factors) + string = f"{string}{factor_type.__name__}s\n\n{factor_info}\n\n" + return string + + def make_results_text(self, model_approx) -> str: + """ + Generate text describing the graph w.r.t. a given model approximation + """ + results_text = "\n\n".join( + factor.make_results_text(model_approx) for factor in self._factors + ) + return f"{self.name}\n\n{results_text}" + + @property + def name(self): + return self._name + + def _validate(self): + """ + Raises + ------ + If there is an inconsistency with this graph + """ + det_var_counts = ", ".join( + map( + str, + [ + v + for v, c in Counter( + v for f in self.factors for v in f.deterministic_variables + ).items() + if c > 1 + ], + ) + ) + if det_var_counts: + raise ValueError( + "Improper FactorGraph, " + f"Deterministic variables {det_var_counts} appear in " + "multiple factors" + ) + + @cached_property + def all_variables(self): + return reduce( + frozenset.union, (factor.all_variables for factor in self.factors) + ) + + @cached_property + def deterministic_variables(self): + return reduce( + frozenset.union, (factor.deterministic_variables for factor in self.factors) + ) + + @cached_property + def variables(self): + return self.all_variables - self.deterministic_variables + + def _get_call_sequence(self) -> List[List[Factor]]: + """ + Compute the order in which the factors must be evaluated. This is done by checking whether + all variables required to call a factor are present in the set of variables encapsulated + by all factors, not including deterministic variables. + + Deterministic variables must be computed before the dependent factors can be computed. + """ + call_sets = defaultdict(list) + for factor in self.factors: + missing_vars = frozenset(factor.variables.difference(self.variables)) + call_sets[missing_vars].append(factor) + + call_sequence = [] + while call_sets: + # the factors that can be evaluated have no missing variables + factors = call_sets.pop(frozenset(())) + # if there's a KeyError then the FactorGraph is improper + calls = [] + new_variables = set() + for factor in factors: + det_vars = factor.deterministic_variables + calls.append(factor) + # TODO: this might cause problems + # TODO: if det_vars appear more than once + new_variables.update(det_vars) + + call_sequence.append(calls) + + # update to include newly calculated factors + for missing in list(call_sets.keys()): + if missing.intersection(new_variables): + factors = call_sets.pop(missing) + call_sets[missing.difference(new_variables)].extend(factors) + + return call_sequence + + def _call_args(self, *args): + return self(dict(zip(self.args, args))) + + def __call__( + self, + variable_dict: Dict[Variable, np.ndarray], + ) -> FactorValue: + """ + Call each function in the graph in the correct order, adding the logarithmic results. + + Deterministic values computed in initial factor calls are added to a dictionary and + passed to subsequent factor calls. + + Parameters + ---------- + variable_dict + Positional arguments + axis + Keyword arguments + + Returns + ------- + Object comprising the log value of the computation and a dictionary containing + the values of deterministic variables. + """ + + # generate set of factors to call, these are indexed by the + # missing deterministic variables that need to be calculated + log_value = 0.0 + det_values = {} + variables = {**self.fixed_values, **variable_dict} + + missing = set(v.name for v in self.variables).difference( + v.name for v in variables + ) + if missing: + n_miss = len(missing) + missing_str = ", ".join(missing) + raise ValueError( + f"{self} missing {n_miss} arguments: {missing_str}" + f"factor graph call signature: {self.call_signature}" + ) + + for calls in self._call_sequence: + # TODO parallelise this part? + for factor in calls: + ret = factor(variables) + log_value += np.sum(ret) + det_values.update(ret.deterministic_values) + variables.update(ret.deterministic_values) + + return FactorValue(log_value, det_values) + + def func_jacobian( + self, variable_dict: Dict[Variable, np.ndarray], **kwargs + ) -> FactorValue: + + # generate set of factors to call, these are indexed by the + # missing deterministic variables that need to be calculated + log_value = 0.0 + det_values = {} + factor_jacs = {} + variables = {**self.fixed_values, **variable_dict} + + missing = set(v.name for v in self.variables).difference( + v.name for v in variables + ) + if missing: + n_miss = len(missing) + missing_str = ", ".join(missing) + raise ValueError( + f"{self} missing {n_miss} arguments: {missing_str}" + f"factor graph call signature: {self.call_signature}" + ) + + for calls in self._call_sequence: + # TODO parallelise this part? + for factor in calls: + ret, factor_jacs[factor] = factor.func_jacobian(variables, **kwargs) + log_value += ret + + det_values.update(ret.deterministic_values) + variables.update(ret.deterministic_values) + + jac_out = (FactorValue,) + tuple(det_values) + jac_variables = tuple(variables) + + def graph_vjp(args): + args = args if isinstance(args, tuple) else (args,) + grads = {} + grads.update(zip(jac_out, args)) + for calls in self._call_sequence[::-1]: + for factor in calls: + factor_grad = factor_jacs[factor](grads) + for v, v_grad in factor_grad.items(): + grads[v] = grads.get(v, 0) + v_grad + + return tuple(grads[v] for v in jac_variables) + + fval = FactorValue(log_value, det_values) + graph_vjp = VectorJacobianProduct( + jac_out, + graph_vjp, + *jac_variables, + out_shapes=fval.to_dict().shapes, + ) + + return fval, graph_vjp + + def __mul__(self, other: AbstractNode) -> "FactorGraph": + """ + Combine this object with another factor node or graph, creating + a new graph that comprises all of the factors of the two objects. + """ + factors = self.factors + + if isinstance(other, FactorGraph): + factors += other.factors + elif isinstance(other, Factor): + factors += (other,) + else: + raise TypeError( + f"type of passed element {(type(other))} " + "does not match required types, (`FactorGraph`, `FactorNode`)" + ) + + return type(self)(factors) + + def __repr__(self) -> str: + factors_str = " * ".join(map(repr, self.factors)) + return f"({factors_str})" + + @property + def factors(self) -> Tuple[Factor, ...]: + return self._factors + + @property + def factor_all_variables(self) -> Dict[Factor, List[Variable]]: + return self._factor_all_variables + + @property + def graph(self): + try: + import networkx as nx + except ImportError as e: + raise ImportError("networkx required for graph") from e + + G = nx.Graph() + G.add_nodes_from(self.factors, bipartite="factor") + G.add_nodes_from(self.all_variables, bipartite="variable") + G.add_edges_from( + (f, v) for f, vs in self.factor_all_variables.items() for v in vs + ) + + return G + + def draw_graph( + self, + pos=None, + ax=None, + size=20, + color="k", + fill="w", + factor_shape="s", + variable_shape="o", + factor_labels=None, + variable_labels=None, + factor_kws=None, + variable_kws=None, + edge_kws=None, + factors=None, + draw_labels=False, + label_kws=None, + **kwargs, + ): + try: + import matplotlib.pyplot as plt + import networkx as nx + except ImportError as e: + raise ImportError( + "Matplotlib and networkx required for draw_graph()" + ) from e + except RuntimeError as e: + print("Matplotlib unable to open display") + raise e + + if ax is None: + ax = plt.gca() + + G = self.graph + if pos is None: + pos = bipartite_layout(factors or self.factors) + + kwargs.setdefault("ms", size) + kwargs.setdefault("c", color) + kwargs.setdefault("mec", color) + kwargs.setdefault("mfc", fill) + kwargs.setdefault("ls", "") + + factor_kws = factor_kws or {} + factor_kws.setdefault("marker", factor_shape) + + variable_kws = variable_kws or {} + variable_kws.setdefault("marker", variable_shape) + + # draw factors + xy = np.array([pos[f] for f in self.factors]).T + fs = ax.plot(*xy, **{**kwargs, **factor_kws}) + # draw variables + xy = np.array([pos[f] for f in self.all_variables]).T + vs = ax.plot(*xy, **{**kwargs, **variable_kws}) + # draw edges + edges = nx.draw_networkx_edges(G, pos, **(edge_kws or {})) + + # remove ticks from axes + ax.tick_params( + axis="both", + which="both", + bottom=False, + left=False, + labelbottom=False, + labelleft=False, + ) + if draw_labels: + self.draw_graph_labels( + pos, + ax=ax, + factor_labels=factor_labels, + variable_labels=variable_labels, + **(label_kws or {}) + ) + return pos, fs, vs, edges + + def draw_graph_labels( + self, + pos, + factor_labels=None, + variable_labels=None, + shift=0.1, + f_shift=None, + v_shift=None, + f_horizontalalignment="right", + v_horizontalalignment="left", + f_kws=None, + v_kws=None, + graph=None, + ax=None, + rescale=True, + **kwargs, + ): + try: + import matplotlib.pyplot as plt + import networkx as nx + except ImportError as e: + raise ImportError( + "Matplotlib and networkx required for draw_graph()" + ) from e + ax = ax or plt.gca() + graph = graph or self.graph + factor_labels = factor_labels or {f: f.name for f in self.factors} + variable_labels = variable_labels or {v: v.name for v in self.all_variables} + f_kws = f_kws or {"horizontalalignment": f_horizontalalignment} + v_kws = v_kws or {"horizontalalignment": v_horizontalalignment} + + f_shift = f_shift or shift + f_pos = {f: (x - f_shift, y) for f, (x, y) in pos.items()} + v_shift = v_shift or shift + v_pos = {f: (x + v_shift, y) for f, (x, y) in pos.items()} + + text = { + **nx.draw_networkx_labels( + graph, f_pos, labels=factor_labels, ax=ax, **f_kws, **kwargs + ), + **nx.draw_networkx_labels( + graph, v_pos, labels=variable_labels, ax=ax, **v_kws, **kwargs + ), + } + if rescale: + rescale_to_artists(text.values(), ax=ax) + + return text + + +def bipartite_layout(factors): + n_factors = len(factors) + n_variables = len(set().union(*(f.variables for f in factors))) + n = max(n_factors, n_variables) + factor_count = count() + variable_count = count() + + pos = {} + for factor in factors: + pos[factor] = 0, next(factor_count) * n / n_factors + for v in factor.variables: + if v not in pos: + pos[v] = 1, next(variable_count) * n / n_variables + + return pos diff --git a/autofit/mapper/mock/mock_model.py b/autofit/mapper/mock/mock_model.py index fe1ff914c..6130d3aef 100644 --- a/autofit/mapper/mock/mock_model.py +++ b/autofit/mapper/mock/mock_model.py @@ -1,204 +1,204 @@ -class MockClassx2: - def __init__(self, one=1, two=2): - self.one = one - self.two = two - - -class MockClassx2Instance(MockClassx2): - pass - - -class MockClassx2FormatExp: - """ - This mock classes's second parameter `two_exp` has a format label `two={:.2e}` in `notational/label_format.ini` and - is used to test latex generation. - """ - - def __init__(self, one=1, two_exp=2): - self.one = one - self.two_exp = two_exp - - -class MockClassx2NoSuperScript: - def __init__(self, one=1, two=2): - self.one = one - self.two = two - - -class MockClassx4: - def __init__(self, one=1, two=2, three=3, four=4): - self.one = one - self.two = two - self.three = three - self.four = four - - -class MockClassx3(MockClassx4): - def __init__(self, one=1, two=2, three=3): - super().__init__(one, two, three) - - -class MockClassx2Tuple: - def __init__(self, one_tuple=(0.0, 0.0)): - """Abstract MockParent, describing an object with y, x cartesian - coordinates""" - self.one_tuple = one_tuple - - def __eq__(self, other): - return self.__dict__ == other.__dict__ - - -class MockClassx3TupleFloat: - def __init__(self, one_tuple=(0.0, 0.0), two=0.1): - self.one_tuple = one_tuple - self.two = two - - -class MockClassRelativeWidth: - def __init__(self, one, two, three): - self.one = one - self.two = two - self.three = three - - -class MockClassInf: - def __init__(self, one, two): - self.one = one - self.two = two - - -class MockComplexClass: - def __init__(self, simple: MockClassx2): - self.simple = simple - - -class MockDeferredClass: - def __init__(self, one, two): - self.one = one - self.two = two - - -class MockListClass: - def __init__(self, ls: list): - self.ls = ls - - -class MockWithFloat: - def __init__(self, value): - self.value = value - - -class MockWithTuple: - def __init__(self, tup=(0.0, 0.0)): - self.tup = tup - - -class MockOverload: - def __init__(self, one=1.0): - self.one = one - - def with_two(self, two): - self.two = two - - @property - def two(self): - return self.one * 2 - - @two.setter - def two(self, two): - self.one = two / 2 - - -class MockComponents: - def __init__( - self, - components_0: list = None, - components_1: list = None, - parameter=None, - **kwargs - ): - self.parameter = parameter - self.group_0 = components_0 - self.group_1 = components_1 - self.kwargs = kwargs - - -class MockParent: - def __init__(self, tup=(0.0, 0.0)): - self.tup = tup - - def __eq__(self, other): - return self.__dict__ == other.__dict__ - - -class MockChildTuple(MockParent): - def __init__(self, tup=(0.0, 0.0)): - """Generic circular profiles class to contain functions shared by light and - mass profiles. - - Parameters - ---------- - tup - The (y,x) coordinates of the origin of the profile. - """ - super().__init__(tup) - - -class MockChildTuplex2(MockChildTuple): - def __init__(self, tup=(0.0, 0.0), one=1.0, two=0.0): - """Generic elliptical profiles class to contain functions shared by light - and mass profiles. - - Parameters - ---------- - tup - The (y,x) coordinates of the origin of the profiles - one - Ratio of profiles ellipse's minor and major axes (b/a) - two : float - Rotational two of profiles ellipse counter-clockwise from positive x-axis - """ - super().__init__(tup) - self.one = one - self.two = two - - -class MockChildTuplex3(MockChildTuple): - def __init__(self, tup=(0.0, 0.0), one=1.0, two=0.0, three=0.0): - """Generic elliptical profiles class to contain functions shared by light - and mass profiles. - - Parameters - ---------- - tup - The (y,x) coordinates of the origin of the profiles - one - Ratio of profiles ellipse's minor and major axes (b/a) - two : float - Rotational two of profiles ellipse counter-clockwise from positive x-axis - """ - super().__init__(tup) - self.one = one - self.two = two - self.three = three - - -class Parameter: - def __init__(self, value: float = 0.5): - self.value = value - - -class WithString: - def __init__(self, arg: "Parameter"): - self.arg = arg - - -class WithConstants: - def __init__(self, constant_1, constant_2): - self.constant_1 = constant_1 - self.constant_2 = constant_2 - - -class ModelWithTupleConstant: - def __init__(self, constant=(0.0, 0.0)): - self.constant = constant +class MockClassx2: + def __init__(self, one=1, two=2): + self.one = one + self.two = two + + +class MockClassx2Instance(MockClassx2): + pass + + +class MockClassx2FormatExp: + """ + This mock classes's second parameter `two_exp` has a format label `two={:.2e}` in `notational/label_format.ini` and + is used to test latex generation. + """ + + def __init__(self, one=1, two_exp=2): + self.one = one + self.two_exp = two_exp + + +class MockClassx2NoSuperScript: + def __init__(self, one=1, two=2): + self.one = one + self.two = two + + +class MockClassx4: + def __init__(self, one=1, two=2, three=3, four=4): + self.one = one + self.two = two + self.three = three + self.four = four + + +class MockClassx3(MockClassx4): + def __init__(self, one=1, two=2, three=3): + super().__init__(one, two, three) + + +class MockClassx2Tuple: + def __init__(self, one_tuple=(0.0, 0.0)): + """Abstract MockParent, describing an object with y, x cartesian + coordinates""" + self.one_tuple = one_tuple + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + +class MockClassx3TupleFloat: + def __init__(self, one_tuple=(0.0, 0.0), two=0.1): + self.one_tuple = one_tuple + self.two = two + + +class MockClassRelativeWidth: + def __init__(self, one, two, three): + self.one = one + self.two = two + self.three = three + + +class MockClassInf: + def __init__(self, one, two): + self.one = one + self.two = two + + +class MockComplexClass: + def __init__(self, simple: MockClassx2): + self.simple = simple + + +class MockDeferredClass: + def __init__(self, one, two): + self.one = one + self.two = two + + +class MockListClass: + def __init__(self, ls: list): + self.ls = ls + + +class MockWithFloat: + def __init__(self, value): + self.value = value + + +class MockWithTuple: + def __init__(self, tup=(0.0, 0.0)): + self.tup = tup + + +class MockOverload: + def __init__(self, one=1.0): + self.one = one + + def with_two(self, two): + self.two = two + + @property + def two(self): + return self.one * 2 + + @two.setter + def two(self, two): + self.one = two / 2 + + +class MockComponents: + def __init__( + self, + components_0: list = None, + components_1: list = None, + parameter=None, + **kwargs + ): + self.parameter = parameter + self.group_0 = components_0 + self.group_1 = components_1 + self.kwargs = kwargs + + +class MockParent: + def __init__(self, tup=(0.0, 0.0)): + self.tup = tup + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + +class MockChildTuple(MockParent): + def __init__(self, tup=(0.0, 0.0)): + """Generic circular profiles class to contain functions shared by light and + mass profiles. + + Parameters + ---------- + tup + The (y,x) coordinates of the origin of the profile. + """ + super().__init__(tup) + + +class MockChildTuplex2(MockChildTuple): + def __init__(self, tup=(0.0, 0.0), one=1.0, two=0.0): + """Generic elliptical profiles class to contain functions shared by light + and mass profiles. + + Parameters + ---------- + tup + The (y,x) coordinates of the origin of the profiles + one + Ratio of profiles ellipse's minor and major axes (b/a) + two : float + Rotational two of profiles ellipse counter-clockwise from positive x-axis + """ + super().__init__(tup) + self.one = one + self.two = two + + +class MockChildTuplex3(MockChildTuple): + def __init__(self, tup=(0.0, 0.0), one=1.0, two=0.0, three=0.0): + """Generic elliptical profiles class to contain functions shared by light + and mass profiles. + + Parameters + ---------- + tup + The (y,x) coordinates of the origin of the profiles + one + Ratio of profiles ellipse's minor and major axes (b/a) + two : float + Rotational two of profiles ellipse counter-clockwise from positive x-axis + """ + super().__init__(tup) + self.one = one + self.two = two + self.three = three + + +class Parameter: + def __init__(self, value: float = 0.5): + self.value = value + + +class WithString: + def __init__(self, arg: "Parameter"): + self.arg = arg + + +class WithConstants: + def __init__(self, constant_1, constant_2): + self.constant_1 = constant_1 + self.constant_2 = constant_2 + + +class ModelWithTupleConstant: + def __init__(self, constant=(0.0, 0.0)): + self.constant = constant diff --git a/autofit/mapper/model.py b/autofit/mapper/model.py index f056bee02..41d05a5ed 100644 --- a/autofit/mapper/model.py +++ b/autofit/mapper/model.py @@ -1,548 +1,548 @@ -import copy -import logging -from functools import wraps -from typing import Optional, Union, Tuple, List, Iterable, Type, Dict - -from autofit.mapper.model_object import ModelObject -from autofit.mapper.prior_model.recursion import DynamicRecursionCache - -logger = logging.getLogger(__name__) - - -def frozen_cache(func): - """ - Decorator that caches results from function calls when - a model is frozen. - - Value is cached by function name, instance and arguments. - - Parameters - ---------- - func - Some function attached to a freezable, hashable object - that takes hashable arguments - - Returns - ------- - Function with cache - """ - - @wraps(func) - def cache(self, *args, **kwargs): - if hasattr(self, "_is_frozen") and self._is_frozen: - key = ( - func.__name__, - self, - *args, - ) + tuple(kwargs.items()) - - if key not in self._frozen_cache: - self._frozen_cache[key] = func(self, *args, **kwargs) - return self._frozen_cache[key] - return func(self, *args, **kwargs) - - return cache - - -def assert_not_frozen(func): - """ - Decorator that asserts a function is not called when an object - is frozen. For example, it should not be possible to set an - attribute on a frozen model as that might invalidate the results - in the cache. - - Parameters - ---------- - func - Some function - - Raises - ------ - AssertionError - If the function is called when the object is frozen - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - string_args = list(filter(lambda arg: isinstance(arg, str), args)) - if ( - "_is_frozen" not in string_args - and "_frozen_cache" not in string_args - and hasattr(self, "_is_frozen") - and self._is_frozen - ): - raise AssertionError("Frozen models cannot be modified") - return func(self, *args, **kwargs) - - return wrapper - - -class AbstractModel(ModelObject): - def __init__(self, label=None, id_=None): - self._is_frozen = False - self._frozen_cache = dict() - super().__init__(label=label, id_=id_) - - @classmethod - def _cached_property_names(cls) -> frozenset: - """ - Return the names of every ``cached_property``-style descriptor - declared anywhere in ``cls``'s MRO. - - Used by the ``__dict__``-iteration sites in this module and in - ``autofit/mapper/prior_model/`` to exclude cached descriptor values - from instance construction, ``ModelInstance.dict``, pickling, and - downstream JAX pytree flattening. See PyAutoFit#1300 for the - diagnosed leak this defends against. - """ - from autonerves.tools.decorators import cached_property_names - - return cached_property_names(cls) - - def __getstate__(self): - excluded = type(self)._cached_property_names() - return { - key: value - for key, value in self.__dict__.items() - if key != "_frozen_cache" and key not in excluded - } - - def __setstate__(self, state): - self.__dict__.update(state) - self._frozen_cache = {} - - def freeze(self): - """ - Freeze this object. - - A frozen object caches results for some function calls - and does not allow its state to be modified. - """ - logger.debug("Freezing model") - tuples = self.direct_tuples_with_type(AbstractModel) - for _, model in tuples: - if model is not self: - model.freeze() - self._is_frozen = True - - def unfreeze(self): - """ - Unfreeze this object. Allows modification and removes - caches associated with some functions. - """ - logger.debug("Thawing model") - self._is_frozen = False - tuples = self.direct_tuples_with_type(AbstractModel) - for _, model in tuples: - if model is not self: - model.unfreeze() - self._frozen_cache = dict() - - def __add__(self, other): - instance = self.__class__() - - def add_items(item_dict): - for key, value in item_dict.items(): - if isinstance(value, list) and hasattr(instance, key): - setattr(instance, key, getattr(instance, key) + value) - else: - setattr(instance, key, value) - - add_items(self.__dict__) - add_items(other.__dict__) - return instance - - def copy(self): - """ - Create a copy of the model. All priors remain equivalent - i.e. two - copies of a model in a collection has the same prior count as a single - model. - """ - return copy.deepcopy(self) - - def object_for_path( - self, path: Iterable[Union[str, int, type]] - ) -> Union[object, List]: - """ - Get the object at a given path. - - The path describes the location of some object in the model. - - String entries get an attribute. - Int entries index an attribute. - Type entries product a new ModelInstance which collates all of the instances - of a given type in the path. - - Parameters - ---------- - path - A tuple describing the path to an object in the model tree - - Returns - ------- - An object or Instance collating a collection of objects with a given type. - """ - instance = self - for name in path: - if isinstance(name, int): - instance = instance[name] - elif isinstance(name, type): - from autofit.mapper.prior_model.prior_model import Model - - instances = [ - instance - for _, instance in self.path_instance_tuples_for_class(name) - ] - instances += [ - instance - for _, instance in self.path_instance_tuples_for_class(Model) - if issubclass(instance.cls, name) - ] - instance = ModelInstance(instances) - else: - instance = getattr(instance, name) - return instance - - @frozen_cache - def path_instance_tuples_for_class( - self, - cls: Union[Tuple, Type], - ignore_class: Optional[type] = None, - ignore_children: bool = True, - ): - """ - Tuples containing the path tuple and instance for every instance of the class - in the model tree. - - Parameters - ---------- - ignore_class - Children of instances of this class are ignored - ignore_children - If true do not continue to recurse the children of an object once found - cls - The type to find instances of - - Returns - ------- - path_instance_tuples: [((str,), object)] - Tuples containing the path to and instance of objects of the given type. - """ - return path_instances_of_class( - self, cls, ignore_class=ignore_class, ignore_children=ignore_children - ) - - @frozen_cache - def direct_tuples_with_type(self, class_type): - return list( - filter( - lambda t: t[0] != "id" - and not t[0].startswith("_") - and isinstance(t[1], class_type), - self.__dict__.items(), - ) - ) - - @frozen_cache - def models_with_type( - self, - cls: Union[Type, Tuple[Type, ...]], - include_zero_dimension=False, - ) -> List["AbstractModel"]: - """ - Return all models of a given type in the model tree. - - Parameters - ---------- - cls - The type to find instances of - include_zero_dimension - If true, include models with zero dimensions - - Returns - ------- - A list of models of the given type - """ - # noinspection PyTypeChecker - return [ - t[1] - for t in self.model_tuples_with_type( - cls, include_zero_dimension=include_zero_dimension - ) - ] - - @frozen_cache - def model_tuples_with_type( - self, cls: Union[Type, Tuple[Type, ...]], include_zero_dimension=False - ): - """ - All models of the class in this model which have at least - one free parameter, recursively. - - Parameters - ---------- - cls - The type of the model - include_zero_dimension - If true, include models with 0 free parameters - - Returns - ------- - Models with free parameters - """ - from .prior_model.prior_model import Model - - return [ - (path, model) - for path, model in self.attribute_tuples_with_type( - Model, ignore_children=False - ) - if issubclass(model.cls, cls) - and (include_zero_dimension or model.prior_count > 0) - ] - - @frozen_cache - def attribute_tuples_with_type( - self, - class_type, - ignore_class=None, - ignore_children=True, - ) -> List[tuple]: - """ - Tuples describing the name and instance for attributes in the model - with a given type, recursively. - - Parameters - ---------- - ignore_children - If True then recursion stops at instances with the type - class_type - The type of the objects to find - ignore_class - Any classes which should not be recursively searched - - Returns - ------- - Tuples containing the name and instance of each attribute with the type - """ - return [ - (path[-1] if len(path) > 0 else "", value) - for path, value in self.path_instance_tuples_for_class( - class_type, - ignore_class=ignore_class, - ignore_children=ignore_children, - ) - ] - - -@DynamicRecursionCache() -def path_instances_of_class( - obj, - cls: type, - ignore_class: Optional[Union[type, Tuple[type]]] = None, - ignore_children: bool = False, -): - """ - Recursively search the object for instances of a given class - - Parameters - ---------- - obj - The object to recursively search - cls - The type to search for - ignore_class - A type or tuple of classes to skip - ignore_children - If true stop recursion at found objects - - Returns - ------- - instance of type - """ - if ignore_class is not None and isinstance(obj, ignore_class): - return [] - - results = [] - if isinstance(obj, cls): - results.append((tuple(), obj)) - if ignore_children: - return results - - if isinstance(obj, list): - for i, item in enumerate(obj): - for path, instance in path_instances_of_class( - item, cls, ignore_class=ignore_class, ignore_children=ignore_children - ): - results.append(((i,) + path, instance)) - return results - - try: - from autofit.mapper.prior_model.annotation import AnnotationPriorModel - - if isinstance(obj, dict): - d = obj - else: - d = obj.__dict__ - - for key, value in d.items(): - if key.startswith("_"): - continue - for item in path_instances_of_class( - value, cls, ignore_class=ignore_class, ignore_children=ignore_children - ): - if isinstance(value, AnnotationPriorModel): - path = (key,) - else: - path = (key, *item[0]) - results.append((path, item[1])) - return results - except (AttributeError, TypeError): - return results - - -class ModelInstance(AbstractModel): - """ - An instance of a Collection or Model. This is created by optimisers and correspond - to a point in the parameter space. - - @DynamicAttrs - """ - - __dictable_type__ = "instance" - - def __init__(self, child_items: Optional[Union[List, Dict]] = None, id_=None): - """ - An instance of a Collection or Model. This is created by optimisers and correspond - to a point in the parameter space. - - Parameters - ---------- - child_items - The child items of the instance. This can be a list or dict. - - If a list, the items are assigned to the instance in order. - If a dict, the items are assigned to the instance by key and accessed by attribute. - """ - super().__init__() - self.child_items = child_items - self.id = id_ - - def __eq__(self, other): - try: - return self.__dict__ == other.__dict__ - except AttributeError: - return False - - def __getitem__(self, item): - if isinstance(item, int): - return list(self.values())[item] - if isinstance(item, slice): - return ModelInstance(list(self.values())[item]) - return self.__dict__[item] - - def __setitem__(self, key, value): - self.__dict__[key] = value - - @property - def child_items(self): - return self.dict - - @child_items.setter - def child_items(self, child_items): - if isinstance(child_items, list): - for i, item in enumerate(child_items): - self[i] = item - if isinstance(child_items, dict): - for key, value in child_items.items(): - self[key] = value - - def items(self): - return self.dict.items() - - def __hash__(self): - return self.id - - @property - def dict(self): - excluded = type(self)._cached_property_names() - return { - key: value - for key, value in self.__dict__.items() - if key not in ("id", "component_number", "item_number") - and not (isinstance(key, str) and key.startswith("_")) - and key not in excluded - } - - def tree_flatten(self) -> Tuple[List, Tuple]: - """ - Flatten the instance into a PyTree - """ - keys, values = zip(*self.dict.items()) - return values, ( - *keys, - self.id, - ) - - @classmethod - def tree_unflatten( - cls, - aux_data: Tuple, - children: List, - ): - """ - Create an instance from a flattened PyTree - - Parameters - ---------- - aux_data - Auxiliary information that remains unchanged including - the keys of the dict - children - Child objects subject to change - - Returns - ------- - An instance of this class - """ - *keys, id_ = aux_data - - instance = cls(id_=id_) - - for key, value in zip(keys, children): - instance[key] = value - return instance - - def values(self): - return self.dict.values() - - def __len__(self): - return len(self.values()) - - def as_model( - self, - model_classes: Union[type, Iterable[type]] = tuple(), - excluded_classes: Union[type, Iterable[type]] = tuple(), - ): - """ - Convert this instance to a model - - Parameters - ---------- - model_classes - The classes to convert to models - excluded_classes - The classes to exclude from conversion - - Returns - ------- - A model - """ - - from autofit.mapper.prior_model.abstract import AbstractPriorModel - - return AbstractPriorModel.from_instance( - self, - model_classes, - excluded_classes, - ) +import copy +import logging +from functools import wraps +from typing import Optional, Union, Tuple, List, Iterable, Type, Dict + +from autofit.mapper.model_object import ModelObject +from autofit.mapper.prior_model.recursion import DynamicRecursionCache + +logger = logging.getLogger(__name__) + + +def frozen_cache(func): + """ + Decorator that caches results from function calls when + a model is frozen. + + Value is cached by function name, instance and arguments. + + Parameters + ---------- + func + Some function attached to a freezable, hashable object + that takes hashable arguments + + Returns + ------- + Function with cache + """ + + @wraps(func) + def cache(self, *args, **kwargs): + if hasattr(self, "_is_frozen") and self._is_frozen: + key = ( + func.__name__, + self, + *args, + ) + tuple(kwargs.items()) + + if key not in self._frozen_cache: + self._frozen_cache[key] = func(self, *args, **kwargs) + return self._frozen_cache[key] + return func(self, *args, **kwargs) + + return cache + + +def assert_not_frozen(func): + """ + Decorator that asserts a function is not called when an object + is frozen. For example, it should not be possible to set an + attribute on a frozen model as that might invalidate the results + in the cache. + + Parameters + ---------- + func + Some function + + Raises + ------ + AssertionError + If the function is called when the object is frozen + """ + + @wraps(func) + def wrapper(self, *args, **kwargs): + string_args = list(filter(lambda arg: isinstance(arg, str), args)) + if ( + "_is_frozen" not in string_args + and "_frozen_cache" not in string_args + and hasattr(self, "_is_frozen") + and self._is_frozen + ): + raise AssertionError("Frozen models cannot be modified") + return func(self, *args, **kwargs) + + return wrapper + + +class AbstractModel(ModelObject): + def __init__(self, label=None, id_=None): + self._is_frozen = False + self._frozen_cache = dict() + super().__init__(label=label, id_=id_) + + @classmethod + def _cached_property_names(cls) -> frozenset: + """ + Return the names of every ``cached_property``-style descriptor + declared anywhere in ``cls``'s MRO. + + Used by the ``__dict__``-iteration sites in this module and in + ``autofit/mapper/prior_model/`` to exclude cached descriptor values + from instance construction, ``ModelInstance.dict``, pickling, and + downstream JAX pytree flattening. See PyAutoFit#1300 for the + diagnosed leak this defends against. + """ + from autonerves.tools.decorators import cached_property_names + + return cached_property_names(cls) + + def __getstate__(self): + excluded = type(self)._cached_property_names() + return { + key: value + for key, value in self.__dict__.items() + if key != "_frozen_cache" and key not in excluded + } + + def __setstate__(self, state): + self.__dict__.update(state) + self._frozen_cache = {} + + def freeze(self): + """ + Freeze this object. + + A frozen object caches results for some function calls + and does not allow its state to be modified. + """ + logger.debug("Freezing model") + tuples = self.direct_tuples_with_type(AbstractModel) + for _, model in tuples: + if model is not self: + model.freeze() + self._is_frozen = True + + def unfreeze(self): + """ + Unfreeze this object. Allows modification and removes + caches associated with some functions. + """ + logger.debug("Thawing model") + self._is_frozen = False + tuples = self.direct_tuples_with_type(AbstractModel) + for _, model in tuples: + if model is not self: + model.unfreeze() + self._frozen_cache = dict() + + def __add__(self, other): + instance = self.__class__() + + def add_items(item_dict): + for key, value in item_dict.items(): + if isinstance(value, list) and hasattr(instance, key): + setattr(instance, key, getattr(instance, key) + value) + else: + setattr(instance, key, value) + + add_items(self.__dict__) + add_items(other.__dict__) + return instance + + def copy(self): + """ + Create a copy of the model. All priors remain equivalent - i.e. two + copies of a model in a collection has the same prior count as a single + model. + """ + return copy.deepcopy(self) + + def object_for_path( + self, path: Iterable[Union[str, int, type]] + ) -> Union[object, List]: + """ + Get the object at a given path. + + The path describes the location of some object in the model. + + String entries get an attribute. + Int entries index an attribute. + Type entries product a new ModelInstance which collates all of the instances + of a given type in the path. + + Parameters + ---------- + path + A tuple describing the path to an object in the model tree + + Returns + ------- + An object or Instance collating a collection of objects with a given type. + """ + instance = self + for name in path: + if isinstance(name, int): + instance = instance[name] + elif isinstance(name, type): + from autofit.mapper.prior_model.prior_model import Model + + instances = [ + instance + for _, instance in self.path_instance_tuples_for_class(name) + ] + instances += [ + instance + for _, instance in self.path_instance_tuples_for_class(Model) + if issubclass(instance.cls, name) + ] + instance = ModelInstance(instances) + else: + instance = getattr(instance, name) + return instance + + @frozen_cache + def path_instance_tuples_for_class( + self, + cls: Union[Tuple, Type], + ignore_class: Optional[type] = None, + ignore_children: bool = True, + ): + """ + Tuples containing the path tuple and instance for every instance of the class + in the model tree. + + Parameters + ---------- + ignore_class + Children of instances of this class are ignored + ignore_children + If true do not continue to recurse the children of an object once found + cls + The type to find instances of + + Returns + ------- + path_instance_tuples: [((str,), object)] + Tuples containing the path to and instance of objects of the given type. + """ + return path_instances_of_class( + self, cls, ignore_class=ignore_class, ignore_children=ignore_children + ) + + @frozen_cache + def direct_tuples_with_type(self, class_type): + return list( + filter( + lambda t: t[0] != "id" + and not t[0].startswith("_") + and isinstance(t[1], class_type), + self.__dict__.items(), + ) + ) + + @frozen_cache + def models_with_type( + self, + cls: Union[Type, Tuple[Type, ...]], + include_zero_dimension=False, + ) -> List["AbstractModel"]: + """ + Return all models of a given type in the model tree. + + Parameters + ---------- + cls + The type to find instances of + include_zero_dimension + If true, include models with zero dimensions + + Returns + ------- + A list of models of the given type + """ + # noinspection PyTypeChecker + return [ + t[1] + for t in self.model_tuples_with_type( + cls, include_zero_dimension=include_zero_dimension + ) + ] + + @frozen_cache + def model_tuples_with_type( + self, cls: Union[Type, Tuple[Type, ...]], include_zero_dimension=False + ): + """ + All models of the class in this model which have at least + one free parameter, recursively. + + Parameters + ---------- + cls + The type of the model + include_zero_dimension + If true, include models with 0 free parameters + + Returns + ------- + Models with free parameters + """ + from .prior_model.prior_model import Model + + return [ + (path, model) + for path, model in self.attribute_tuples_with_type( + Model, ignore_children=False + ) + if issubclass(model.cls, cls) + and (include_zero_dimension or model.prior_count > 0) + ] + + @frozen_cache + def attribute_tuples_with_type( + self, + class_type, + ignore_class=None, + ignore_children=True, + ) -> List[tuple]: + """ + Tuples describing the name and instance for attributes in the model + with a given type, recursively. + + Parameters + ---------- + ignore_children + If True then recursion stops at instances with the type + class_type + The type of the objects to find + ignore_class + Any classes which should not be recursively searched + + Returns + ------- + Tuples containing the name and instance of each attribute with the type + """ + return [ + (path[-1] if len(path) > 0 else "", value) + for path, value in self.path_instance_tuples_for_class( + class_type, + ignore_class=ignore_class, + ignore_children=ignore_children, + ) + ] + + +@DynamicRecursionCache() +def path_instances_of_class( + obj, + cls: type, + ignore_class: Optional[Union[type, Tuple[type]]] = None, + ignore_children: bool = False, +): + """ + Recursively search the object for instances of a given class + + Parameters + ---------- + obj + The object to recursively search + cls + The type to search for + ignore_class + A type or tuple of classes to skip + ignore_children + If true stop recursion at found objects + + Returns + ------- + instance of type + """ + if ignore_class is not None and isinstance(obj, ignore_class): + return [] + + results = [] + if isinstance(obj, cls): + results.append((tuple(), obj)) + if ignore_children: + return results + + if isinstance(obj, list): + for i, item in enumerate(obj): + for path, instance in path_instances_of_class( + item, cls, ignore_class=ignore_class, ignore_children=ignore_children + ): + results.append(((i,) + path, instance)) + return results + + try: + from autofit.mapper.prior_model.annotation import AnnotationPriorModel + + if isinstance(obj, dict): + d = obj + else: + d = obj.__dict__ + + for key, value in d.items(): + if key.startswith("_"): + continue + for item in path_instances_of_class( + value, cls, ignore_class=ignore_class, ignore_children=ignore_children + ): + if isinstance(value, AnnotationPriorModel): + path = (key,) + else: + path = (key, *item[0]) + results.append((path, item[1])) + return results + except (AttributeError, TypeError): + return results + + +class ModelInstance(AbstractModel): + """ + An instance of a Collection or Model. This is created by optimisers and correspond + to a point in the parameter space. + + @DynamicAttrs + """ + + __dictable_type__ = "instance" + + def __init__(self, child_items: Optional[Union[List, Dict]] = None, id_=None): + """ + An instance of a Collection or Model. This is created by optimisers and correspond + to a point in the parameter space. + + Parameters + ---------- + child_items + The child items of the instance. This can be a list or dict. + + If a list, the items are assigned to the instance in order. + If a dict, the items are assigned to the instance by key and accessed by attribute. + """ + super().__init__() + self.child_items = child_items + self.id = id_ + + def __eq__(self, other): + try: + return self.__dict__ == other.__dict__ + except AttributeError: + return False + + def __getitem__(self, item): + if isinstance(item, int): + return list(self.values())[item] + if isinstance(item, slice): + return ModelInstance(list(self.values())[item]) + return self.__dict__[item] + + def __setitem__(self, key, value): + self.__dict__[key] = value + + @property + def child_items(self): + return self.dict + + @child_items.setter + def child_items(self, child_items): + if isinstance(child_items, list): + for i, item in enumerate(child_items): + self[i] = item + if isinstance(child_items, dict): + for key, value in child_items.items(): + self[key] = value + + def items(self): + return self.dict.items() + + def __hash__(self): + return self.id + + @property + def dict(self): + excluded = type(self)._cached_property_names() + return { + key: value + for key, value in self.__dict__.items() + if key not in ("id", "component_number", "item_number") + and not (isinstance(key, str) and key.startswith("_")) + and key not in excluded + } + + def tree_flatten(self) -> Tuple[List, Tuple]: + """ + Flatten the instance into a PyTree + """ + keys, values = zip(*self.dict.items()) + return values, ( + *keys, + self.id, + ) + + @classmethod + def tree_unflatten( + cls, + aux_data: Tuple, + children: List, + ): + """ + Create an instance from a flattened PyTree + + Parameters + ---------- + aux_data + Auxiliary information that remains unchanged including + the keys of the dict + children + Child objects subject to change + + Returns + ------- + An instance of this class + """ + *keys, id_ = aux_data + + instance = cls(id_=id_) + + for key, value in zip(keys, children): + instance[key] = value + return instance + + def values(self): + return self.dict.values() + + def __len__(self): + return len(self.values()) + + def as_model( + self, + model_classes: Union[type, Iterable[type]] = tuple(), + excluded_classes: Union[type, Iterable[type]] = tuple(), + ): + """ + Convert this instance to a model + + Parameters + ---------- + model_classes + The classes to convert to models + excluded_classes + The classes to exclude from conversion + + Returns + ------- + A model + """ + + from autofit.mapper.prior_model.abstract import AbstractPriorModel + + return AbstractPriorModel.from_instance( + self, + model_classes, + excluded_classes, + ) diff --git a/autofit/mapper/model_mapper.py b/autofit/mapper/model_mapper.py index 93f9f82e8..e8a0e677e 100644 --- a/autofit/mapper/model_mapper.py +++ b/autofit/mapper/model_mapper.py @@ -1,83 +1,83 @@ -from pathlib import Path - -from autofit.mapper.prior_model.collection import Collection - -path = Path(__file__).resolve().parent - - -class ModelMapper(Collection): - """ - A mapper of priors formed by passing in classes to be reconstructed - - @DynamicAttrs - - The ModelMapper converts a set of classes whose input attributes may be - modeled using a non-linear search, to parameters with priors attached. - - A config is passed into the model mapper to provide default setup values for - the priors: - - mapper = ModelMapper(config) - - All class instances that are to be generated by the model mapper are - specified by adding classes to it: - - mapper = ModelMapper() - - mapper.sersic = al.lp.AbstractSersic - mapper.gaussian = al.lp.Gaussian - mapper.any_class = SomeClass - - A `Model` instance is created each time we add a class to the mapper. We - can access those models using # the mapper attributes: - - sersic_model = mapper.sersic - - This allows us to replace the default priors: - - mapper.sersic.normalization = GaussianPrior(mean=2., sigma=5.) - - Or maybe we want to tie two priors together: - - mapper.sersic.two = mapper.other_sersic.two - - This statement reduces the number of priors by one and means that the two - sersic instances will always share # the same rotation two two. - - We can then create instances of every class for a unit hypercube vector - with length equal to # len(mapper.priors): - - model_instance = mapper.model_instance_for_vector([.4, .2, .3, .1]) - - The attributes of the model_instance are named the same as those of the mapper: - - sersic_1 = mapper.sersic_1 - - But this attribute is an instance of the actual AbstractSersic:P - class - - A ModelMapper can be concisely constructed using keyword arguments: - - mapper = prior.ModelMapper( - source_light_profile=light_profile.AbstractSersic, - lens_mass_profile=mass_profile.IsothermalCore, - lens_light_profile=light_profile.SersicCore - ) - """ - - @property - def prior_prior_model_dict(self): - """ - - Returns - ------- - prior_prior_model_dict: {Prior: Model} - A dictionary mapping priors to associated prior models. Each prior will only - have one prior model; if a prior is shared by two prior models then one of - those prior models will be in this dictionary. - """ - return { - prior: prior_model[1] - for prior_model in self.prior_model_tuples - for _, prior in prior_model[1].prior_tuples - } +from pathlib import Path + +from autofit.mapper.prior_model.collection import Collection + +path = Path(__file__).resolve().parent + + +class ModelMapper(Collection): + """ + A mapper of priors formed by passing in classes to be reconstructed + + @DynamicAttrs + + The ModelMapper converts a set of classes whose input attributes may be + modeled using a non-linear search, to parameters with priors attached. + + A config is passed into the model mapper to provide default setup values for + the priors: + + mapper = ModelMapper(config) + + All class instances that are to be generated by the model mapper are + specified by adding classes to it: + + mapper = ModelMapper() + + mapper.sersic = al.lp.AbstractSersic + mapper.gaussian = al.lp.Gaussian + mapper.any_class = SomeClass + + A `Model` instance is created each time we add a class to the mapper. We + can access those models using # the mapper attributes: + + sersic_model = mapper.sersic + + This allows us to replace the default priors: + + mapper.sersic.normalization = GaussianPrior(mean=2., sigma=5.) + + Or maybe we want to tie two priors together: + + mapper.sersic.two = mapper.other_sersic.two + + This statement reduces the number of priors by one and means that the two + sersic instances will always share # the same rotation two two. + + We can then create instances of every class for a unit hypercube vector + with length equal to # len(mapper.priors): + + model_instance = mapper.model_instance_for_vector([.4, .2, .3, .1]) + + The attributes of the model_instance are named the same as those of the mapper: + + sersic_1 = mapper.sersic_1 + + But this attribute is an instance of the actual AbstractSersic:P + class + + A ModelMapper can be concisely constructed using keyword arguments: + + mapper = prior.ModelMapper( + source_light_profile=light_profile.AbstractSersic, + lens_mass_profile=mass_profile.IsothermalCore, + lens_light_profile=light_profile.SersicCore + ) + """ + + @property + def prior_prior_model_dict(self): + """ + + Returns + ------- + prior_prior_model_dict: {Prior: Model} + A dictionary mapping priors to associated prior models. Each prior will only + have one prior model; if a prior is shared by two prior models then one of + those prior models will be in this dictionary. + """ + return { + prior: prior_model[1] + for prior_model in self.prior_model_tuples + for _, prior in prior_model[1].prior_tuples + } diff --git a/autofit/mapper/model_object.py b/autofit/mapper/model_object.py index 3f76e6165..3974c9a61 100644 --- a/autofit/mapper/model_object.py +++ b/autofit/mapper/model_object.py @@ -1,349 +1,349 @@ -import copy -import itertools -from typing import Type, Union, Tuple, Optional, Dict -import logging - -from autonerves.class_path import get_class -from autonerves.dictable import from_dict, to_dict -from .identifier import Identifier - -logger = logging.getLogger(__name__) - - -def dereference(reference: Optional[dict], name: str): - if reference is None: - return None - updated = {} - for key, value in reference.items(): - array = key.split(".") - if array[0] == name: - updated[".".join(array[1:])] = value - return updated - - -class ModelObject: - _ids = itertools.count() - - @classmethod - def next_id(cls): - return next(cls._ids) - - def __init__( - self, - id_=None, - label=None, - ): - """ - A generic object in AutoFit - - Parameters - ---------- - id_ - A unique integer identifier. This is used to hash and order priors. - label - A label which can optionally be set for visualising this object in a - graph. - """ - self.id = int(self.next_id() if id_ is None else id_) - self._label = label - - def replacing_for_path(self, path: Tuple[str, ...], value) -> "ModelObject": - """ - Create a new model replacing the value for a given path with a new value - - Parameters - ---------- - path - A path indicating the sequence of names used to address an object - value - A value that should replace the object at the given path - - Returns - ------- - A copy of this with an updated value - """ - new = copy.deepcopy(self) - obj = new - for key in path[:-1]: - if isinstance(key, int): - obj = obj[key] - else: - obj = getattr(obj, key) - - key = path[-1] - if isinstance(key, int): - obj[key] = value - else: - setattr(obj, key, value) - return new - - def has(self, cls: Union[Type, Tuple[Type, ...]]) -> bool: - """ - Does this instance have an attribute which is of type cls? - """ - for value in self.__dict__.values(): - if isinstance(value, cls): - return True - return False - - @property - def label(self): - return self._label - - @label.setter - def label(self, label): - self._label = label - - @property - def component_number(self): - return self.id - - def __hash__(self): - return self.id - - def __eq__(self, other): - try: - return self.id == other.id - except AttributeError: - return False - - @property - def identifier(self): - return str(Identifier(self)) - - @classmethod - def from_dict( - cls, - d, - reference: Optional[Dict[str, str]] = None, - loaded_ids: Optional[dict] = None, - ): - """ - Recursively parse a dictionary returning the model, collection or - instance that is represents. - - Parameters - ---------- - d - A dictionary representation of some object - reference - An optional dictionary mapping names to class paths. This is used - to specify the type of a model or instance. - - Maps paths to class paths. For example: - "path.in.model": "path.to.Class" - - In this case, the class path "path.to.Class" will be used to - instantiate the object at "path.in.model". If no class path is - specified, or no type can be found for the class path in 'd', then - a Collection will be used as a placeholder. - - This is used to specify the type of a model or instance. - loaded_ids - A dictionary mapping ids to instances. This is used to ensure that - all instances with the same id are the same object. - - Returns - ------- - An instance - """ - from autofit.mapper.prior_model.collection import Collection - from autofit.mapper.prior_model.prior_model import Model - from autofit.mapper.prior.abstract import Prior - from autofit.mapper.prior.tuple_prior import TuplePrior - from autofit.mapper.prior.arithmetic.compound import Compound - from autofit.mapper.prior.arithmetic.compound import ModifiedPrior - from .prior.constant import Constant - - if isinstance(d, list): - return [ - from_dict( - value, - reference=dereference(reference, str(index)), - loaded_ids=loaded_ids, - ) - for index, value in enumerate(d) - ] - - if not isinstance(d, dict): - return d - - loaded_ids = {} if loaded_ids is None else loaded_ids - - type_ = d["type"] - - def get_class_path(): - try: - return reference[""] - except (KeyError, TypeError): - return d.pop("class_path") - - if type_ == "model": - class_path = get_class_path() - try: - instance = Model(get_class(class_path)) - except (ModuleNotFoundError, AttributeError): - logger.warning( - f"Could not find type for class path {class_path}. Defaulting to Collection placeholder." - ) - instance = Collection() - elif type == "constant": - return Constant(value=d["value"]) - elif type_ == "collection": - instance = Collection() - elif type_ == "tuple_prior": - instance = TuplePrior() - elif type_ == "compound": - return Compound.from_dict( - d, - reference=dereference(reference, "assertion"), - loaded_ids=loaded_ids, - ) - elif type_ == "modified": - return ModifiedPrior.from_dict( - d, - reference=dereference(reference, "prior"), - loaded_ids=loaded_ids, - ) - elif type_ == "dict": - return { - key: from_dict( - value, - reference=dereference(reference, key), - loaded_ids=loaded_ids, - ) - for key, value in d["arguments"].items() - if value is not None - } - elif type_ == "instance": - class_path = get_class_path() - try: - cls_ = get_class(class_path) - # noinspection PyArgumentList - return cls_( - **{ - key: from_dict( - value, - reference=dereference(reference, key), - loaded_ids=loaded_ids, - ) - for key, value in d["arguments"].items() - } - ) - except (ModuleNotFoundError, AttributeError): - from autofit.mapper.model import ModelInstance - - logger.warning( - f"Could not find type for class path {class_path}. Defaulting to Instance placeholder." - ) - instance = ModelInstance() - elif type_ == "array": - from autofit.mapper.prior_model.array import Array - - return Array.from_dict(d) - else: - try: - return Prior.from_dict(d, loaded_ids=loaded_ids) - except KeyError: - cls_ = get_class(type_) - instance = object.__new__(cls_) - - for key, value in d["arguments"].items(): - try: - setattr( - instance, - key, - from_dict( - value, - reference=dereference(reference, key), - loaded_ids=loaded_ids, - ), - ) - except KeyError: - pass - - if "assertions" in d: - instance.assertions = [ - from_dict( - value, - reference=dereference(reference, "assertions"), - loaded_ids=loaded_ids, - ) - for value in d["assertions"] - ] - - return instance - - def dict(self) -> dict: - """ - A dictionary representation of this object - """ - from autofit.mapper.prior_model.abstract import AbstractPriorModel - from autofit.mapper.prior_model.collection import Collection - from autofit.mapper.prior_model.prior_model import Model - from autofit.mapper.prior.tuple_prior import TuplePrior - from autofit.mapper.prior_model.array import Array - from autofit.mapper.prior.constant import Constant - - if isinstance(self, Collection): - type_ = "collection" - elif isinstance(self, AbstractPriorModel) and self.prior_count == 0: - type_ = "instance" - elif isinstance(self, Model): - type_ = "model" - elif isinstance(self, TuplePrior): - type_ = "tuple_prior" - elif isinstance(self, Array): - type_ = "array" - elif isinstance(self, Constant): - type_ = "constant" - else: - raise AssertionError( - f"{self.__class__.__name__} cannot be serialised to dict" - ) - - try: - assertions = [assertion.dict() for assertion in self._assertions] - except AttributeError: - assertions = [] - - dict_ = { - "type": type_, - } - - if assertions: - dict_["assertions"] = assertions - - arguments = {} - - for key, value in self._dict.items(): - try: - value = to_dict(value) - except AttributeError: - pass - except TypeError: - pass - arguments[key] = value - - dict_["arguments"] = arguments - return dict_ - - @property - def _dict(self): - # Pick up any cached_property descriptors declared on the class so - # their cached values don't propagate via `Collection.items()` (which - # delegates here) or any other downstream consumer. The lookup is - # gated on hasattr because ModelObject is the base for the whole - # mapper module: a few non-AbstractModel descendants do not carry the - # ``_cached_property_names`` classmethod. - try: - excluded = type(self)._cached_property_names() - except AttributeError: - excluded = frozenset() - return { - key: value - for key, value in self.__dict__.items() - if key not in ("component_number", "item_number", "id", "cls", "label") - and not key.startswith("_") - and key not in excluded - } +import copy +import itertools +from typing import Type, Union, Tuple, Optional, Dict +import logging + +from autonerves.class_path import get_class +from autonerves.dictable import from_dict, to_dict +from .identifier import Identifier + +logger = logging.getLogger(__name__) + + +def dereference(reference: Optional[dict], name: str): + if reference is None: + return None + updated = {} + for key, value in reference.items(): + array = key.split(".") + if array[0] == name: + updated[".".join(array[1:])] = value + return updated + + +class ModelObject: + _ids = itertools.count() + + @classmethod + def next_id(cls): + return next(cls._ids) + + def __init__( + self, + id_=None, + label=None, + ): + """ + A generic object in AutoFit + + Parameters + ---------- + id_ + A unique integer identifier. This is used to hash and order priors. + label + A label which can optionally be set for visualising this object in a + graph. + """ + self.id = int(self.next_id() if id_ is None else id_) + self._label = label + + def replacing_for_path(self, path: Tuple[str, ...], value) -> "ModelObject": + """ + Create a new model replacing the value for a given path with a new value + + Parameters + ---------- + path + A path indicating the sequence of names used to address an object + value + A value that should replace the object at the given path + + Returns + ------- + A copy of this with an updated value + """ + new = copy.deepcopy(self) + obj = new + for key in path[:-1]: + if isinstance(key, int): + obj = obj[key] + else: + obj = getattr(obj, key) + + key = path[-1] + if isinstance(key, int): + obj[key] = value + else: + setattr(obj, key, value) + return new + + def has(self, cls: Union[Type, Tuple[Type, ...]]) -> bool: + """ + Does this instance have an attribute which is of type cls? + """ + for value in self.__dict__.values(): + if isinstance(value, cls): + return True + return False + + @property + def label(self): + return self._label + + @label.setter + def label(self, label): + self._label = label + + @property + def component_number(self): + return self.id + + def __hash__(self): + return self.id + + def __eq__(self, other): + try: + return self.id == other.id + except AttributeError: + return False + + @property + def identifier(self): + return str(Identifier(self)) + + @classmethod + def from_dict( + cls, + d, + reference: Optional[Dict[str, str]] = None, + loaded_ids: Optional[dict] = None, + ): + """ + Recursively parse a dictionary returning the model, collection or + instance that is represents. + + Parameters + ---------- + d + A dictionary representation of some object + reference + An optional dictionary mapping names to class paths. This is used + to specify the type of a model or instance. + + Maps paths to class paths. For example: + "path.in.model": "path.to.Class" + + In this case, the class path "path.to.Class" will be used to + instantiate the object at "path.in.model". If no class path is + specified, or no type can be found for the class path in 'd', then + a Collection will be used as a placeholder. + + This is used to specify the type of a model or instance. + loaded_ids + A dictionary mapping ids to instances. This is used to ensure that + all instances with the same id are the same object. + + Returns + ------- + An instance + """ + from autofit.mapper.prior_model.collection import Collection + from autofit.mapper.prior_model.prior_model import Model + from autofit.mapper.prior.abstract import Prior + from autofit.mapper.prior.tuple_prior import TuplePrior + from autofit.mapper.prior.arithmetic.compound import Compound + from autofit.mapper.prior.arithmetic.compound import ModifiedPrior + from .prior.constant import Constant + + if isinstance(d, list): + return [ + from_dict( + value, + reference=dereference(reference, str(index)), + loaded_ids=loaded_ids, + ) + for index, value in enumerate(d) + ] + + if not isinstance(d, dict): + return d + + loaded_ids = {} if loaded_ids is None else loaded_ids + + type_ = d["type"] + + def get_class_path(): + try: + return reference[""] + except (KeyError, TypeError): + return d.pop("class_path") + + if type_ == "model": + class_path = get_class_path() + try: + instance = Model(get_class(class_path)) + except (ModuleNotFoundError, AttributeError): + logger.warning( + f"Could not find type for class path {class_path}. Defaulting to Collection placeholder." + ) + instance = Collection() + elif type == "constant": + return Constant(value=d["value"]) + elif type_ == "collection": + instance = Collection() + elif type_ == "tuple_prior": + instance = TuplePrior() + elif type_ == "compound": + return Compound.from_dict( + d, + reference=dereference(reference, "assertion"), + loaded_ids=loaded_ids, + ) + elif type_ == "modified": + return ModifiedPrior.from_dict( + d, + reference=dereference(reference, "prior"), + loaded_ids=loaded_ids, + ) + elif type_ == "dict": + return { + key: from_dict( + value, + reference=dereference(reference, key), + loaded_ids=loaded_ids, + ) + for key, value in d["arguments"].items() + if value is not None + } + elif type_ == "instance": + class_path = get_class_path() + try: + cls_ = get_class(class_path) + # noinspection PyArgumentList + return cls_( + **{ + key: from_dict( + value, + reference=dereference(reference, key), + loaded_ids=loaded_ids, + ) + for key, value in d["arguments"].items() + } + ) + except (ModuleNotFoundError, AttributeError): + from autofit.mapper.model import ModelInstance + + logger.warning( + f"Could not find type for class path {class_path}. Defaulting to Instance placeholder." + ) + instance = ModelInstance() + elif type_ == "array": + from autofit.mapper.prior_model.array import Array + + return Array.from_dict(d) + else: + try: + return Prior.from_dict(d, loaded_ids=loaded_ids) + except KeyError: + cls_ = get_class(type_) + instance = object.__new__(cls_) + + for key, value in d["arguments"].items(): + try: + setattr( + instance, + key, + from_dict( + value, + reference=dereference(reference, key), + loaded_ids=loaded_ids, + ), + ) + except KeyError: + pass + + if "assertions" in d: + instance.assertions = [ + from_dict( + value, + reference=dereference(reference, "assertions"), + loaded_ids=loaded_ids, + ) + for value in d["assertions"] + ] + + return instance + + def dict(self) -> dict: + """ + A dictionary representation of this object + """ + from autofit.mapper.prior_model.abstract import AbstractPriorModel + from autofit.mapper.prior_model.collection import Collection + from autofit.mapper.prior_model.prior_model import Model + from autofit.mapper.prior.tuple_prior import TuplePrior + from autofit.mapper.prior_model.array import Array + from autofit.mapper.prior.constant import Constant + + if isinstance(self, Collection): + type_ = "collection" + elif isinstance(self, AbstractPriorModel) and self.prior_count == 0: + type_ = "instance" + elif isinstance(self, Model): + type_ = "model" + elif isinstance(self, TuplePrior): + type_ = "tuple_prior" + elif isinstance(self, Array): + type_ = "array" + elif isinstance(self, Constant): + type_ = "constant" + else: + raise AssertionError( + f"{self.__class__.__name__} cannot be serialised to dict" + ) + + try: + assertions = [assertion.dict() for assertion in self._assertions] + except AttributeError: + assertions = [] + + dict_ = { + "type": type_, + } + + if assertions: + dict_["assertions"] = assertions + + arguments = {} + + for key, value in self._dict.items(): + try: + value = to_dict(value) + except AttributeError: + pass + except TypeError: + pass + arguments[key] = value + + dict_["arguments"] = arguments + return dict_ + + @property + def _dict(self): + # Pick up any cached_property descriptors declared on the class so + # their cached values don't propagate via `Collection.items()` (which + # delegates here) or any other downstream consumer. The lookup is + # gated on hasattr because ModelObject is the base for the whole + # mapper module: a few non-AbstractModel descendants do not carry the + # ``_cached_property_names`` classmethod. + try: + excluded = type(self)._cached_property_names() + except AttributeError: + excluded = frozenset() + return { + key: value + for key, value in self.__dict__.items() + if key not in ("component_number", "item_number", "id", "cls", "label") + and not key.startswith("_") + and key not in excluded + } diff --git a/autofit/mapper/prior/arithmetic/arithmetic.py b/autofit/mapper/prior/arithmetic/arithmetic.py index 5a9745b74..e8ca031f9 100644 --- a/autofit/mapper/prior/arithmetic/arithmetic.py +++ b/autofit/mapper/prior/arithmetic/arithmetic.py @@ -1,212 +1,212 @@ -class ArithmeticMixin: - def __add__(self, other): - """ - Add this object to another object. Addition occurs - after priors have been converted into values. - - Parameters - ---------- - other - - Returns - ------- - An object comprising two objects to be summed after - realisation - """ - from autofit.mapper.prior.arithmetic.compound import SumPrior - return SumPrior( - self, other - ) - - def __floordiv__(self, other): - from autofit.mapper.prior.arithmetic.compound import FloorDivPrior - return FloorDivPrior(self, other) - - def __rfloordiv__(self, other): - from autofit.mapper.prior.arithmetic.compound import FloorDivPrior - return FloorDivPrior(other, self) - - def __abs__(self): - from autofit.mapper.prior.arithmetic.compound import AbsolutePrior - return AbsolutePrior(self) - - def __truediv__(self, other): - from autofit.mapper.prior.arithmetic.compound import DivisionPrior - return DivisionPrior(self, other) - - def __rtruediv__(self, other): - from autofit.mapper.prior.arithmetic.compound import DivisionPrior - return DivisionPrior(other, self) - - def __pow__(self, other): - from autofit.mapper.prior.arithmetic.compound import PowerPrior - return PowerPrior(self, other) - - def __rpow__(self, other): - from autofit.mapper.prior.arithmetic.compound import PowerPrior - return PowerPrior(other, self) - - def __mod__(self, other): - from autofit.mapper.prior.arithmetic.compound import ModPrior - return ModPrior(self, other) - - def __rmod__(self, other): - from autofit.mapper.prior.arithmetic.compound import ModPrior - return ModPrior(other, self) - - def __radd__(self, other): - """ - Add this object to another object. Addition occurs - after priors have been converted into values. - - Parameters - ---------- - other - - Returns - ------- - An object comprising two objects to be summed after - realisation - """ - from autofit.mapper.prior.arithmetic.compound import SumPrior - return SumPrior( - other, self - ) - - def __sub__(self, other): - """ - Subtract another object from this object. Subtraction - occurs after priors have been converted into values. - - Parameters - ---------- - other - - Returns - ------- - An object comprising two objects to be summed after - realisation - """ - return self + (-other) - - def __rsub__(self, other): - return (-self) + other - - def __neg__(self): - """ - Returns an object representing the negation of this - object. - """ - from autofit.mapper.prior.arithmetic.compound import NegativePrior - return NegativePrior( - self - ) - - def __mul__(self, other): - """ - Multiple another object by this object. Multiplication - occurs after priors have been converted into values. - - Parameters - ---------- - other - - Returns - ------- - An object comprising two objects to be multiplied after - realisation - """ - from autofit.mapper.prior.arithmetic.compound import MultiplePrior - return MultiplePrior( - self, other - ) - - def __rmul__(self, other): - from autofit.mapper.prior.arithmetic.compound import MultiplePrior - return MultiplePrior( - other, self - ) - - def __gt__(self, other_prior): - """ - Add an assertion that values associated with this prior are greater. - - Parameters - ---------- - other_prior - Another prior which is associated with a field that should always have - lower physical values. - - Returns - ------- - An assertion object - """ - from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanAssertion, unwrap - # noinspection PyTypeChecker - return GreaterThanLessThanAssertion( - greater=unwrap(self), - lower=unwrap(other_prior) - ) - - def __lt__(self, other_prior): - """ - Add an assertion that values associated with this prior are lower. - - Parameters - ---------- - other_prior - Another prior which is associated with a field that should always have - greater physical values. - - Returns - ------- - An assertion object - """ - from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanAssertion, unwrap - # noinspection PyTypeChecker - return GreaterThanLessThanAssertion( - lower=unwrap(self), - greater=unwrap(other_prior) - ) - - def __ge__(self, other_prior): - """ - Add an assertion that values associated with this prior are greater or equal. - - Parameters - ---------- - other_prior - Another prior which is associated with a field that should always have - lower physical values. - - Returns - ------- - An assertion object - """ - from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanEqualAssertion, unwrap - # noinspection PyTypeChecker - return GreaterThanLessThanEqualAssertion( - greater=unwrap(self), - lower=unwrap(other_prior) - ) - - def __le__(self, other_prior): - """ - Add an assertion that values associated with this prior are lower or equal. - - Parameters - ---------- - other_prior - Another prior which is associated with a field that should always have - greater physical values. - - Returns - ------- - An assertion object - """ - from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanEqualAssertion, unwrap - # noinspection PyTypeChecker - return GreaterThanLessThanEqualAssertion( - lower=unwrap(self), - greater=unwrap(other_prior) - ) +class ArithmeticMixin: + def __add__(self, other): + """ + Add this object to another object. Addition occurs + after priors have been converted into values. + + Parameters + ---------- + other + + Returns + ------- + An object comprising two objects to be summed after + realisation + """ + from autofit.mapper.prior.arithmetic.compound import SumPrior + return SumPrior( + self, other + ) + + def __floordiv__(self, other): + from autofit.mapper.prior.arithmetic.compound import FloorDivPrior + return FloorDivPrior(self, other) + + def __rfloordiv__(self, other): + from autofit.mapper.prior.arithmetic.compound import FloorDivPrior + return FloorDivPrior(other, self) + + def __abs__(self): + from autofit.mapper.prior.arithmetic.compound import AbsolutePrior + return AbsolutePrior(self) + + def __truediv__(self, other): + from autofit.mapper.prior.arithmetic.compound import DivisionPrior + return DivisionPrior(self, other) + + def __rtruediv__(self, other): + from autofit.mapper.prior.arithmetic.compound import DivisionPrior + return DivisionPrior(other, self) + + def __pow__(self, other): + from autofit.mapper.prior.arithmetic.compound import PowerPrior + return PowerPrior(self, other) + + def __rpow__(self, other): + from autofit.mapper.prior.arithmetic.compound import PowerPrior + return PowerPrior(other, self) + + def __mod__(self, other): + from autofit.mapper.prior.arithmetic.compound import ModPrior + return ModPrior(self, other) + + def __rmod__(self, other): + from autofit.mapper.prior.arithmetic.compound import ModPrior + return ModPrior(other, self) + + def __radd__(self, other): + """ + Add this object to another object. Addition occurs + after priors have been converted into values. + + Parameters + ---------- + other + + Returns + ------- + An object comprising two objects to be summed after + realisation + """ + from autofit.mapper.prior.arithmetic.compound import SumPrior + return SumPrior( + other, self + ) + + def __sub__(self, other): + """ + Subtract another object from this object. Subtraction + occurs after priors have been converted into values. + + Parameters + ---------- + other + + Returns + ------- + An object comprising two objects to be summed after + realisation + """ + return self + (-other) + + def __rsub__(self, other): + return (-self) + other + + def __neg__(self): + """ + Returns an object representing the negation of this + object. + """ + from autofit.mapper.prior.arithmetic.compound import NegativePrior + return NegativePrior( + self + ) + + def __mul__(self, other): + """ + Multiple another object by this object. Multiplication + occurs after priors have been converted into values. + + Parameters + ---------- + other + + Returns + ------- + An object comprising two objects to be multiplied after + realisation + """ + from autofit.mapper.prior.arithmetic.compound import MultiplePrior + return MultiplePrior( + self, other + ) + + def __rmul__(self, other): + from autofit.mapper.prior.arithmetic.compound import MultiplePrior + return MultiplePrior( + other, self + ) + + def __gt__(self, other_prior): + """ + Add an assertion that values associated with this prior are greater. + + Parameters + ---------- + other_prior + Another prior which is associated with a field that should always have + lower physical values. + + Returns + ------- + An assertion object + """ + from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanAssertion, unwrap + # noinspection PyTypeChecker + return GreaterThanLessThanAssertion( + greater=unwrap(self), + lower=unwrap(other_prior) + ) + + def __lt__(self, other_prior): + """ + Add an assertion that values associated with this prior are lower. + + Parameters + ---------- + other_prior + Another prior which is associated with a field that should always have + greater physical values. + + Returns + ------- + An assertion object + """ + from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanAssertion, unwrap + # noinspection PyTypeChecker + return GreaterThanLessThanAssertion( + lower=unwrap(self), + greater=unwrap(other_prior) + ) + + def __ge__(self, other_prior): + """ + Add an assertion that values associated with this prior are greater or equal. + + Parameters + ---------- + other_prior + Another prior which is associated with a field that should always have + lower physical values. + + Returns + ------- + An assertion object + """ + from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanEqualAssertion, unwrap + # noinspection PyTypeChecker + return GreaterThanLessThanEqualAssertion( + greater=unwrap(self), + lower=unwrap(other_prior) + ) + + def __le__(self, other_prior): + """ + Add an assertion that values associated with this prior are lower or equal. + + Parameters + ---------- + other_prior + Another prior which is associated with a field that should always have + greater physical values. + + Returns + ------- + An assertion object + """ + from autofit.mapper.prior.arithmetic.assertion import GreaterThanLessThanEqualAssertion, unwrap + # noinspection PyTypeChecker + return GreaterThanLessThanEqualAssertion( + lower=unwrap(self), + greater=unwrap(other_prior) + ) diff --git a/autofit/mapper/prior/arithmetic/assertion.py b/autofit/mapper/prior/arithmetic/assertion.py index b23a8c7a4..996c77a1f 100644 --- a/autofit/mapper/prior/arithmetic/assertion.py +++ b/autofit/mapper/prior/arithmetic/assertion.py @@ -1,130 +1,130 @@ -from abc import ABC -import numpy as np -from typing import Optional, Dict - -from autofit.mapper.prior.arithmetic.compound import CompoundPrior, Compound -from autofit.mapper.prior_model.abstract import AbstractPriorModel - - -class ComparisonAssertion(CompoundPrior, Compound, ABC): - def __init__(self, lower, greater, name=""): - super().__init__(lower, greater) - self._name = name - - def __gt__(self, other): - return CompoundAssertion(self, self._left > other) - - def __lt__(self, other): - return CompoundAssertion(self, self._right < other) - - def __ge__(self, other): - return CompoundAssertion(self, self._left >= other) - - def __le__(self, other): - return CompoundAssertion(self, self._right <= other) - - -class GreaterThanLessThanAssertion(ComparisonAssertion): - def _instance_for_arguments(self, arguments, ignore_assertions=False, xp=np): - """ - Assert that the value in the dictionary associated with the lower - prior is lower than the value associated with the greater prior. - - Parameters - ---------- - arguments - A dictionary mapping priors to physical values. - - Raises - ------ - FitException - If the assertion is not met - """ - lower = self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - greater = self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - return lower < greater - - -class GreaterThanLessThanEqualAssertion(ComparisonAssertion): - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - """ - Assert that the value in the dictionary associated with the lower - prior is lower than the value associated with the greater prior. - - Parameters - ---------- - arguments - A dictionary mapping priors to physical values. - - Raises - ------ - FitException - If the assertion is not met - """ - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) <= self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class CompoundAssertion(AbstractPriorModel, Compound): - def __init__(self, assertion_1, assertion_2, name=""): - super().__init__() - self.assertion_1 = assertion_1 - self.assertion_2 = assertion_2 - self._name = name - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.assertion_1.instance_for_arguments( - arguments, - ignore_assertions, - ) and self.assertion_2.instance_for_arguments( - arguments, - ignore_assertions, - ) - - def dict(self) -> dict: - return { - "type": "compound", - "compound_type": self.__class__.__name__, - "assertion_1": self.assertion_1.dict(), - "assertion_2": self.assertion_2.dict(), - } - - @classmethod - def from_dict( - cls, - d, - reference: Optional[Dict[str, str]] = None, - loaded_ids: Optional[dict] = None, - ): - return cls( - Compound.from_dict(d["assertion_1"], reference, loaded_ids), - Compound.from_dict(d["assertion_2"], reference, loaded_ids), - ) - - -def unwrap(obj): - try: - return obj._value - except AttributeError: - return obj +from abc import ABC +import numpy as np +from typing import Optional, Dict + +from autofit.mapper.prior.arithmetic.compound import CompoundPrior, Compound +from autofit.mapper.prior_model.abstract import AbstractPriorModel + + +class ComparisonAssertion(CompoundPrior, Compound, ABC): + def __init__(self, lower, greater, name=""): + super().__init__(lower, greater) + self._name = name + + def __gt__(self, other): + return CompoundAssertion(self, self._left > other) + + def __lt__(self, other): + return CompoundAssertion(self, self._right < other) + + def __ge__(self, other): + return CompoundAssertion(self, self._left >= other) + + def __le__(self, other): + return CompoundAssertion(self, self._right <= other) + + +class GreaterThanLessThanAssertion(ComparisonAssertion): + def _instance_for_arguments(self, arguments, ignore_assertions=False, xp=np): + """ + Assert that the value in the dictionary associated with the lower + prior is lower than the value associated with the greater prior. + + Parameters + ---------- + arguments + A dictionary mapping priors to physical values. + + Raises + ------ + FitException + If the assertion is not met + """ + lower = self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + greater = self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + return lower < greater + + +class GreaterThanLessThanEqualAssertion(ComparisonAssertion): + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + """ + Assert that the value in the dictionary associated with the lower + prior is lower than the value associated with the greater prior. + + Parameters + ---------- + arguments + A dictionary mapping priors to physical values. + + Raises + ------ + FitException + If the assertion is not met + """ + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) <= self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class CompoundAssertion(AbstractPriorModel, Compound): + def __init__(self, assertion_1, assertion_2, name=""): + super().__init__() + self.assertion_1 = assertion_1 + self.assertion_2 = assertion_2 + self._name = name + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.assertion_1.instance_for_arguments( + arguments, + ignore_assertions, + ) and self.assertion_2.instance_for_arguments( + arguments, + ignore_assertions, + ) + + def dict(self) -> dict: + return { + "type": "compound", + "compound_type": self.__class__.__name__, + "assertion_1": self.assertion_1.dict(), + "assertion_2": self.assertion_2.dict(), + } + + @classmethod + def from_dict( + cls, + d, + reference: Optional[Dict[str, str]] = None, + loaded_ids: Optional[dict] = None, + ): + return cls( + Compound.from_dict(d["assertion_1"], reference, loaded_ids), + Compound.from_dict(d["assertion_2"], reference, loaded_ids), + ) + + +def unwrap(obj): + try: + return obj._value + except AttributeError: + return obj diff --git a/autofit/mapper/prior/arithmetic/compound.py b/autofit/mapper/prior/arithmetic/compound.py index ce9d1b910..79af83feb 100644 --- a/autofit/mapper/prior/arithmetic/compound.py +++ b/autofit/mapper/prior/arithmetic/compound.py @@ -1,467 +1,467 @@ -import inspect -import logging -from abc import ABC -from copy import copy -from typing import Optional, Dict - -import numpy as np - -from autofit.mapper.model_object import dereference -from autofit.mapper.prior.arithmetic import ArithmeticMixin -from autofit.mapper.prior_model.abstract import AbstractPriorModel - -logger = logging.getLogger(__name__) - - -def retrieve_name(var): - first_name = None - frame = inspect.currentframe() - while frame is not None: - for name, value in list(frame.f_locals.items()): - if var is value: - first_name = name - frame = frame.f_back - - return first_name - - -class Compound: - @classmethod - def from_dict( - cls, - d, - reference: Optional[Dict[str, str]] = None, - loaded_ids: Optional[dict] = None, - ): - assertion_type = d.pop("compound_type") - for subclass in cls.descendants(): - if subclass.__name__ == assertion_type: - return subclass.from_dict( - d, - reference=reference, - loaded_ids=loaded_ids, - ) - raise ValueError(f"Compound type {assertion_type} not recognised") - - @classmethod - def descendants(cls): - subclasses = cls.__subclasses__() - - for child in subclasses: - yield child - yield from child.descendants() - - -class CompoundPrior(AbstractPriorModel, ArithmeticMixin, Compound, ABC): - cls = float - - def __init__(self, left, right): - """ - Comprises objects that are to undergo some arithmetic - operation after realisation. - - Parameters - ---------- - left - A prior, promise or float - right - A prior, promise or float - """ - super().__init__() - - self._left_name = retrieve_name(left) or "left" - self._right_name = retrieve_name(right) or "right" - - if self._left_name == "left": - self._left_name = "left_" - - if self._right_name == "right": - self._right_name = "right_" - - self._left = None - self._right = None - - self.left = left - self.right = right - - def __repr__(self): - return str(self) - - def dict(self) -> dict: - from autofit import ModelObject - - return { - "type": "compound", - "compound_type": self.__class__.__name__, - "left": self._left.dict() - if isinstance(self._left, ModelObject) - else self._left, - "right": self._right.dict() - if isinstance(self._right, ModelObject) - else self._right, - } - - @classmethod - def from_dict( - cls, - d, - reference: Optional[Dict[str, str]] = None, - loaded_ids: Optional[dict] = None, - ): - from autofit import ModelObject - - return cls( - ModelObject.from_dict(d["left"], reference, loaded_ids), - ModelObject.from_dict(d["right"], reference, loaded_ids), - ) - - @property - def left(self): - return self._left - - @property - def right(self): - return self._right - - @left.setter - def left(self, left): - self._left = left - setattr(self, self._left_name, left) - - @right.setter - def right(self, right): - self._right = right - setattr(self, self._right_name, right) - - def gaussian_prior_model_for_arguments(self, arguments): - new = copy(self) - try: - new.left = new.left.gaussian_prior_model_for_arguments(arguments) - except AttributeError: - pass - try: - new.right = new.right.gaussian_prior_model_for_arguments(arguments) - except AttributeError: - pass - return new - - def left_for_arguments( - self, - arguments: dict, - ignore_assertions=False, - ): - """ - Instantiate the left object. - - Parameters - ---------- - arguments - A dictionary mapping priors to values - ignore_assertions - If True, ignore assertions - - Returns - ------- - A value for the left object - """ - try: - return self._left.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - except AttributeError: - return self._left - - def right_for_arguments( - self, - arguments: dict, - ignore_assertions=False, - ): - """ - Instantiate the right object. - - Parameters - ---------- - arguments - A dictionary mapping priors to values - ignore_assertions - If True, ignore assertions - - Returns - ------- - A value for the right object - """ - try: - return self._right.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - except AttributeError: - return self._right - - def __add__(self, other): - return ArithmeticMixin.__add__(self, other) - - -class SumPrior(CompoundPrior): - """ - The sum of two objects, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) + self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - def __str__(self): - return f"{self._left} + {self._right}" - - -class MultiplePrior(CompoundPrior): - """ - The multiple of two objects, computed after realisation. - """ - - def __str__(self): - return f"{self._left} * {self._right}" - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) * self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class DivisionPrior(CompoundPrior): - """ - One object divided by another, computed after realisation - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) / self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class FloorDivPrior(CompoundPrior): - """ - One object divided by another and floored, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) // self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class ModPrior(CompoundPrior): - """ - The modulus of a pair of objects, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) % self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class PowerPrior(CompoundPrior): - """ - One object to the power of another, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return self.left_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) ** self.right_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class ModifiedPrior(AbstractPriorModel, ABC, ArithmeticMixin, Compound): - def __init__(self, prior, name=None): - super().__init__() - self._prior_name = name or retrieve_name(prior) - - if self._prior_name == "prior": - self._prior_name = "prior_" - - self.prior = prior - - def dict(self): - return { - "type": "modified", - "modified_type": self.__class__.__name__, - "name": self._prior_name, - "prior": self.prior.dict() - if isinstance(self.prior, AbstractPriorModel) - else self.prior, - } - - @classmethod - def from_dict( - cls, - d, - reference: Optional[Dict[str, str]] = None, - loaded_ids: Optional[dict] = None, - ): - modified_type = d.pop("modified_type") - for subclass in cls.descendants(): - if subclass.__name__ == modified_type: - return subclass( - AbstractPriorModel.from_dict( - d["prior"], - reference=dereference(reference, "prior"), - loaded_ids=loaded_ids, - ), - name=d["name"], - ) - raise ValueError(f"Modified type {modified_type} not recognised") - - def __add__(self, other): - return ArithmeticMixin.__add__(self, other) - - @property - def cls(self): - return self.prior.cls - - @property - def prior(self): - return getattr(self, self._prior_name) - - @prior.setter - def prior(self, prior): - setattr(self, self._prior_name, prior) - - def gaussian_prior_model_for_arguments(self, arguments): - new = copy(self) - try: - new.prior = new.prior.gaussian_prior_model_for_arguments(arguments) - except AttributeError: - pass - return new - - -class NegativePrior(ModifiedPrior): - """ - The negation of an object, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return -self.prior.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - - -class AbsolutePrior(ModifiedPrior): - """ - The absolute value of an object, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return abs( - self.prior.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - ) - - -class Log(ModifiedPrior): - """ - The natural logarithm of an object, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return np.log( - self.prior.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - ) - - -class Log10(ModifiedPrior): - """ - The base10 logarithm of an object, computed after realisation. - """ - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - return np.log10( - self.prior.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - ) - ) +import inspect +import logging +from abc import ABC +from copy import copy +from typing import Optional, Dict + +import numpy as np + +from autofit.mapper.model_object import dereference +from autofit.mapper.prior.arithmetic import ArithmeticMixin +from autofit.mapper.prior_model.abstract import AbstractPriorModel + +logger = logging.getLogger(__name__) + + +def retrieve_name(var): + first_name = None + frame = inspect.currentframe() + while frame is not None: + for name, value in list(frame.f_locals.items()): + if var is value: + first_name = name + frame = frame.f_back + + return first_name + + +class Compound: + @classmethod + def from_dict( + cls, + d, + reference: Optional[Dict[str, str]] = None, + loaded_ids: Optional[dict] = None, + ): + assertion_type = d.pop("compound_type") + for subclass in cls.descendants(): + if subclass.__name__ == assertion_type: + return subclass.from_dict( + d, + reference=reference, + loaded_ids=loaded_ids, + ) + raise ValueError(f"Compound type {assertion_type} not recognised") + + @classmethod + def descendants(cls): + subclasses = cls.__subclasses__() + + for child in subclasses: + yield child + yield from child.descendants() + + +class CompoundPrior(AbstractPriorModel, ArithmeticMixin, Compound, ABC): + cls = float + + def __init__(self, left, right): + """ + Comprises objects that are to undergo some arithmetic + operation after realisation. + + Parameters + ---------- + left + A prior, promise or float + right + A prior, promise or float + """ + super().__init__() + + self._left_name = retrieve_name(left) or "left" + self._right_name = retrieve_name(right) or "right" + + if self._left_name == "left": + self._left_name = "left_" + + if self._right_name == "right": + self._right_name = "right_" + + self._left = None + self._right = None + + self.left = left + self.right = right + + def __repr__(self): + return str(self) + + def dict(self) -> dict: + from autofit import ModelObject + + return { + "type": "compound", + "compound_type": self.__class__.__name__, + "left": self._left.dict() + if isinstance(self._left, ModelObject) + else self._left, + "right": self._right.dict() + if isinstance(self._right, ModelObject) + else self._right, + } + + @classmethod + def from_dict( + cls, + d, + reference: Optional[Dict[str, str]] = None, + loaded_ids: Optional[dict] = None, + ): + from autofit import ModelObject + + return cls( + ModelObject.from_dict(d["left"], reference, loaded_ids), + ModelObject.from_dict(d["right"], reference, loaded_ids), + ) + + @property + def left(self): + return self._left + + @property + def right(self): + return self._right + + @left.setter + def left(self, left): + self._left = left + setattr(self, self._left_name, left) + + @right.setter + def right(self, right): + self._right = right + setattr(self, self._right_name, right) + + def gaussian_prior_model_for_arguments(self, arguments): + new = copy(self) + try: + new.left = new.left.gaussian_prior_model_for_arguments(arguments) + except AttributeError: + pass + try: + new.right = new.right.gaussian_prior_model_for_arguments(arguments) + except AttributeError: + pass + return new + + def left_for_arguments( + self, + arguments: dict, + ignore_assertions=False, + ): + """ + Instantiate the left object. + + Parameters + ---------- + arguments + A dictionary mapping priors to values + ignore_assertions + If True, ignore assertions + + Returns + ------- + A value for the left object + """ + try: + return self._left.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + except AttributeError: + return self._left + + def right_for_arguments( + self, + arguments: dict, + ignore_assertions=False, + ): + """ + Instantiate the right object. + + Parameters + ---------- + arguments + A dictionary mapping priors to values + ignore_assertions + If True, ignore assertions + + Returns + ------- + A value for the right object + """ + try: + return self._right.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + except AttributeError: + return self._right + + def __add__(self, other): + return ArithmeticMixin.__add__(self, other) + + +class SumPrior(CompoundPrior): + """ + The sum of two objects, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + def __str__(self): + return f"{self._left} + {self._right}" + + +class MultiplePrior(CompoundPrior): + """ + The multiple of two objects, computed after realisation. + """ + + def __str__(self): + return f"{self._left} * {self._right}" + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) * self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class DivisionPrior(CompoundPrior): + """ + One object divided by another, computed after realisation + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) / self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class FloorDivPrior(CompoundPrior): + """ + One object divided by another and floored, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) // self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class ModPrior(CompoundPrior): + """ + The modulus of a pair of objects, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) % self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class PowerPrior(CompoundPrior): + """ + One object to the power of another, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return self.left_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) ** self.right_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class ModifiedPrior(AbstractPriorModel, ABC, ArithmeticMixin, Compound): + def __init__(self, prior, name=None): + super().__init__() + self._prior_name = name or retrieve_name(prior) + + if self._prior_name == "prior": + self._prior_name = "prior_" + + self.prior = prior + + def dict(self): + return { + "type": "modified", + "modified_type": self.__class__.__name__, + "name": self._prior_name, + "prior": self.prior.dict() + if isinstance(self.prior, AbstractPriorModel) + else self.prior, + } + + @classmethod + def from_dict( + cls, + d, + reference: Optional[Dict[str, str]] = None, + loaded_ids: Optional[dict] = None, + ): + modified_type = d.pop("modified_type") + for subclass in cls.descendants(): + if subclass.__name__ == modified_type: + return subclass( + AbstractPriorModel.from_dict( + d["prior"], + reference=dereference(reference, "prior"), + loaded_ids=loaded_ids, + ), + name=d["name"], + ) + raise ValueError(f"Modified type {modified_type} not recognised") + + def __add__(self, other): + return ArithmeticMixin.__add__(self, other) + + @property + def cls(self): + return self.prior.cls + + @property + def prior(self): + return getattr(self, self._prior_name) + + @prior.setter + def prior(self, prior): + setattr(self, self._prior_name, prior) + + def gaussian_prior_model_for_arguments(self, arguments): + new = copy(self) + try: + new.prior = new.prior.gaussian_prior_model_for_arguments(arguments) + except AttributeError: + pass + return new + + +class NegativePrior(ModifiedPrior): + """ + The negation of an object, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return -self.prior.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + + +class AbsolutePrior(ModifiedPrior): + """ + The absolute value of an object, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return abs( + self.prior.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + ) + + +class Log(ModifiedPrior): + """ + The natural logarithm of an object, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return np.log( + self.prior.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + ) + + +class Log10(ModifiedPrior): + """ + The base10 logarithm of an object, computed after realisation. + """ + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + return np.log10( + self.prior.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + ) + ) diff --git a/autofit/mapper/prior/deferred.py b/autofit/mapper/prior/deferred.py index c406ea4ba..616b6b793 100644 --- a/autofit/mapper/prior/deferred.py +++ b/autofit/mapper/prior/deferred.py @@ -1,68 +1,68 @@ -from autofit import exc - - -class DeferredInstance: - def __init__(self, cls: type, constructor_arguments: {str: object}): - """ - An instance that has been deferred for later construction - - Parameters - ---------- - cls - The class to be constructed - constructor_arguments - The arguments provided by the optimiser - """ - self.cls = cls - self.constructor_arguments = constructor_arguments - - @property - def deferred_argument_names(self) -> [str]: - """ - The names of arguments still required to instantiate the class - """ - return [ - name - for name, value in self.constructor_arguments.items() - if isinstance(value, DeferredArgument) - ] - - def __call__(self, **kwargs): - """ - Constructs an instance of the class provided that all unset arguments are - passed. - - Parameters - ---------- - kwargs - Key value pairs for arguments that should be set - - Returns - ------- - instance: self.cls - An instance of the class - """ - return self.cls(**{**self.constructor_arguments, **kwargs}) - - def __getattr__(self, item): - """ - Failing to get an attribute is considered to indicate an attempt to use a - deferred instance without first instantiating it. As such an exception is - raised to warn the user that they need to instantiate the class. - """ - try: - super().__getattribute__(item) - except AttributeError: - raise exc.DeferredInstanceException( - f"{self.cls.__name__} cannot be called until it is instantiated with" - f" deferred arguments {self.deferred_argument_names}" - ) - - -class DeferredArgument: - """ - A deferred argument which is passed into the construct the final instance after - model mapper instance generation - """ - - pass +from autofit import exc + + +class DeferredInstance: + def __init__(self, cls: type, constructor_arguments: {str: object}): + """ + An instance that has been deferred for later construction + + Parameters + ---------- + cls + The class to be constructed + constructor_arguments + The arguments provided by the optimiser + """ + self.cls = cls + self.constructor_arguments = constructor_arguments + + @property + def deferred_argument_names(self) -> [str]: + """ + The names of arguments still required to instantiate the class + """ + return [ + name + for name, value in self.constructor_arguments.items() + if isinstance(value, DeferredArgument) + ] + + def __call__(self, **kwargs): + """ + Constructs an instance of the class provided that all unset arguments are + passed. + + Parameters + ---------- + kwargs + Key value pairs for arguments that should be set + + Returns + ------- + instance: self.cls + An instance of the class + """ + return self.cls(**{**self.constructor_arguments, **kwargs}) + + def __getattr__(self, item): + """ + Failing to get an attribute is considered to indicate an attempt to use a + deferred instance without first instantiating it. As such an exception is + raised to warn the user that they need to instantiate the class. + """ + try: + super().__getattribute__(item) + except AttributeError: + raise exc.DeferredInstanceException( + f"{self.cls.__name__} cannot be called until it is instantiated with" + f" deferred arguments {self.deferred_argument_names}" + ) + + +class DeferredArgument: + """ + A deferred argument which is passed into the construct the final instance after + model mapper instance generation + """ + + pass diff --git a/autofit/mapper/prior/vectorized.py b/autofit/mapper/prior/vectorized.py index 6e72aaea6..17df13615 100644 --- a/autofit/mapper/prior/vectorized.py +++ b/autofit/mapper/prior/vectorized.py @@ -1,194 +1,194 @@ -import numpy as np - -from autofit.mapper.prior.gaussian import GaussianPrior -from autofit.mapper.prior.truncated_gaussian import TruncatedGaussianPrior -from autofit.mapper.prior.uniform import UniformPrior -from autofit.mapper.prior.log_uniform import LogUniformPrior -from autofit.mapper.prior_model.abstract import AbstractPriorModel - - -class PriorVectorized: - - def __init__(self, model: AbstractPriorModel): - """ - Vectorized transformer for a model's priors that batches together priors by type - and applies inverse transformations from unit cube to physical parameter space. - - This performs the same unit transformation as the individual prior classes - `value_for` functions, it simply groups and performs them all in one go - in order to make the mapping from unit cube to physical parameter space - more efficient. - - Supports Uniform, Gaussian, Truncated Gaussian, and LogUniform priors. - - Raises exceptions if the model contains unsupported prior types. - - - Parameters - ---------- - model : Model - A model object that contains priors ordered by ID, accessible via - `model.priors_ordered_by_id`. - """ - - self.model = model - self.prior_list = model.priors_ordered_by_id - - supported_prior_list = [ - UniformPrior, - GaussianPrior, - TruncatedGaussianPrior, - LogUniformPrior, - ] - - # 1) Group UniformPriors with key info - self.uniform_idx = [ - i for i, p in enumerate(self.prior_list) if isinstance(p, UniformPrior) - ] - - if self.uniform_idx: - - self.uniform_lowers = np.array( - [self.prior_list[i].lower_limit for i in self.uniform_idx] - ) # (n_uniforms,) - self.uniform_uppers = np.array( - [self.prior_list[i].upper_limit for i in self.uniform_idx] - ) # (n_uniforms,) - - self.gaussian_idx = [ - i for i, p in enumerate(self.prior_list) if isinstance(p, GaussianPrior) - ] - - if self.gaussian_idx: - - self.gaussian_means = np.array( - [self.prior_list[i].mean for i in self.gaussian_idx] - ) - self.gaussian_sigmas = np.array( - [self.prior_list[i].sigma for i in self.gaussian_idx] - ) - - self.truncated_gaussian_idx = [ - i - for i, p in enumerate(self.prior_list) - if isinstance(p, TruncatedGaussianPrior) - ] - - if self.truncated_gaussian_idx: - - self.truncated_gaussian_means = np.array( - [self.prior_list[i].mean for i in self.truncated_gaussian_idx] - ) - self.truncated_gaussian_sigmas = np.array( - [self.prior_list[i].sigma for i in self.truncated_gaussian_idx] - ) - lowers = np.array( - [self.prior_list[i].lower_limit for i in self.truncated_gaussian_idx] - ) - uppers = np.array( - [self.prior_list[i].upper_limit for i in self.truncated_gaussian_idx] - ) - - a = ( - lowers - self.truncated_gaussian_means - ) / self.truncated_gaussian_sigmas - b = ( - uppers - self.truncated_gaussian_means - ) / self.truncated_gaussian_sigmas - - from scipy.stats import norm - - self.truncated_gaussian_cdf_a = norm.cdf(a) - self.truncated_gaussian_cdf_b = norm.cdf(b) - - self.loguniform_idx = [ - i - for i, p in enumerate(self.prior_list) - if isinstance(p, LogUniformPrior) - ] - - if self.loguniform_idx: - - self.loguniform_lowers = np.array( - [self.prior_list[i].lower_limit for i in self.loguniform_idx] - ) # (n_loguniforms,) - self.loguniform_uppers = np.array( - [self.prior_list[i].upper_limit for i in self.loguniform_idx] - ) # (n_loguniforms,) - # Map unit interval to log scale: - # x = exp(log(lower) + unit * (log(upper) - log(lower))) - self.loguniform_log_lowers = np.log(self.loguniform_lowers) - self.loguniform_log_uppers = np.log(self.loguniform_uppers) - - def __call__(self, cube: np.ndarray) -> np.ndarray: - """ - Apply vectorized prior transformation from unit cube [0, 1] to physical space. - - Parameters - ---------- - cube - Array of shape (n_samples, n_priors) with values in [0, 1] which are mapped - to physical parameter space via the priors defined in the model. - - Returns - ------- - out - Transformed parameters of shape (n_samples, n_priors). - """ - - cube_reshaped = False - - if len(cube.shape) == 1: - cube = cube[None, :] - cube_reshaped = True - - out = np.empty_like(cube) - - # 2) Batch‐process all UniformPriors - if self.uniform_idx: - subcube = cube[:, self.uniform_idx] # shape (n_samples, n_uniforms) - - out[:, self.uniform_idx] = self.uniform_lowers + subcube * ( - self.uniform_uppers - self.uniform_lowers - ) - - # 3) Batch‐process all GaussianPriors - if self.gaussian_idx: - from scipy.stats import norm - - subcube = cube[:, self.gaussian_idx] # (n_samples, n_gaussians) - - inv = norm.ppf(subcube) # inverse CDF of standard normal - out[:, self.gaussian_idx] = self.gaussian_means + self.gaussian_sigmas * inv - - # 4) Batch‐process all TruncatedGaussianPriors - if self.truncated_gaussian_idx: - - subcube = cube[:, self.truncated_gaussian_idx] # (n_samples, n_truncs) - - from scipy.stats import norm - - truncated_cdf = self.truncated_gaussian_cdf_a + subcube * ( - self.truncated_gaussian_cdf_b - self.truncated_gaussian_cdf_a - ) - x_std = norm.ppf(truncated_cdf) - - out[:, self.truncated_gaussian_idx] = ( - self.truncated_gaussian_means + self.truncated_gaussian_sigmas * x_std - ) - - # 5) Batch‐process all LogUniformPriors - if self.loguniform_idx: - - subcube = cube[:, self.loguniform_idx] # (n_samples, n_loguniforms) - - out[:, self.loguniform_idx] = np.exp( - self.loguniform_log_lowers - + subcube * (self.loguniform_log_uppers - self.loguniform_log_lowers) - ) - - if cube_reshaped: - - return out[0] - +import numpy as np + +from autofit.mapper.prior.gaussian import GaussianPrior +from autofit.mapper.prior.truncated_gaussian import TruncatedGaussianPrior +from autofit.mapper.prior.uniform import UniformPrior +from autofit.mapper.prior.log_uniform import LogUniformPrior +from autofit.mapper.prior_model.abstract import AbstractPriorModel + + +class PriorVectorized: + + def __init__(self, model: AbstractPriorModel): + """ + Vectorized transformer for a model's priors that batches together priors by type + and applies inverse transformations from unit cube to physical parameter space. + + This performs the same unit transformation as the individual prior classes + `value_for` functions, it simply groups and performs them all in one go + in order to make the mapping from unit cube to physical parameter space + more efficient. + + Supports Uniform, Gaussian, Truncated Gaussian, and LogUniform priors. + + Raises exceptions if the model contains unsupported prior types. + + + Parameters + ---------- + model : Model + A model object that contains priors ordered by ID, accessible via + `model.priors_ordered_by_id`. + """ + + self.model = model + self.prior_list = model.priors_ordered_by_id + + supported_prior_list = [ + UniformPrior, + GaussianPrior, + TruncatedGaussianPrior, + LogUniformPrior, + ] + + # 1) Group UniformPriors with key info + self.uniform_idx = [ + i for i, p in enumerate(self.prior_list) if isinstance(p, UniformPrior) + ] + + if self.uniform_idx: + + self.uniform_lowers = np.array( + [self.prior_list[i].lower_limit for i in self.uniform_idx] + ) # (n_uniforms,) + self.uniform_uppers = np.array( + [self.prior_list[i].upper_limit for i in self.uniform_idx] + ) # (n_uniforms,) + + self.gaussian_idx = [ + i for i, p in enumerate(self.prior_list) if isinstance(p, GaussianPrior) + ] + + if self.gaussian_idx: + + self.gaussian_means = np.array( + [self.prior_list[i].mean for i in self.gaussian_idx] + ) + self.gaussian_sigmas = np.array( + [self.prior_list[i].sigma for i in self.gaussian_idx] + ) + + self.truncated_gaussian_idx = [ + i + for i, p in enumerate(self.prior_list) + if isinstance(p, TruncatedGaussianPrior) + ] + + if self.truncated_gaussian_idx: + + self.truncated_gaussian_means = np.array( + [self.prior_list[i].mean for i in self.truncated_gaussian_idx] + ) + self.truncated_gaussian_sigmas = np.array( + [self.prior_list[i].sigma for i in self.truncated_gaussian_idx] + ) + lowers = np.array( + [self.prior_list[i].lower_limit for i in self.truncated_gaussian_idx] + ) + uppers = np.array( + [self.prior_list[i].upper_limit for i in self.truncated_gaussian_idx] + ) + + a = ( + lowers - self.truncated_gaussian_means + ) / self.truncated_gaussian_sigmas + b = ( + uppers - self.truncated_gaussian_means + ) / self.truncated_gaussian_sigmas + + from scipy.stats import norm + + self.truncated_gaussian_cdf_a = norm.cdf(a) + self.truncated_gaussian_cdf_b = norm.cdf(b) + + self.loguniform_idx = [ + i + for i, p in enumerate(self.prior_list) + if isinstance(p, LogUniformPrior) + ] + + if self.loguniform_idx: + + self.loguniform_lowers = np.array( + [self.prior_list[i].lower_limit for i in self.loguniform_idx] + ) # (n_loguniforms,) + self.loguniform_uppers = np.array( + [self.prior_list[i].upper_limit for i in self.loguniform_idx] + ) # (n_loguniforms,) + # Map unit interval to log scale: + # x = exp(log(lower) + unit * (log(upper) - log(lower))) + self.loguniform_log_lowers = np.log(self.loguniform_lowers) + self.loguniform_log_uppers = np.log(self.loguniform_uppers) + + def __call__(self, cube: np.ndarray) -> np.ndarray: + """ + Apply vectorized prior transformation from unit cube [0, 1] to physical space. + + Parameters + ---------- + cube + Array of shape (n_samples, n_priors) with values in [0, 1] which are mapped + to physical parameter space via the priors defined in the model. + + Returns + ------- + out + Transformed parameters of shape (n_samples, n_priors). + """ + + cube_reshaped = False + + if len(cube.shape) == 1: + cube = cube[None, :] + cube_reshaped = True + + out = np.empty_like(cube) + + # 2) Batch‐process all UniformPriors + if self.uniform_idx: + subcube = cube[:, self.uniform_idx] # shape (n_samples, n_uniforms) + + out[:, self.uniform_idx] = self.uniform_lowers + subcube * ( + self.uniform_uppers - self.uniform_lowers + ) + + # 3) Batch‐process all GaussianPriors + if self.gaussian_idx: + from scipy.stats import norm + + subcube = cube[:, self.gaussian_idx] # (n_samples, n_gaussians) + + inv = norm.ppf(subcube) # inverse CDF of standard normal + out[:, self.gaussian_idx] = self.gaussian_means + self.gaussian_sigmas * inv + + # 4) Batch‐process all TruncatedGaussianPriors + if self.truncated_gaussian_idx: + + subcube = cube[:, self.truncated_gaussian_idx] # (n_samples, n_truncs) + + from scipy.stats import norm + + truncated_cdf = self.truncated_gaussian_cdf_a + subcube * ( + self.truncated_gaussian_cdf_b - self.truncated_gaussian_cdf_a + ) + x_std = norm.ppf(truncated_cdf) + + out[:, self.truncated_gaussian_idx] = ( + self.truncated_gaussian_means + self.truncated_gaussian_sigmas * x_std + ) + + # 5) Batch‐process all LogUniformPriors + if self.loguniform_idx: + + subcube = cube[:, self.loguniform_idx] # (n_samples, n_loguniforms) + + out[:, self.loguniform_idx] = np.exp( + self.loguniform_log_lowers + + subcube * (self.loguniform_log_uppers - self.loguniform_log_lowers) + ) + + if cube_reshaped: + + return out[0] + return out \ No newline at end of file diff --git a/autofit/mapper/prior_model/annotation.py b/autofit/mapper/prior_model/annotation.py index b2207526e..5bd9e0ccd 100644 --- a/autofit/mapper/prior_model/annotation.py +++ b/autofit/mapper/prior_model/annotation.py @@ -1,17 +1,17 @@ -from autofit.mapper.prior.arithmetic import ArithmeticMixin -from autofit.mapper.prior_model.prior_model import Model, Prior - - -class AnnotationPriorModel(Model, ArithmeticMixin): - def __init__(self, cls, parent_class, true_argument_name, **kwargs): - self.parent_class = parent_class - self.true_argument_name = true_argument_name - self._value = None - super().__init__(cls, **kwargs) - - def make_prior(self, attribute_name): - if self._value is None: - self._value = Prior.for_class_and_attribute_name( - self.parent_class, self.true_argument_name - ) - return self._value +from autofit.mapper.prior.arithmetic import ArithmeticMixin +from autofit.mapper.prior_model.prior_model import Model, Prior + + +class AnnotationPriorModel(Model, ArithmeticMixin): + def __init__(self, cls, parent_class, true_argument_name, **kwargs): + self.parent_class = parent_class + self.true_argument_name = true_argument_name + self._value = None + super().__init__(cls, **kwargs) + + def make_prior(self, attribute_name): + if self._value is None: + self._value = Prior.for_class_and_attribute_name( + self.parent_class, self.true_argument_name + ) + return self._value diff --git a/autofit/mapper/prior_model/attribute_pair.py b/autofit/mapper/prior_model/attribute_pair.py index fbea896e6..de369c35f 100644 --- a/autofit/mapper/prior_model/attribute_pair.py +++ b/autofit/mapper/prior_model/attribute_pair.py @@ -1,62 +1,62 @@ -from functools import wraps - - -def cast_collection(named_tuple): - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - return list(map(lambda tup: named_tuple(*tup), func(*args, **kwargs))) - - return wrapper - - return decorator - - -class AttributeNameValue: - def __init__(self, name, value): - self.name = name - self.value = value - - def __iter__(self): - return iter(self.tuple) - - @property - def tuple(self): - return self.name, self.value - - def __getitem__(self, item): - return self.tuple[item] - - def __eq__(self, other): - if isinstance(other, AttributeNameValue): - return self.tuple == other.tuple - if isinstance(other, tuple): - return self.tuple == other - return False - - def __hash__(self): - return hash(self.tuple) - - def __str__(self): - return "({}, {})".format(self.name, self.value) - - def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, str(self)) - - -class PriorNameValue(AttributeNameValue): - @property - def prior(self): - return self.value - - -class InstanceNameValue(AttributeNameValue): - @property - def instance(self): - return self.value - - -class DeferredNameValue(AttributeNameValue): - @property - def deferred(self): - return self.value +from functools import wraps + + +def cast_collection(named_tuple): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + return list(map(lambda tup: named_tuple(*tup), func(*args, **kwargs))) + + return wrapper + + return decorator + + +class AttributeNameValue: + def __init__(self, name, value): + self.name = name + self.value = value + + def __iter__(self): + return iter(self.tuple) + + @property + def tuple(self): + return self.name, self.value + + def __getitem__(self, item): + return self.tuple[item] + + def __eq__(self, other): + if isinstance(other, AttributeNameValue): + return self.tuple == other.tuple + if isinstance(other, tuple): + return self.tuple == other + return False + + def __hash__(self): + return hash(self.tuple) + + def __str__(self): + return "({}, {})".format(self.name, self.value) + + def __repr__(self): + return "<{} {}>".format(self.__class__.__name__, str(self)) + + +class PriorNameValue(AttributeNameValue): + @property + def prior(self): + return self.value + + +class InstanceNameValue(AttributeNameValue): + @property + def instance(self): + return self.value + + +class DeferredNameValue(AttributeNameValue): + @property + def deferred(self): + return self.value diff --git a/autofit/mapper/prior_model/collection.py b/autofit/mapper/prior_model/collection.py index 5198dfe57..ed6703664 100644 --- a/autofit/mapper/prior_model/collection.py +++ b/autofit/mapper/prior_model/collection.py @@ -1,349 +1,349 @@ -import numpy as np - -from collections.abc import Iterable - -from autofit.mapper.model import ModelInstance, assert_not_frozen -from autofit.mapper.prior.abstract import Prior -from autofit.mapper.prior.constant import Constant -from autofit.mapper.prior_model.abstract import AbstractPriorModel - - -class Collection(AbstractPriorModel): - def name_for_prior(self, prior: Prior) -> str: - """ - Construct a name for the prior. This is the path taken - to get to the prior. - - Parameters - ---------- - prior - - Returns - ------- - A string of object names joined by underscores - """ - for name, prior_model in self.prior_model_tuples: - prior_name = prior_model.name_for_prior(prior) - if prior_name is not None: - return "{}_{}".format(name, prior_name) - for name, direct_prior in self.direct_prior_tuples: - if prior == direct_prior: - return name - - def tree_flatten(self): - """Flatten this collection into a JAX-compatible PyTree representation. - - Returns - ------- - tuple - A (children, aux_data) pair where children are the values and - aux_data are the corresponding keys. - """ - keys, values = zip(*self.items()) - return values, keys - - @classmethod - def tree_unflatten(cls, aux_data, children): - """Reconstruct a Collection from a flattened PyTree. - - Parameters - ---------- - aux_data - The keys of the collection items. - children - The values of the collection items. - """ - instance = cls() - - for key, value in zip(aux_data, children): - setattr(instance, key, value) - return instance - - def __contains__(self, item): - return item in self._dict or item in self._dict.values() - - def __getitem__(self, item): - """Retrieve an item by string key or integer index. - - Parameters - ---------- - item : str or int - A string key for dict-style access, or an integer index - for positional access into the values list. - """ - if isinstance(item, str): - return self._dict[item] - return self.values[item] - - def __len__(self): - return len(self.values) - - def __str__(self): - return "\n".join(f"{key} = {value}" for key, value in self.items()) - - def __hash__(self): - return self.id - - def __repr__(self): - return f"<{self.__class__.__name__} {self}>" - - @property - def values(self): - """The model components in this collection as a list.""" - return list(self._dict.values()) - - def items(self): - """The (key, model_component) pairs in this collection.""" - return self._dict.items() - - def with_prefix(self, prefix: str): - """ - Filter members of the collection, only returning those that start - with a given prefix as a new collection. - """ - return Collection( - {key: value for key, value in self.items() if key.startswith(prefix)} - ) - - def as_model(self): - """Convert all prior models in this collection to Model instances. - - Returns a new Collection where each AbstractPriorModel child has - been converted via its own as_model() method. - """ - return Collection( - { - key: value.as_model() - if isinstance(value, AbstractPriorModel) - else value - for key, value in self.dict().items() - } - ) - - def __init__( - self, - *arguments, - **kwargs, - ): - """ - The object multiple Python classes are input into to create model-components, which has free parameters that - are fitted by a non-linear search. - - Multiple Python classes can be input into a `Collection` in order to compose high dimensional models made of - multiple model-components. - - The ``Collection`` object is highly flexible, and can create models from many input Python data structures - (e.g. a list of classes, dictionary of classes, hierarchy of classes). - - For a complete description of the model composition API, see the **PyAutoFit** model API cookbooks: - - https://pyautofit.readthedocs.io/en/latest/cookbooks/model.html - - The Python class input into a ``Model`` to create a model component is written using the following format: - - - The name of the class is the name of the model component (e.g. ``Gaussian``). - - The input arguments of the constructor are the parameters of the mode (e.g. ``centre``, ``normalization`` and ``sigma``). - - The default values of the input arguments tell PyAutoFit whether a parameter is a single-valued float or a - multi-valued tuple. - - [Rich document more clearly] - - A prior model used to represent a list of prior models for convenience. - - Arguments are flexibly converted into a collection. - - Parameters - ---------- - arguments - Classes, prior models, instances or priors - - Examples - -------- - - class Gaussian: - - def __init__( - self, - centre=0.0, # <- PyAutoFit recognises these - normalization=0.1, # <- constructor arguments are - sigma=0.01, # <- the Gaussian's parameters. - ): - self.centre = centre - self.normalization = normalization - self.sigma = sigma - - model = af.Collection(gaussian_0=Gaussian, gaussian_1=Gaussian) - """ - super().__init__() - self.item_number = 0 - arguments = list(arguments) - if len(arguments) == 0: - self.add_dict_items(kwargs) - elif len(arguments) == 1: - arguments = arguments[0] - - if isinstance(arguments, dict): - self.add_dict_items(arguments) - elif isinstance(arguments, Iterable): - for argument in arguments: - self.append(argument) - else: - self.append(arguments) - else: - self.__init__(arguments) - - @assert_not_frozen - def add_dict_items(self, item_dict): - """Add all entries from a dictionary, converting values to prior models. - - Parameters - ---------- - item_dict - A dictionary mapping string keys to classes, instances, or prior models. - """ - for key, value in item_dict.items(): - if isinstance(key, tuple): - key = ".".join(key) - setattr(self, key, AbstractPriorModel.from_object(value)) - - def __eq__(self, other): - if other is None: - return False - if len(self) != len(other): - return False - for i, item in enumerate(self): - if item != other[i]: - return False - return True - - @assert_not_frozen - def append(self, item): - """Append an item to the collection with an auto-incremented numeric key. - - The item is converted to an AbstractPriorModel if it is not already one. - """ - setattr(self, str(self.item_number), AbstractPriorModel.from_object(item)) - self.item_number += 1 - - @assert_not_frozen - def __setitem__(self, key, value): - """Set an item by key, converting the value to a prior model. - - Preserves the id of any existing item at the same key so that - prior identity is maintained across replacements. - """ - obj = AbstractPriorModel.from_object(value) - try: - obj.id = getattr(self, str(key)).id - except AttributeError: - pass - setattr(self, str(key), obj) - - @assert_not_frozen - def __setattr__(self, key, value): - """Set an attribute, automatically converting values to prior models. - - Private attributes (starting with ``_``) are set directly. All other - values are wrapped via ``AbstractPriorModel.from_object`` so that - plain classes become ``Model`` instances and floats become fixed values. - """ - if key.startswith("_"): - super().__setattr__(key, value) - else: - try: - super().__setattr__(key, AbstractPriorModel.from_object(value)) - except AttributeError: - pass - - def remove(self, item): - """Remove an item from the collection by value equality. - - Parameters - ---------- - item - The item to remove. All entries whose value equals this item - are deleted. - """ - for key, value in self.__dict__.copy().items(): - if value == item: - del self.__dict__[key] - - def _instance_for_arguments( - self, - arguments, - ignore_assertions=False, - xp=np, - ): - """ - Parameters - ---------- - arguments: {Prior: float} - A dictionary of arguments - - Returns - ------- - model_instances: [object] - A list of instances constructed from the list of prior models. - """ - result = ModelInstance() - excluded = type(self)._cached_property_names() - for key, value in self.__dict__.items(): - if key.startswith("_") or key in excluded: - continue - if isinstance(value, AbstractPriorModel): - value = value.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - xp=xp - ) - elif isinstance(value, Prior): - value = arguments[value] - elif isinstance(value, Constant): - value = value.value - setattr(result, key, value) - return result - - def gaussian_prior_model_for_arguments(self, arguments): - """ - Create a new collection, updating its priors according to the argument - dictionary. - - Parameters - ---------- - arguments - A dictionary of arguments - - Returns - ------- - A new collection - """ - collection = Collection() - - for key, value in self.items(): - if key in ("component_number", "item_number", "id") or key.startswith("_"): - continue - - if isinstance(value, AbstractPriorModel): - collection[key] = value.gaussian_prior_model_for_arguments(arguments) - elif isinstance(value, Prior): - collection[key] = arguments[value] - else: - collection[key] = value - - return collection - - @property - def prior_class_dict(self): - """Map each prior to the class it will produce when instantiated. - - For child prior models, delegates to their own prior_class_dict. - Direct priors on the collection itself map to ModelInstance. - """ - return { - **{ - prior: cls - for prior_model in self.direct_prior_model_tuples - for prior, cls in prior_model[1].prior_class_dict.items() - }, - **{prior: ModelInstance for _, prior in self.direct_prior_tuples}, - } +import numpy as np + +from collections.abc import Iterable + +from autofit.mapper.model import ModelInstance, assert_not_frozen +from autofit.mapper.prior.abstract import Prior +from autofit.mapper.prior.constant import Constant +from autofit.mapper.prior_model.abstract import AbstractPriorModel + + +class Collection(AbstractPriorModel): + def name_for_prior(self, prior: Prior) -> str: + """ + Construct a name for the prior. This is the path taken + to get to the prior. + + Parameters + ---------- + prior + + Returns + ------- + A string of object names joined by underscores + """ + for name, prior_model in self.prior_model_tuples: + prior_name = prior_model.name_for_prior(prior) + if prior_name is not None: + return "{}_{}".format(name, prior_name) + for name, direct_prior in self.direct_prior_tuples: + if prior == direct_prior: + return name + + def tree_flatten(self): + """Flatten this collection into a JAX-compatible PyTree representation. + + Returns + ------- + tuple + A (children, aux_data) pair where children are the values and + aux_data are the corresponding keys. + """ + keys, values = zip(*self.items()) + return values, keys + + @classmethod + def tree_unflatten(cls, aux_data, children): + """Reconstruct a Collection from a flattened PyTree. + + Parameters + ---------- + aux_data + The keys of the collection items. + children + The values of the collection items. + """ + instance = cls() + + for key, value in zip(aux_data, children): + setattr(instance, key, value) + return instance + + def __contains__(self, item): + return item in self._dict or item in self._dict.values() + + def __getitem__(self, item): + """Retrieve an item by string key or integer index. + + Parameters + ---------- + item : str or int + A string key for dict-style access, or an integer index + for positional access into the values list. + """ + if isinstance(item, str): + return self._dict[item] + return self.values[item] + + def __len__(self): + return len(self.values) + + def __str__(self): + return "\n".join(f"{key} = {value}" for key, value in self.items()) + + def __hash__(self): + return self.id + + def __repr__(self): + return f"<{self.__class__.__name__} {self}>" + + @property + def values(self): + """The model components in this collection as a list.""" + return list(self._dict.values()) + + def items(self): + """The (key, model_component) pairs in this collection.""" + return self._dict.items() + + def with_prefix(self, prefix: str): + """ + Filter members of the collection, only returning those that start + with a given prefix as a new collection. + """ + return Collection( + {key: value for key, value in self.items() if key.startswith(prefix)} + ) + + def as_model(self): + """Convert all prior models in this collection to Model instances. + + Returns a new Collection where each AbstractPriorModel child has + been converted via its own as_model() method. + """ + return Collection( + { + key: value.as_model() + if isinstance(value, AbstractPriorModel) + else value + for key, value in self.dict().items() + } + ) + + def __init__( + self, + *arguments, + **kwargs, + ): + """ + The object multiple Python classes are input into to create model-components, which has free parameters that + are fitted by a non-linear search. + + Multiple Python classes can be input into a `Collection` in order to compose high dimensional models made of + multiple model-components. + + The ``Collection`` object is highly flexible, and can create models from many input Python data structures + (e.g. a list of classes, dictionary of classes, hierarchy of classes). + + For a complete description of the model composition API, see the **PyAutoFit** model API cookbooks: + + https://pyautofit.readthedocs.io/en/latest/cookbooks/model.html + + The Python class input into a ``Model`` to create a model component is written using the following format: + + - The name of the class is the name of the model component (e.g. ``Gaussian``). + - The input arguments of the constructor are the parameters of the mode (e.g. ``centre``, ``normalization`` and ``sigma``). + - The default values of the input arguments tell PyAutoFit whether a parameter is a single-valued float or a + multi-valued tuple. + + [Rich document more clearly] + + A prior model used to represent a list of prior models for convenience. + + Arguments are flexibly converted into a collection. + + Parameters + ---------- + arguments + Classes, prior models, instances or priors + + Examples + -------- + + class Gaussian: + + def __init__( + self, + centre=0.0, # <- PyAutoFit recognises these + normalization=0.1, # <- constructor arguments are + sigma=0.01, # <- the Gaussian's parameters. + ): + self.centre = centre + self.normalization = normalization + self.sigma = sigma + + model = af.Collection(gaussian_0=Gaussian, gaussian_1=Gaussian) + """ + super().__init__() + self.item_number = 0 + arguments = list(arguments) + if len(arguments) == 0: + self.add_dict_items(kwargs) + elif len(arguments) == 1: + arguments = arguments[0] + + if isinstance(arguments, dict): + self.add_dict_items(arguments) + elif isinstance(arguments, Iterable): + for argument in arguments: + self.append(argument) + else: + self.append(arguments) + else: + self.__init__(arguments) + + @assert_not_frozen + def add_dict_items(self, item_dict): + """Add all entries from a dictionary, converting values to prior models. + + Parameters + ---------- + item_dict + A dictionary mapping string keys to classes, instances, or prior models. + """ + for key, value in item_dict.items(): + if isinstance(key, tuple): + key = ".".join(key) + setattr(self, key, AbstractPriorModel.from_object(value)) + + def __eq__(self, other): + if other is None: + return False + if len(self) != len(other): + return False + for i, item in enumerate(self): + if item != other[i]: + return False + return True + + @assert_not_frozen + def append(self, item): + """Append an item to the collection with an auto-incremented numeric key. + + The item is converted to an AbstractPriorModel if it is not already one. + """ + setattr(self, str(self.item_number), AbstractPriorModel.from_object(item)) + self.item_number += 1 + + @assert_not_frozen + def __setitem__(self, key, value): + """Set an item by key, converting the value to a prior model. + + Preserves the id of any existing item at the same key so that + prior identity is maintained across replacements. + """ + obj = AbstractPriorModel.from_object(value) + try: + obj.id = getattr(self, str(key)).id + except AttributeError: + pass + setattr(self, str(key), obj) + + @assert_not_frozen + def __setattr__(self, key, value): + """Set an attribute, automatically converting values to prior models. + + Private attributes (starting with ``_``) are set directly. All other + values are wrapped via ``AbstractPriorModel.from_object`` so that + plain classes become ``Model`` instances and floats become fixed values. + """ + if key.startswith("_"): + super().__setattr__(key, value) + else: + try: + super().__setattr__(key, AbstractPriorModel.from_object(value)) + except AttributeError: + pass + + def remove(self, item): + """Remove an item from the collection by value equality. + + Parameters + ---------- + item + The item to remove. All entries whose value equals this item + are deleted. + """ + for key, value in self.__dict__.copy().items(): + if value == item: + del self.__dict__[key] + + def _instance_for_arguments( + self, + arguments, + ignore_assertions=False, + xp=np, + ): + """ + Parameters + ---------- + arguments: {Prior: float} + A dictionary of arguments + + Returns + ------- + model_instances: [object] + A list of instances constructed from the list of prior models. + """ + result = ModelInstance() + excluded = type(self)._cached_property_names() + for key, value in self.__dict__.items(): + if key.startswith("_") or key in excluded: + continue + if isinstance(value, AbstractPriorModel): + value = value.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + xp=xp + ) + elif isinstance(value, Prior): + value = arguments[value] + elif isinstance(value, Constant): + value = value.value + setattr(result, key, value) + return result + + def gaussian_prior_model_for_arguments(self, arguments): + """ + Create a new collection, updating its priors according to the argument + dictionary. + + Parameters + ---------- + arguments + A dictionary of arguments + + Returns + ------- + A new collection + """ + collection = Collection() + + for key, value in self.items(): + if key in ("component_number", "item_number", "id") or key.startswith("_"): + continue + + if isinstance(value, AbstractPriorModel): + collection[key] = value.gaussian_prior_model_for_arguments(arguments) + elif isinstance(value, Prior): + collection[key] = arguments[value] + else: + collection[key] = value + + return collection + + @property + def prior_class_dict(self): + """Map each prior to the class it will produce when instantiated. + + For child prior models, delegates to their own prior_class_dict. + Direct priors on the collection itself map to ModelInstance. + """ + return { + **{ + prior: cls + for prior_model in self.direct_prior_model_tuples + for prior, cls in prior_model[1].prior_class_dict.items() + }, + **{prior: ModelInstance for _, prior in self.direct_prior_tuples}, + } diff --git a/autofit/mapper/prior_model/prior_model.py b/autofit/mapper/prior_model/prior_model.py index cf3537f58..e8e355240 100644 --- a/autofit/mapper/prior_model/prior_model.py +++ b/autofit/mapper/prior_model/prior_model.py @@ -1,563 +1,563 @@ -import collections.abc -import copy -import inspect -import logging -import numpy as np -import typing -from typing import * - -from autonerves.class_path import get_class_path -from autonerves.exc import ConfigException -from autofit.mapper.model import assert_not_frozen -from autofit.mapper.model_object import ModelObject -from autofit.mapper.prior.abstract import Prior -from autofit.mapper.prior.constant import Constant -from autofit.mapper.prior.deferred import DeferredInstance -from autofit.mapper.prior.tuple_prior import TuplePrior -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.mapper.prior_model.util import gather_namespaces -from autofit.tools.namer import namer - -logger = logging.getLogger(__name__) - -class_args_dict = dict() - -class Model(AbstractPriorModel): - """ - @DynamicAttrs - """ - - @property - def name(self): - return self.cls.__name__ - - def __str__(self): - prior_string = ", ".join(map(str, self.prior_tuples)) - return f"{self.name} {prior_string}" - - def __repr__(self): - return f"<{self.__class__.__name__} {self}>" - - def as_model(self): - return Model(self.cls) - - def __hash__(self): - return self.id - - def __add__(self, other): - if self.cls != other.cls: - raise TypeError( - f"Cannot add PriorModels with different classes " - f"({self.cls.__name__} and {other.cls.__name__})" - ) - return super().__add__(other) - - def __init__( - self, - cls, - **kwargs, - ): - """ - The object a Python class is input into to create a model-component, which has free parameters that are fitted - by a non-linear search. - - The ``Model`` object is flexible, and can create models from many input Python data structures - (e.g. a list of classes, dictionary of classes, hierarchy of classes). - - For a complete description of the model composition API, see the **PyAutoFit** model API cookbooks: - - https://pyautofit.readthedocs.io/en/latest/cookbooks/model.html - - The Python class input into a ``Model`` to create a model component is written using the following format: - - - The name of the class is the name of the model component (e.g. ``Gaussian``). - - The input arguments of the constructor are the parameters of the mode (e.g. ``centre``, ``normalization`` and ``sigma``). - - The default values of the input arguments tell PyAutoFit whether a parameter is a single-valued float or a - multi-valued tuple. - - [Rich explain everything else] - - Parameters - ---------- - cls - The class associated with this instance - - Examples - -------- - - class Gaussian: - - def __init__( - self, - centre=0.0, # <- PyAutoFit recognises these - normalization=0.1, # <- constructor arguments are - sigma=0.01, # <- the Gaussian's parameters. - ): - self.centre = centre - self.normalization = normalization - self.sigma = sigma - - model = af.Model(Gaussian) - """ - super().__init__( - label=namer(cls.__name__) if inspect.isclass(cls) else None, - ) - if cls is self: - return - - if not (inspect.isclass(cls) or inspect.isfunction(cls)): - raise AssertionError(f"{cls} is not a class or function") - - self.cls = cls - - namespaces = gather_namespaces(cls) - - annotations = typing.get_type_hints( - cls.__init__, - namespaces, - namespaces, - ) - - try: - arg_spec = inspect.getfullargspec(cls) - defaults = dict( - zip(arg_spec.args[-len(arg_spec.defaults) :], arg_spec.defaults) - ) - defaults = { - key: value.default if hasattr(value, "default") else value - for key, value in defaults.items() - } - except TypeError: - defaults = {} - - args = self.constructor_argument_names - - if "settings" in defaults: - del defaults["settings"] - if "settings" in args: - args.remove("settings") - - for arg in args: - if isinstance(defaults.get(arg), str): - continue - - if arg in kwargs: - keyword_arg = kwargs[arg] - if isinstance(keyword_arg, (list, dict)): - from autofit.mapper.prior_model.collection import Collection - - ls = Collection(keyword_arg) - - setattr(self, arg, ls) - else: - keyword_arg = self._convert_value(keyword_arg) - setattr(self, arg, keyword_arg) - elif arg in defaults and isinstance(defaults[arg], tuple): - setattr(self, arg, self.make_tuple_prior(arg, len(defaults[arg]))) - elif arg in annotations and annotations[arg] is not float: - spec = annotations[arg] - - if isinstance(spec, typing._GenericAlias) and spec.__origin__ is tuple: - setattr(self, arg, self.make_tuple_prior(arg, len(spec.__args__))) - - # noinspection PyUnresolvedReferences - elif inspect.isclass(spec) and issubclass(spec, float): - from autofit.mapper.prior_model.annotation import ( - AnnotationPriorModel, - ) - - setattr(self, arg, AnnotationPriorModel(spec, cls, arg)) - elif hasattr(spec, "__args__") and type(None) in spec.__args__: - setattr(self, arg, None) - else: - annotation = annotations[arg] - - if isinstance(annotation, str): - continue - - if arg in defaults: - value = self._convert_value(defaults[arg]) - elif ( - ( - hasattr(annotation, "__origin__") - and issubclass( - annotation.__origin__, collections.abc.Collection - ) - ) - or isinstance(annotation, collections.abc.Collection) - or issubclass(annotation, collections.abc.Collection) - ): - from autofit.mapper.prior_model.collection import Collection - - value = Collection() - else: - value = Model(annotation) - setattr(self, arg, value) - else: - prior = self.make_prior(arg) - if ( - isinstance(prior, ConfigException) - and hasattr(cls, "__default_fields__") - and arg in cls.__default_fields__ - ): - prior = defaults[arg] - setattr(self, arg, prior) - for key, value in kwargs.items(): - if not hasattr(self, key): - setattr(self, key, self._convert_value(value)) - - # try: - # # noinspection PyTypeChecker - # register_pytree_node( - # self.cls, - # self.instance_flatten, - # self.instance_unflatten, - # ) - # except ValueError: - # pass - - @staticmethod - def _convert_value(value): - if inspect.isclass(value): - value = Model(value) - if isinstance(value, int): - value = float(value) - if isinstance(value, float): - value = Constant(value) - return value - - @property - def direct_argument_names(self) -> List[str]: - """ - The names of priors, constants and other attributes that are direct - attributes of this model. - """ - return [ - t.name - for t in self.direct_prior_tuples - + self.direct_prior_model_tuples - + self.direct_instance_tuples - + self.direct_deferred_tuples - + self.direct_prior_tuples - ] - - def instance_flatten(self, instance): - """ - Flatten an instance of this model as a PyTree. - """ - attribute_names = [ - name - for name in self.direct_argument_names - if hasattr(instance, name) and name not in self.constructor_argument_names - ] - return ( - ( - [getattr(instance, name) for name in self.constructor_argument_names], - [getattr(instance, name) for name in attribute_names], - ), - (attribute_names,), - ) - - def instance_unflatten(self, aux_data, children): - """ - Unflatten a PyTree into an instance of this model. - - Parameters - ---------- - aux_data - children - - Returns - ------- - An instance of this model. - """ - constructor_arguments, other_arguments = children - attribute_names = aux_data[0] - instance = self.cls(*constructor_arguments) - for name, value in zip(attribute_names, other_arguments): - setattr(instance, name, value) - return instance - - def tree_flatten(self): - """ - Flatten this model as a PyTree. - """ - names, priors = zip(*self.direct_prior_tuples) - return priors, (names, self.cls) - - @classmethod - def tree_unflatten(cls, aux_data, children): - """ - Unflatten a PyTree into a model. - """ - names, cls_ = aux_data - arguments = {name: child for name, child in zip(names, children)} - return cls(cls_, **arguments) - - def dict(self): - return {"class_path": get_class_path(self.cls), **super().dict()} - - # noinspection PyAttributeOutsideInit - @property - def constructor_argument_names(self) -> List[str]: - """ - The argument names of the constructor of the class of this model. - """ - if self.cls not in class_args_dict: - try: - class_args_dict[self.cls] = [ - arg - for arg in inspect.getfullargspec(self.cls).args - if arg != "self" - ] - except TypeError: - class_args_dict[self.cls] = [] - return class_args_dict[self.cls] - - def __eq__(self, other): - return ( - isinstance(other, Model) - and self.cls == other.cls - and self.prior_tuples == other.prior_tuples - ) - - def make_prior(self, attribute_name): - """ - Returns a prior for an attribute of a class with a given name. The prior is - created by searching the default prior config for the attribute. - - Entries in configuration with a u become uniform priors; with a g become - gaussian priors; with a c become instances. - - If prior configuration for a given attribute is not specified in the - configuration for a class then the configuration corresponding to the parents - of that class is searched. If no configuration can be found then a prior - exception is raised. - - Parameters - ---------- - attribute_name: str - The name of the attribute for which a prior is created - - Returns - ------- - prior: p.Prior - A prior - - Raises - ------ - exc.PriorException - If no configuration can be found - """ - cls = self.cls - if isinstance(cls, ConfigException): - return cls - if not inspect.isclass(cls): - # noinspection PyProtectedMember - cls = inspect._findclass(cls) - try: - return Prior.for_class_and_attribute_name(cls, attribute_name) - except ConfigException as e: - return e - - def make_tuple_prior(self, name, length): - tuple_prior = TuplePrior() - for i in range(length): - attribute_name = "{}_{}".format(name, i) - setattr(tuple_prior, attribute_name, self.make_prior(attribute_name)) - return tuple_prior - - @assert_not_frozen - def __setattr__(self, key, value): - try: - value.label = namer(key) - except (AttributeError, TypeError): - pass - - if key not in ( - "component_number", - "phase_property_position", - "mapping_name", - "id", - "_is_frozen", - "_frozen_cache", - ): - try: - if "_" in key: - name = key.split("_")[0] - tuple_prior = [v for k, v in self.tuple_prior_tuples if name == k][ - 0 - ] - setattr(tuple_prior, key, value) - return - - except IndexError: - pass - - if isinstance(value, float): - value = Constant(value) - try: - super().__setattr__(key, value) - except AttributeError as e: - logger.exception(e) - logger.exception(key) - - def __getattr__(self, item): - try: - if ( - "_" in item - and item not in ("_is_frozen", "tuple_prior_tuples") - and not item.startswith("_") - ): - return getattr( - [v for k, v in self.tuple_prior_tuples if item.split("_")[0] == k][ - 0 - ], - item, - ) - - except IndexError: - pass - - self.__getattribute__(item) - - @property - def is_deferred_arguments(self): - return len(self.direct_deferred_tuples) > 0 - - # noinspection PyUnresolvedReferences - def _instance_for_arguments( - self, - arguments: {ModelObject: object}, - ignore_assertions=False, - xp=np, - ): - """ - Returns an instance of the associated class for a set of arguments - - Parameters - ---------- - arguments: {Prior: float} - Dictionary mapping_matrix priors to attribute analysis_path and value pairs - - Returns - ------- - An instance of the class - """ - model_arguments = dict() - attribute_arguments = { - key: value - for key, value in self.__dict__.items() - if key in self.constructor_argument_names - } - - for tuple_prior in self.tuple_prior_tuples: - model_arguments[tuple_prior.name] = tuple_prior.prior.value_for_arguments( - arguments - ) - for prior_model_tuple in self.direct_prior_model_tuples: - prior_model = prior_model_tuple.prior_model - model_arguments[ - prior_model_tuple.name - ] = prior_model.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - xp=xp - ) - - prior_arguments = dict() - - for name, prior in self.direct_prior_tuples: - try: - prior_arguments[name] = arguments[prior] - except KeyError as e: - raise KeyError(f"No argument given for prior {name}") from e - - constructor_arguments = { - **attribute_arguments, - **model_arguments, - **prior_arguments, - } - - constructor_arguments = { - key: value.value if isinstance(value, Constant) else value - for key, value in constructor_arguments.items() - } - - if self.is_deferred_arguments: - return DeferredInstance(self.cls, constructor_arguments) - - if not inspect.isclass(self.cls): - result = object.__new__(inspect._findclass(self.cls)) - cls = self.cls - cls(result, **constructor_arguments) - else: - result = self.cls(**constructor_arguments) - - excluded = type(self)._cached_property_names() - for key, value in self.__dict__.items(): - if ( - not hasattr(result, key) - and not isinstance(value, Prior) - and not key == "cls" - and not key.startswith("_") - and key not in excluded - ): - if isinstance(value, Model): - value = value.instance_for_arguments( - arguments, - ignore_assertions=ignore_assertions, - xp=xp - ) - elif isinstance(value, Constant): - value = value.value - elif isinstance(value, Prior): - value = arguments[value] - try: - setattr(result, key, value) - except AttributeError: - pass - - return result - - def gaussian_prior_model_for_arguments(self, arguments): - """ - Returns a new instance of model mapper with a set of Gaussian priors based on \ - tuples provided by a previous nonlinear search. - - Parameters - ---------- - arguments: [(float, float)] - Tuples providing the mean and sigma of gaussians - - Returns - ------- - new_model: ModelMapper - A new model mapper populated with Gaussian priors - """ - self.unfreeze() - new_model = copy.deepcopy(self) - - new_model._assertions = list() - - model_arguments = {t.name: arguments[t.prior] for t in self.direct_prior_tuples} - - for tuple_prior_tuple in self.tuple_prior_tuples: - setattr( - new_model, - tuple_prior_tuple.name, - tuple_prior_tuple.prior.gaussian_tuple_prior_for_arguments(arguments), - ) - for prior_tuple in self.direct_prior_tuples: - setattr(new_model, prior_tuple.name, model_arguments[prior_tuple.name]) - for instance_tuple in self.direct_instance_tuples: - setattr(new_model, instance_tuple.name, instance_tuple.instance) - - for name, prior_model in self.direct_prior_model_tuples: - setattr( - new_model, - name, - prior_model.gaussian_prior_model_for_arguments(arguments), - ) - - return new_model +import collections.abc +import copy +import inspect +import logging +import numpy as np +import typing +from typing import * + +from autonerves.class_path import get_class_path +from autonerves.exc import ConfigException +from autofit.mapper.model import assert_not_frozen +from autofit.mapper.model_object import ModelObject +from autofit.mapper.prior.abstract import Prior +from autofit.mapper.prior.constant import Constant +from autofit.mapper.prior.deferred import DeferredInstance +from autofit.mapper.prior.tuple_prior import TuplePrior +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.mapper.prior_model.util import gather_namespaces +from autofit.tools.namer import namer + +logger = logging.getLogger(__name__) + +class_args_dict = dict() + +class Model(AbstractPriorModel): + """ + @DynamicAttrs + """ + + @property + def name(self): + return self.cls.__name__ + + def __str__(self): + prior_string = ", ".join(map(str, self.prior_tuples)) + return f"{self.name} {prior_string}" + + def __repr__(self): + return f"<{self.__class__.__name__} {self}>" + + def as_model(self): + return Model(self.cls) + + def __hash__(self): + return self.id + + def __add__(self, other): + if self.cls != other.cls: + raise TypeError( + f"Cannot add PriorModels with different classes " + f"({self.cls.__name__} and {other.cls.__name__})" + ) + return super().__add__(other) + + def __init__( + self, + cls, + **kwargs, + ): + """ + The object a Python class is input into to create a model-component, which has free parameters that are fitted + by a non-linear search. + + The ``Model`` object is flexible, and can create models from many input Python data structures + (e.g. a list of classes, dictionary of classes, hierarchy of classes). + + For a complete description of the model composition API, see the **PyAutoFit** model API cookbooks: + + https://pyautofit.readthedocs.io/en/latest/cookbooks/model.html + + The Python class input into a ``Model`` to create a model component is written using the following format: + + - The name of the class is the name of the model component (e.g. ``Gaussian``). + - The input arguments of the constructor are the parameters of the mode (e.g. ``centre``, ``normalization`` and ``sigma``). + - The default values of the input arguments tell PyAutoFit whether a parameter is a single-valued float or a + multi-valued tuple. + + [Rich explain everything else] + + Parameters + ---------- + cls + The class associated with this instance + + Examples + -------- + + class Gaussian: + + def __init__( + self, + centre=0.0, # <- PyAutoFit recognises these + normalization=0.1, # <- constructor arguments are + sigma=0.01, # <- the Gaussian's parameters. + ): + self.centre = centre + self.normalization = normalization + self.sigma = sigma + + model = af.Model(Gaussian) + """ + super().__init__( + label=namer(cls.__name__) if inspect.isclass(cls) else None, + ) + if cls is self: + return + + if not (inspect.isclass(cls) or inspect.isfunction(cls)): + raise AssertionError(f"{cls} is not a class or function") + + self.cls = cls + + namespaces = gather_namespaces(cls) + + annotations = typing.get_type_hints( + cls.__init__, + namespaces, + namespaces, + ) + + try: + arg_spec = inspect.getfullargspec(cls) + defaults = dict( + zip(arg_spec.args[-len(arg_spec.defaults) :], arg_spec.defaults) + ) + defaults = { + key: value.default if hasattr(value, "default") else value + for key, value in defaults.items() + } + except TypeError: + defaults = {} + + args = self.constructor_argument_names + + if "settings" in defaults: + del defaults["settings"] + if "settings" in args: + args.remove("settings") + + for arg in args: + if isinstance(defaults.get(arg), str): + continue + + if arg in kwargs: + keyword_arg = kwargs[arg] + if isinstance(keyword_arg, (list, dict)): + from autofit.mapper.prior_model.collection import Collection + + ls = Collection(keyword_arg) + + setattr(self, arg, ls) + else: + keyword_arg = self._convert_value(keyword_arg) + setattr(self, arg, keyword_arg) + elif arg in defaults and isinstance(defaults[arg], tuple): + setattr(self, arg, self.make_tuple_prior(arg, len(defaults[arg]))) + elif arg in annotations and annotations[arg] is not float: + spec = annotations[arg] + + if isinstance(spec, typing._GenericAlias) and spec.__origin__ is tuple: + setattr(self, arg, self.make_tuple_prior(arg, len(spec.__args__))) + + # noinspection PyUnresolvedReferences + elif inspect.isclass(spec) and issubclass(spec, float): + from autofit.mapper.prior_model.annotation import ( + AnnotationPriorModel, + ) + + setattr(self, arg, AnnotationPriorModel(spec, cls, arg)) + elif hasattr(spec, "__args__") and type(None) in spec.__args__: + setattr(self, arg, None) + else: + annotation = annotations[arg] + + if isinstance(annotation, str): + continue + + if arg in defaults: + value = self._convert_value(defaults[arg]) + elif ( + ( + hasattr(annotation, "__origin__") + and issubclass( + annotation.__origin__, collections.abc.Collection + ) + ) + or isinstance(annotation, collections.abc.Collection) + or issubclass(annotation, collections.abc.Collection) + ): + from autofit.mapper.prior_model.collection import Collection + + value = Collection() + else: + value = Model(annotation) + setattr(self, arg, value) + else: + prior = self.make_prior(arg) + if ( + isinstance(prior, ConfigException) + and hasattr(cls, "__default_fields__") + and arg in cls.__default_fields__ + ): + prior = defaults[arg] + setattr(self, arg, prior) + for key, value in kwargs.items(): + if not hasattr(self, key): + setattr(self, key, self._convert_value(value)) + + # try: + # # noinspection PyTypeChecker + # register_pytree_node( + # self.cls, + # self.instance_flatten, + # self.instance_unflatten, + # ) + # except ValueError: + # pass + + @staticmethod + def _convert_value(value): + if inspect.isclass(value): + value = Model(value) + if isinstance(value, int): + value = float(value) + if isinstance(value, float): + value = Constant(value) + return value + + @property + def direct_argument_names(self) -> List[str]: + """ + The names of priors, constants and other attributes that are direct + attributes of this model. + """ + return [ + t.name + for t in self.direct_prior_tuples + + self.direct_prior_model_tuples + + self.direct_instance_tuples + + self.direct_deferred_tuples + + self.direct_prior_tuples + ] + + def instance_flatten(self, instance): + """ + Flatten an instance of this model as a PyTree. + """ + attribute_names = [ + name + for name in self.direct_argument_names + if hasattr(instance, name) and name not in self.constructor_argument_names + ] + return ( + ( + [getattr(instance, name) for name in self.constructor_argument_names], + [getattr(instance, name) for name in attribute_names], + ), + (attribute_names,), + ) + + def instance_unflatten(self, aux_data, children): + """ + Unflatten a PyTree into an instance of this model. + + Parameters + ---------- + aux_data + children + + Returns + ------- + An instance of this model. + """ + constructor_arguments, other_arguments = children + attribute_names = aux_data[0] + instance = self.cls(*constructor_arguments) + for name, value in zip(attribute_names, other_arguments): + setattr(instance, name, value) + return instance + + def tree_flatten(self): + """ + Flatten this model as a PyTree. + """ + names, priors = zip(*self.direct_prior_tuples) + return priors, (names, self.cls) + + @classmethod + def tree_unflatten(cls, aux_data, children): + """ + Unflatten a PyTree into a model. + """ + names, cls_ = aux_data + arguments = {name: child for name, child in zip(names, children)} + return cls(cls_, **arguments) + + def dict(self): + return {"class_path": get_class_path(self.cls), **super().dict()} + + # noinspection PyAttributeOutsideInit + @property + def constructor_argument_names(self) -> List[str]: + """ + The argument names of the constructor of the class of this model. + """ + if self.cls not in class_args_dict: + try: + class_args_dict[self.cls] = [ + arg + for arg in inspect.getfullargspec(self.cls).args + if arg != "self" + ] + except TypeError: + class_args_dict[self.cls] = [] + return class_args_dict[self.cls] + + def __eq__(self, other): + return ( + isinstance(other, Model) + and self.cls == other.cls + and self.prior_tuples == other.prior_tuples + ) + + def make_prior(self, attribute_name): + """ + Returns a prior for an attribute of a class with a given name. The prior is + created by searching the default prior config for the attribute. + + Entries in configuration with a u become uniform priors; with a g become + gaussian priors; with a c become instances. + + If prior configuration for a given attribute is not specified in the + configuration for a class then the configuration corresponding to the parents + of that class is searched. If no configuration can be found then a prior + exception is raised. + + Parameters + ---------- + attribute_name: str + The name of the attribute for which a prior is created + + Returns + ------- + prior: p.Prior + A prior + + Raises + ------ + exc.PriorException + If no configuration can be found + """ + cls = self.cls + if isinstance(cls, ConfigException): + return cls + if not inspect.isclass(cls): + # noinspection PyProtectedMember + cls = inspect._findclass(cls) + try: + return Prior.for_class_and_attribute_name(cls, attribute_name) + except ConfigException as e: + return e + + def make_tuple_prior(self, name, length): + tuple_prior = TuplePrior() + for i in range(length): + attribute_name = "{}_{}".format(name, i) + setattr(tuple_prior, attribute_name, self.make_prior(attribute_name)) + return tuple_prior + + @assert_not_frozen + def __setattr__(self, key, value): + try: + value.label = namer(key) + except (AttributeError, TypeError): + pass + + if key not in ( + "component_number", + "phase_property_position", + "mapping_name", + "id", + "_is_frozen", + "_frozen_cache", + ): + try: + if "_" in key: + name = key.split("_")[0] + tuple_prior = [v for k, v in self.tuple_prior_tuples if name == k][ + 0 + ] + setattr(tuple_prior, key, value) + return + + except IndexError: + pass + + if isinstance(value, float): + value = Constant(value) + try: + super().__setattr__(key, value) + except AttributeError as e: + logger.exception(e) + logger.exception(key) + + def __getattr__(self, item): + try: + if ( + "_" in item + and item not in ("_is_frozen", "tuple_prior_tuples") + and not item.startswith("_") + ): + return getattr( + [v for k, v in self.tuple_prior_tuples if item.split("_")[0] == k][ + 0 + ], + item, + ) + + except IndexError: + pass + + self.__getattribute__(item) + + @property + def is_deferred_arguments(self): + return len(self.direct_deferred_tuples) > 0 + + # noinspection PyUnresolvedReferences + def _instance_for_arguments( + self, + arguments: {ModelObject: object}, + ignore_assertions=False, + xp=np, + ): + """ + Returns an instance of the associated class for a set of arguments + + Parameters + ---------- + arguments: {Prior: float} + Dictionary mapping_matrix priors to attribute analysis_path and value pairs + + Returns + ------- + An instance of the class + """ + model_arguments = dict() + attribute_arguments = { + key: value + for key, value in self.__dict__.items() + if key in self.constructor_argument_names + } + + for tuple_prior in self.tuple_prior_tuples: + model_arguments[tuple_prior.name] = tuple_prior.prior.value_for_arguments( + arguments + ) + for prior_model_tuple in self.direct_prior_model_tuples: + prior_model = prior_model_tuple.prior_model + model_arguments[ + prior_model_tuple.name + ] = prior_model.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + xp=xp + ) + + prior_arguments = dict() + + for name, prior in self.direct_prior_tuples: + try: + prior_arguments[name] = arguments[prior] + except KeyError as e: + raise KeyError(f"No argument given for prior {name}") from e + + constructor_arguments = { + **attribute_arguments, + **model_arguments, + **prior_arguments, + } + + constructor_arguments = { + key: value.value if isinstance(value, Constant) else value + for key, value in constructor_arguments.items() + } + + if self.is_deferred_arguments: + return DeferredInstance(self.cls, constructor_arguments) + + if not inspect.isclass(self.cls): + result = object.__new__(inspect._findclass(self.cls)) + cls = self.cls + cls(result, **constructor_arguments) + else: + result = self.cls(**constructor_arguments) + + excluded = type(self)._cached_property_names() + for key, value in self.__dict__.items(): + if ( + not hasattr(result, key) + and not isinstance(value, Prior) + and not key == "cls" + and not key.startswith("_") + and key not in excluded + ): + if isinstance(value, Model): + value = value.instance_for_arguments( + arguments, + ignore_assertions=ignore_assertions, + xp=xp + ) + elif isinstance(value, Constant): + value = value.value + elif isinstance(value, Prior): + value = arguments[value] + try: + setattr(result, key, value) + except AttributeError: + pass + + return result + + def gaussian_prior_model_for_arguments(self, arguments): + """ + Returns a new instance of model mapper with a set of Gaussian priors based on \ + tuples provided by a previous nonlinear search. + + Parameters + ---------- + arguments: [(float, float)] + Tuples providing the mean and sigma of gaussians + + Returns + ------- + new_model: ModelMapper + A new model mapper populated with Gaussian priors + """ + self.unfreeze() + new_model = copy.deepcopy(self) + + new_model._assertions = list() + + model_arguments = {t.name: arguments[t.prior] for t in self.direct_prior_tuples} + + for tuple_prior_tuple in self.tuple_prior_tuples: + setattr( + new_model, + tuple_prior_tuple.name, + tuple_prior_tuple.prior.gaussian_tuple_prior_for_arguments(arguments), + ) + for prior_tuple in self.direct_prior_tuples: + setattr(new_model, prior_tuple.name, model_arguments[prior_tuple.name]) + for instance_tuple in self.direct_instance_tuples: + setattr(new_model, instance_tuple.name, instance_tuple.instance) + + for name, prior_model in self.direct_prior_model_tuples: + setattr( + new_model, + name, + prior_model.gaussian_prior_model_for_arguments(arguments), + ) + + return new_model diff --git a/autofit/mapper/prior_model/recursion.py b/autofit/mapper/prior_model/recursion.py index fea27a4f7..62a44f2d2 100644 --- a/autofit/mapper/prior_model/recursion.py +++ b/autofit/mapper/prior_model/recursion.py @@ -1,88 +1,88 @@ -from functools import wraps - - -class RecursionPromise: - pass - - -def replace_promise(promise: RecursionPromise, obj, true_value, seen_objects=None): - """ - Traverse the object replacing any identity of the promise with the true value - - Parameters - ---------- - promise - A placeholder for an object that had not been computed at the time some part of the object was computed - obj - An object computed that may contain Promises - true_value - The true value associated with the promise - seen_objects - A set of ids of objects that have already been checked in this promise replacement - - Returns - ------- - obj - The object with any identities of the Promise replaced - """ - seen_objects = seen_objects or set() - if id(obj) in seen_objects: - return obj - - seen_objects.add(id(obj)) - - if isinstance(obj, list): - return [ - replace_promise(promise, item, true_value, seen_objects=seen_objects) - for item in obj - ] - if isinstance(obj, dict): - return { - key: replace_promise(promise, value, true_value, seen_objects=seen_objects) - for key, value in obj.items() - } - - if obj is promise: - return true_value - try: - for key, value in list(obj.__dict__.items()): - setattr( - obj, - key, - replace_promise(promise, value, true_value, seen_objects=seen_objects), - ) - except (AttributeError, TypeError): - pass - return obj - - -class DynamicRecursionCache: - def __init__(self): - """ - A decorating class that prevents infinite loops when recursing graphs by attaching placeholders - """ - self.cache = dict() - - def __call__(self, func): - """ - Decorate the function to prevent recursion. - - When the function is called with a set of arguments, A, a Promise is stored for that set of arguments in the - cache. If the function is called again with that set of arguments then the Promise is returned. When the - function itself returns a value any identity of the Promise is replaced by the actual value returned. - """ - - @wraps(func) - def wrapper(item, *args, **kwargs): - item_id = id(item) - - if item_id in self.cache: - return self.cache[item_id] - recursion_promise = RecursionPromise() - self.cache[item_id] = recursion_promise - result = func(item, *args, **kwargs) - result = replace_promise(recursion_promise, result, result) - del self.cache[item_id] - return result - - return wrapper +from functools import wraps + + +class RecursionPromise: + pass + + +def replace_promise(promise: RecursionPromise, obj, true_value, seen_objects=None): + """ + Traverse the object replacing any identity of the promise with the true value + + Parameters + ---------- + promise + A placeholder for an object that had not been computed at the time some part of the object was computed + obj + An object computed that may contain Promises + true_value + The true value associated with the promise + seen_objects + A set of ids of objects that have already been checked in this promise replacement + + Returns + ------- + obj + The object with any identities of the Promise replaced + """ + seen_objects = seen_objects or set() + if id(obj) in seen_objects: + return obj + + seen_objects.add(id(obj)) + + if isinstance(obj, list): + return [ + replace_promise(promise, item, true_value, seen_objects=seen_objects) + for item in obj + ] + if isinstance(obj, dict): + return { + key: replace_promise(promise, value, true_value, seen_objects=seen_objects) + for key, value in obj.items() + } + + if obj is promise: + return true_value + try: + for key, value in list(obj.__dict__.items()): + setattr( + obj, + key, + replace_promise(promise, value, true_value, seen_objects=seen_objects), + ) + except (AttributeError, TypeError): + pass + return obj + + +class DynamicRecursionCache: + def __init__(self): + """ + A decorating class that prevents infinite loops when recursing graphs by attaching placeholders + """ + self.cache = dict() + + def __call__(self, func): + """ + Decorate the function to prevent recursion. + + When the function is called with a set of arguments, A, a Promise is stored for that set of arguments in the + cache. If the function is called again with that set of arguments then the Promise is returned. When the + function itself returns a value any identity of the Promise is replaced by the actual value returned. + """ + + @wraps(func) + def wrapper(item, *args, **kwargs): + item_id = id(item) + + if item_id in self.cache: + return self.cache[item_id] + recursion_promise = RecursionPromise() + self.cache[item_id] = recursion_promise + result = func(item, *args, **kwargs) + result = replace_promise(recursion_promise, result, result) + del self.cache[item_id] + return result + + return wrapper diff --git a/autofit/mapper/prior_model/util.py b/autofit/mapper/prior_model/util.py index 822ce5035..258862175 100644 --- a/autofit/mapper/prior_model/util.py +++ b/autofit/mapper/prior_model/util.py @@ -1,33 +1,33 @@ -import inspect -from typing import Type, Dict -import typing - -from autofit.mapper.prior_model.attribute_pair import AttributeNameValue - - -class PriorModelNameValue(AttributeNameValue): - @property - def prior_model(self): - return self.value - - -def gather_namespaces(cls: Type) -> Dict[str, Dict]: - """ - Recursively gather the globals and locals for a given class and its parent classes. - """ - namespaces = {} - - try: - for base in inspect.getmro(cls): - if base is object: - continue - - module = inspect.getmodule(base) - if module: - namespaces.update(vars(module)) - except AttributeError: - pass - - namespaces.update(vars(typing)) - - return namespaces +import inspect +from typing import Type, Dict +import typing + +from autofit.mapper.prior_model.attribute_pair import AttributeNameValue + + +class PriorModelNameValue(AttributeNameValue): + @property + def prior_model(self): + return self.value + + +def gather_namespaces(cls: Type) -> Dict[str, Dict]: + """ + Recursively gather the globals and locals for a given class and its parent classes. + """ + namespaces = {} + + try: + for base in inspect.getmro(cls): + if base is object: + continue + + module = inspect.getmodule(base) + if module: + namespaces.update(vars(module)) + except AttributeError: + pass + + namespaces.update(vars(typing)) + + return namespaces diff --git a/autofit/mapper/variable.py b/autofit/mapper/variable.py index a3073901b..0f2266de2 100644 --- a/autofit/mapper/variable.py +++ b/autofit/mapper/variable.py @@ -1,591 +1,591 @@ -from itertools import chain, count -from typing import Optional, Tuple, Dict, Set, Union, List, TYPE_CHECKING -import operator -from abc import ABC, abstractmethod -from functools import wraps, reduce -from math import sqrt - -import numpy as np -from autonerves import cached_property - -from autofit.mapper.model_object import ModelObject - -if TYPE_CHECKING: - from autofit.mapper.operator import LinearOperator - from autofit.mapper.variable_operator import VariableFullOperator, VariableOperator - - -class Plate: - _ids = count() - - def __init__(self, name: Optional[str] = None): - """ - Represents a dimension, such as number of observations, features or dimensions - - Parameters - ---------- - name - The name of this dimension - """ - self.id = next(self._ids) - self.name = name or f"plate_{self.id}" - - def __repr__(self): - return f"{type(self).__name__}(name={self.name})" - - def __eq__(self, other): - return isinstance(other, Plate) and self.id == other.id - - def __hash__(self): - return self.id - - def __lt__(self, other): - return self.id < other.id - - def __gt__(self, other): - return self.id > other.id - - def make_index_seq( - self, - plates_index: Dict["Plate", Union[List[int], range, slice]], - plate_sizes: Dict["Plate", int], - ) -> Union[List[int], range]: - seq = plates_index.get(self, range(plate_sizes[self])) - if isinstance(seq, slice): - seq = range(plate_sizes[self])[seq] - - return seq - - -def plates(*vals): - """Helper function for making multiple plate objects - - Example - ------- - x_, a_, b_, y_, z_ = plates("x, a, b", "y, z") - """ - for val in vals: - for v in val.split(","): - yield Plate(v.strip()) - - -class Variable(ModelObject): - # __slots__ = ("name", "plates") - - def __init__(self, name: str = None, *plates: Plate, id_=None): - """ - Represents a variable in the problem. This may be fixed data or some coefficient - that we are optimising for. - - Parameters - ---------- - name - The name of this variable - plates - Representation of the dimensions of this variable - """ - self.plates = plates - super().__init__(id_=id_) - self._name = name - - @property - def name(self): - return self._name or f"{self.__class__.__name__.lower()}_{self.id}" - - def __repr__(self): - args = ", ".join(chain([self.name], map(repr, self.plates))) - return f"{self.__class__.__name__}({args})" - - def __hash__(self): - return self.id - - def __len__(self): - return len(self.plates) - - def __str__(self): - return self.name - - @property - def ndim(self) -> int: - """ - How many dimensions does this variable have? - """ - return len(self.plates) - - def make_indexes( - self, - plates_index: Dict["Plate", Union[List[int], range, slice]], - plate_sizes: Dict["Plate", int], - ) -> Tuple[np.ndarray, ...]: - if any(p in plates_index for p in self.plates): - return np.ix_( - *(p.make_index_seq(plates_index, plate_sizes) for p in self.plates) - ) - return () - - -def variables(*vals): - """Helper function for making multiple variable objects - - Example - ------- - x_, a_, b_, y_, z_ = variables("x, a, b", "y, z") - """ - for val in vals: - for v in val.split(","): - yield Variable(v.strip()) - - -# This allows us to treat the class FactorValue as a variable -# that allows us to keep track of the FactorValue vs deterministic -# values when calculating gradients and jacobians -class VariableMetaClass(type, Variable): - def __new__(cls, clsname, bases, attrs): - newcls = super().__new__(cls, clsname, bases, attrs) - Variable.__init__(newcls, clsname) - return newcls - - -class FactorValue(np.ndarray, metaclass=VariableMetaClass): - def __new__(cls, input_array, deterministic_values=None): - obj = np.asarray(input_array).view(cls) - obj.deterministic_values = deterministic_values or {} - return obj - - def __array_finalize__(self, obj): - if obj is None: - return - self.deterministic_values = getattr(obj, "deterministic_values", None) - - @property - def log_value(self) -> np.ndarray: - if self.shape: - return self.base - else: - return self.item() - - def __getitem__(self, index) -> np.ndarray: - if index is type(self): - return self - elif isinstance(index, Variable): - return self.deterministic_values[index] - else: - return super().__getitem__(index) - - def keys(self): - return self.deterministic_values.keys() - - def values(self): - return self.deterministic_values.values() - - def items(self): - return self.deterministic_values.items() - - deterministic_variables = property(keys) - - def __repr__(self): - r = np.ndarray.__repr__(self) - return r[:-1] + ", " + repr(self.deterministic_values) + ")" - - def to_dict(self): - return VariableData({FactorValue: self.base, **self.deterministic_values}) - - -def broadcast_plates( - value: np.ndarray, - in_plates: Tuple[Plate, ...], - out_plates: Tuple[Plate, ...], - reducer: np.ufunc = np.sum, -) -> np.ndarray: - """ - Extract the indices of a collection of plates then match - the shape of the data to that shape. - - Parameters - ---------- - value - A value to broadcast - in_plates - Plates representing the dimensions of the values - out_plates - Plates representing the output dimensions - reducer - function to reduce excess plates over, default np.sum - must take axis as keyword argument - - - Returns - ------- - The value reshaped to match the plates - """ - n_in = len(in_plates) - n_out = len(out_plates) - shift = np.ndim(value) - n_in - if not (0 <= shift <= 1): - raise ValueError("dimensions of value incompatible with passed plates") - - in_axes = list(range(shift, n_in + shift)) - out_axes = [] - k = n_out + shift - - for plate in in_plates: - try: - out_axes.append(out_plates.index(plate) + shift) - except ValueError: - out_axes.append(k) - k += 1 - - moved_value = np.moveaxis( - np.expand_dims(value, tuple(range(n_in + shift, k))), - in_axes, - out_axes, - ) - return reducer(moved_value, axis=tuple(range(n_out + shift, k))) - - -def _get_variable_data_class(data): - # So that these methods work with standard python dictionaries - return type(data) if isinstance(data, VariableData) else VariableData - - -def _unary_op(op): - @wraps(op) - def __op__(self): - cls = _get_variable_data_class(self) - return cls({k: op(val) for k, val in self.items()}) - - return __op__ - - -def _binary_op(op, ravel=False): - if ravel: - - @wraps(op) - def __op__(self, other): - cls = _get_variable_data_class(self) - if isinstance(other, FactorValue): - other = other.to_dict() - - if isinstance(other, dict): - return cls( - { - k: op(np.ravel(self[k]), np.ravel(other[k])) - for k in self.keys() & other.keys() - } - ) - elif isinstance(other, VariableLinearOperator): - return op(dict(self), other) - else: - return cls({k: op(val, other) for k, val in self.items()}) - - else: - - @wraps(op) - def __op__(self, other): - cls = _get_variable_data_class(self) - if isinstance(other, FactorValue): - other = other.to_dict() - - if isinstance(other, dict): - return cls( - {k: op(self[k], other[k]) for k in self.keys() & other.keys()} - ) - elif isinstance(other, VariableLinearOperator): - return op(dict(self), other) - else: - return cls({k: op(val, other) for k, val in self.items()}) - - return __op__ - - -def rmul(x, y): - return y * x - - -def rtruediv(x, y): - return y / x - - -class VariableData(Dict[Variable, np.ndarray]): - var_norm = _unary_op(np.linalg.norm) - var_det = _unary_op(np.linalg.det) - var_max = _unary_op(np.max) - var_min = _unary_op(np.min) - var_sum = _unary_op(np.sum) - var_all = _unary_op(np.all) - var_any = _unary_op(np.any) - var_prod = _unary_op(np.prod) - var_isfinite = _unary_op(np.isfinite) - - __abs__ = _unary_op(operator.abs) - __pos__ = _unary_op(operator.pos) - __neg__ = _unary_op(operator.neg) - __lt__ = _binary_op(operator.lt) - __le__ = _binary_op(operator.le) - __eq__ = _binary_op(operator.eq) - __ne__ = _binary_op(operator.ne) - __gt__ = _binary_op(operator.gt) - __ge__ = _binary_op(operator.ge) - __and__ = _binary_op(operator.and_) - __and__ = _binary_op(operator.or_) - - __add__ = _binary_op(operator.add) - __radd__ = _binary_op(operator.add) - __sub__ = _binary_op(operator.sub) - __rsub__ = _binary_op(operator.sub) - __mul__ = _binary_op(operator.mul) - __rmul__ = _binary_op(rmul) - __truediv__ = _binary_op(operator.truediv) - __rtruediv__ = _binary_op(rtruediv) - __pow__ = _binary_op(operator.pow) - - abs = __abs__ - neg = __neg__ - sub = __sub__ - add = __add__ - mul = __mul__ - div = __truediv__ - var_dot = _binary_op(np.dot, ravel=True) - - @property - def T(self): - return type(self)({k: val.T for k, val in self.items()}) - - @property - def shapes(self): - return {k: np.shape(val) for k, val in self.items()} - - @property - def sizes(self): - return {k: np.size(val) for k, val in self.items()} - - @property - def size(self): - return sum(np.size(val) for val in self.values()) - - def ravel(self): - cls = _get_variable_data_class(self) - return cls({k: np.ravel(val) for k, val in self.items()}) - - def map(self, func, *args, **kwargs): - cls = _get_variable_data_class(self) - return cls( - (k, func(val, *(arg[k] for arg in args), **kwargs)) - for k, val in self.items() - ) - - def reduce(self, func): - return reduce(func, self.values()) - - def mapreduce(self, func, op): - return VariableData.map(self, func).reduce(op) - - def subset(self, variables): - cls = _get_variable_data_class(self) - return cls((v, self[v]) for v in variables if v in self) - - def sum(self) -> float: - return sum(VariableData.var_sum(self).values()) - - def prod(self) -> float: - return VariableData.reduce(VariableData.var_prod(self), operator.mul) - - def all(self) -> bool: - return all(VariableData.var_all(self).values()) - - def any(self) -> bool: - return any(VariableData.var_all(self).values()) - - def det(self) -> float: - return VariableData.var_det(self).reduce(operator.mul) - - def log_det(self) -> float: - return VariableData.mapreduce(self, np.linalg.logdet, operator.add) - - def max(self) -> float: - return max(VariableData.var_max(self).values()) - - def min(self) -> float: - return min(VariableData.var_min(self).values()) - - def dot(self, other) -> float: - return VariableData.var_dot(self, other).sum() - - def norm(self) -> float: - return sqrt(VariableData.dot(self, self)) - - def vecnorm(self, ord: Optional[float] = None) -> float: - if ord: - absval = VariableData.abs(self) - if ord == np.inf: - return absval.max() - elif ord == -np.inf: - return absval.min() - else: - return (absval**ord).sum() ** (1.0 / ord) - else: - return VariableData.norm(self) - - def __repr__(self): - name = type(self).__name__ - data_repr = dict.__repr__(self) - return f"{name}({data_repr})" - - def merge(self, other): - return VariableData({**self, **other}) - - def plate_sizes(self): - sizes = {} - for v, val in self.items(): - shape = np.shape(val) - assert len(shape) == len( - v.plates - ), f"shape must match the number of plates of {v}" - for p, s in zip(v.plates, shape): - assert ( - sizes.setdefault(p, s) == s - ), f"plate sizes must be consistent, {sizes[p]} != {s}" - return sizes - - def full_like(self, fill_value, **kwargs): - return type(self)( - {v: np.full_like(val, fill_value, **kwargs) for v, val in self.items()} - ) - - def zeros_like(self, **kwargs): - return self.full_like(0.0) - - -class VariableLinearOperator(ABC): - """Implements the functionality of a linear operator acting - on a dictionary of values indexed by `Variable` objects - """ - - @abstractmethod - def __mul__(self, x: VariableData) -> VariableData: - pass - - @abstractmethod - def __rtruediv__(self, x: VariableData) -> VariableData: - pass - - @abstractmethod - def __rmul__(self, x: VariableData) -> VariableData: - pass - - @abstractmethod - def ldiv(self, x: VariableData) -> VariableData: - pass - - @property - @abstractmethod - def variables(self) -> VariableData: - pass - - def __getitem__(self, variable) -> "LinearOperator": - raise NotImplementedError() - - def get(self, variable, default=None): - try: - return self[variable] - except KeyError: - return default - - def __contains__(self, variable): - return variable in self.variables - - def dot(self, x): - return self * x - - __matmul__ = dot - - def inv(self) -> "InverseVariableOperator": - return InverseVariableOperator(self) - - def quad(self, M: VariableData) -> VariableData: - return (M * self).T * self - - def invquad(self, M: VariableData) -> VariableData: - return (M / self).T / self - - @abstractmethod - def update(self, *args: Tuple[VariableData, VariableData]): - pass - - def lowrankupdate(self, *values: VariableData): - return self.update(*((value, value) for value in values())) - - def lowrankdowndate(self, *values: VariableData): - return self.update(*((value, VariableData.neg(value)) for value in values())) - - def blocks(self): - return self.to_block().blocks() - - -class InverseVariableOperator(VariableLinearOperator): - def __init__(self, op): - self.operator = op - - def __mul__(self, x: VariableData) -> VariableData: - return self.operator.ldiv(x) - - def __rtruediv__(self, x: VariableData) -> VariableData: - return x * self.operator - - def __rmul__(self, x: VariableData) -> VariableData: - return x / self.operator - - def ldiv(self, x: VariableData) -> VariableData: - return self * x - - def quad(self, M: VariableData) -> VariableData: - return self.operator.invquad(M) - - def invquad(self, M: VariableData) -> VariableData: - return self.operator.quad(M) - - def inv(self) -> VariableLinearOperator: - return self.operator - - @property - def variables(self) -> Set[Variable]: - return self.operator.variables - - @property - def is_diagonal(self): - return self.operator.is_diagonal - - @cached_property - def log_det(self): - return -self.operator.log_det - - def update(self, *args: Tuple[VariableData, VariableData]): - # apply Sherman-Morrison formulat - A = self.operator - for u, v in args: - A1u = A * u - A1v = v * A - vTA1u = -A1u.dot(v) - A = A.update(A1u, A1v * vTA1u) - - return type(self)(A) - - def diagonalupdate(self, d: VariableData): - A = self.operator.diagonalupdate(d**-1) - return type(self)(A) - - def to_full(self) -> "VariableFullOperator": - full_op = self.operator.to_full() - M = np.linalg.inv(full_op.operator.to_dense()) - return full_op.from_dense(M, full_op.param_shapes) - - def diagonal(self) -> VariableData: - full_op = self.to_full() - diag = full_op.operator.to_dense().diagonal() - return full_op.param_shapes.unflatten(diag) - - def to_block(self) -> "VariableOperator": - return self.to_full().to_block() - - def __getitem__(self, variable): - return self.to_full()[variable] +from itertools import chain, count +from typing import Optional, Tuple, Dict, Set, Union, List, TYPE_CHECKING +import operator +from abc import ABC, abstractmethod +from functools import wraps, reduce +from math import sqrt + +import numpy as np +from autonerves import cached_property + +from autofit.mapper.model_object import ModelObject + +if TYPE_CHECKING: + from autofit.mapper.operator import LinearOperator + from autofit.mapper.variable_operator import VariableFullOperator, VariableOperator + + +class Plate: + _ids = count() + + def __init__(self, name: Optional[str] = None): + """ + Represents a dimension, such as number of observations, features or dimensions + + Parameters + ---------- + name + The name of this dimension + """ + self.id = next(self._ids) + self.name = name or f"plate_{self.id}" + + def __repr__(self): + return f"{type(self).__name__}(name={self.name})" + + def __eq__(self, other): + return isinstance(other, Plate) and self.id == other.id + + def __hash__(self): + return self.id + + def __lt__(self, other): + return self.id < other.id + + def __gt__(self, other): + return self.id > other.id + + def make_index_seq( + self, + plates_index: Dict["Plate", Union[List[int], range, slice]], + plate_sizes: Dict["Plate", int], + ) -> Union[List[int], range]: + seq = plates_index.get(self, range(plate_sizes[self])) + if isinstance(seq, slice): + seq = range(plate_sizes[self])[seq] + + return seq + + +def plates(*vals): + """Helper function for making multiple plate objects + + Example + ------- + x_, a_, b_, y_, z_ = plates("x, a, b", "y, z") + """ + for val in vals: + for v in val.split(","): + yield Plate(v.strip()) + + +class Variable(ModelObject): + # __slots__ = ("name", "plates") + + def __init__(self, name: str = None, *plates: Plate, id_=None): + """ + Represents a variable in the problem. This may be fixed data or some coefficient + that we are optimising for. + + Parameters + ---------- + name + The name of this variable + plates + Representation of the dimensions of this variable + """ + self.plates = plates + super().__init__(id_=id_) + self._name = name + + @property + def name(self): + return self._name or f"{self.__class__.__name__.lower()}_{self.id}" + + def __repr__(self): + args = ", ".join(chain([self.name], map(repr, self.plates))) + return f"{self.__class__.__name__}({args})" + + def __hash__(self): + return self.id + + def __len__(self): + return len(self.plates) + + def __str__(self): + return self.name + + @property + def ndim(self) -> int: + """ + How many dimensions does this variable have? + """ + return len(self.plates) + + def make_indexes( + self, + plates_index: Dict["Plate", Union[List[int], range, slice]], + plate_sizes: Dict["Plate", int], + ) -> Tuple[np.ndarray, ...]: + if any(p in plates_index for p in self.plates): + return np.ix_( + *(p.make_index_seq(plates_index, plate_sizes) for p in self.plates) + ) + return () + + +def variables(*vals): + """Helper function for making multiple variable objects + + Example + ------- + x_, a_, b_, y_, z_ = variables("x, a, b", "y, z") + """ + for val in vals: + for v in val.split(","): + yield Variable(v.strip()) + + +# This allows us to treat the class FactorValue as a variable +# that allows us to keep track of the FactorValue vs deterministic +# values when calculating gradients and jacobians +class VariableMetaClass(type, Variable): + def __new__(cls, clsname, bases, attrs): + newcls = super().__new__(cls, clsname, bases, attrs) + Variable.__init__(newcls, clsname) + return newcls + + +class FactorValue(np.ndarray, metaclass=VariableMetaClass): + def __new__(cls, input_array, deterministic_values=None): + obj = np.asarray(input_array).view(cls) + obj.deterministic_values = deterministic_values or {} + return obj + + def __array_finalize__(self, obj): + if obj is None: + return + self.deterministic_values = getattr(obj, "deterministic_values", None) + + @property + def log_value(self) -> np.ndarray: + if self.shape: + return self.base + else: + return self.item() + + def __getitem__(self, index) -> np.ndarray: + if index is type(self): + return self + elif isinstance(index, Variable): + return self.deterministic_values[index] + else: + return super().__getitem__(index) + + def keys(self): + return self.deterministic_values.keys() + + def values(self): + return self.deterministic_values.values() + + def items(self): + return self.deterministic_values.items() + + deterministic_variables = property(keys) + + def __repr__(self): + r = np.ndarray.__repr__(self) + return r[:-1] + ", " + repr(self.deterministic_values) + ")" + + def to_dict(self): + return VariableData({FactorValue: self.base, **self.deterministic_values}) + + +def broadcast_plates( + value: np.ndarray, + in_plates: Tuple[Plate, ...], + out_plates: Tuple[Plate, ...], + reducer: np.ufunc = np.sum, +) -> np.ndarray: + """ + Extract the indices of a collection of plates then match + the shape of the data to that shape. + + Parameters + ---------- + value + A value to broadcast + in_plates + Plates representing the dimensions of the values + out_plates + Plates representing the output dimensions + reducer + function to reduce excess plates over, default np.sum + must take axis as keyword argument + + + Returns + ------- + The value reshaped to match the plates + """ + n_in = len(in_plates) + n_out = len(out_plates) + shift = np.ndim(value) - n_in + if not (0 <= shift <= 1): + raise ValueError("dimensions of value incompatible with passed plates") + + in_axes = list(range(shift, n_in + shift)) + out_axes = [] + k = n_out + shift + + for plate in in_plates: + try: + out_axes.append(out_plates.index(plate) + shift) + except ValueError: + out_axes.append(k) + k += 1 + + moved_value = np.moveaxis( + np.expand_dims(value, tuple(range(n_in + shift, k))), + in_axes, + out_axes, + ) + return reducer(moved_value, axis=tuple(range(n_out + shift, k))) + + +def _get_variable_data_class(data): + # So that these methods work with standard python dictionaries + return type(data) if isinstance(data, VariableData) else VariableData + + +def _unary_op(op): + @wraps(op) + def __op__(self): + cls = _get_variable_data_class(self) + return cls({k: op(val) for k, val in self.items()}) + + return __op__ + + +def _binary_op(op, ravel=False): + if ravel: + + @wraps(op) + def __op__(self, other): + cls = _get_variable_data_class(self) + if isinstance(other, FactorValue): + other = other.to_dict() + + if isinstance(other, dict): + return cls( + { + k: op(np.ravel(self[k]), np.ravel(other[k])) + for k in self.keys() & other.keys() + } + ) + elif isinstance(other, VariableLinearOperator): + return op(dict(self), other) + else: + return cls({k: op(val, other) for k, val in self.items()}) + + else: + + @wraps(op) + def __op__(self, other): + cls = _get_variable_data_class(self) + if isinstance(other, FactorValue): + other = other.to_dict() + + if isinstance(other, dict): + return cls( + {k: op(self[k], other[k]) for k in self.keys() & other.keys()} + ) + elif isinstance(other, VariableLinearOperator): + return op(dict(self), other) + else: + return cls({k: op(val, other) for k, val in self.items()}) + + return __op__ + + +def rmul(x, y): + return y * x + + +def rtruediv(x, y): + return y / x + + +class VariableData(Dict[Variable, np.ndarray]): + var_norm = _unary_op(np.linalg.norm) + var_det = _unary_op(np.linalg.det) + var_max = _unary_op(np.max) + var_min = _unary_op(np.min) + var_sum = _unary_op(np.sum) + var_all = _unary_op(np.all) + var_any = _unary_op(np.any) + var_prod = _unary_op(np.prod) + var_isfinite = _unary_op(np.isfinite) + + __abs__ = _unary_op(operator.abs) + __pos__ = _unary_op(operator.pos) + __neg__ = _unary_op(operator.neg) + __lt__ = _binary_op(operator.lt) + __le__ = _binary_op(operator.le) + __eq__ = _binary_op(operator.eq) + __ne__ = _binary_op(operator.ne) + __gt__ = _binary_op(operator.gt) + __ge__ = _binary_op(operator.ge) + __and__ = _binary_op(operator.and_) + __and__ = _binary_op(operator.or_) + + __add__ = _binary_op(operator.add) + __radd__ = _binary_op(operator.add) + __sub__ = _binary_op(operator.sub) + __rsub__ = _binary_op(operator.sub) + __mul__ = _binary_op(operator.mul) + __rmul__ = _binary_op(rmul) + __truediv__ = _binary_op(operator.truediv) + __rtruediv__ = _binary_op(rtruediv) + __pow__ = _binary_op(operator.pow) + + abs = __abs__ + neg = __neg__ + sub = __sub__ + add = __add__ + mul = __mul__ + div = __truediv__ + var_dot = _binary_op(np.dot, ravel=True) + + @property + def T(self): + return type(self)({k: val.T for k, val in self.items()}) + + @property + def shapes(self): + return {k: np.shape(val) for k, val in self.items()} + + @property + def sizes(self): + return {k: np.size(val) for k, val in self.items()} + + @property + def size(self): + return sum(np.size(val) for val in self.values()) + + def ravel(self): + cls = _get_variable_data_class(self) + return cls({k: np.ravel(val) for k, val in self.items()}) + + def map(self, func, *args, **kwargs): + cls = _get_variable_data_class(self) + return cls( + (k, func(val, *(arg[k] for arg in args), **kwargs)) + for k, val in self.items() + ) + + def reduce(self, func): + return reduce(func, self.values()) + + def mapreduce(self, func, op): + return VariableData.map(self, func).reduce(op) + + def subset(self, variables): + cls = _get_variable_data_class(self) + return cls((v, self[v]) for v in variables if v in self) + + def sum(self) -> float: + return sum(VariableData.var_sum(self).values()) + + def prod(self) -> float: + return VariableData.reduce(VariableData.var_prod(self), operator.mul) + + def all(self) -> bool: + return all(VariableData.var_all(self).values()) + + def any(self) -> bool: + return any(VariableData.var_all(self).values()) + + def det(self) -> float: + return VariableData.var_det(self).reduce(operator.mul) + + def log_det(self) -> float: + return VariableData.mapreduce(self, np.linalg.logdet, operator.add) + + def max(self) -> float: + return max(VariableData.var_max(self).values()) + + def min(self) -> float: + return min(VariableData.var_min(self).values()) + + def dot(self, other) -> float: + return VariableData.var_dot(self, other).sum() + + def norm(self) -> float: + return sqrt(VariableData.dot(self, self)) + + def vecnorm(self, ord: Optional[float] = None) -> float: + if ord: + absval = VariableData.abs(self) + if ord == np.inf: + return absval.max() + elif ord == -np.inf: + return absval.min() + else: + return (absval**ord).sum() ** (1.0 / ord) + else: + return VariableData.norm(self) + + def __repr__(self): + name = type(self).__name__ + data_repr = dict.__repr__(self) + return f"{name}({data_repr})" + + def merge(self, other): + return VariableData({**self, **other}) + + def plate_sizes(self): + sizes = {} + for v, val in self.items(): + shape = np.shape(val) + assert len(shape) == len( + v.plates + ), f"shape must match the number of plates of {v}" + for p, s in zip(v.plates, shape): + assert ( + sizes.setdefault(p, s) == s + ), f"plate sizes must be consistent, {sizes[p]} != {s}" + return sizes + + def full_like(self, fill_value, **kwargs): + return type(self)( + {v: np.full_like(val, fill_value, **kwargs) for v, val in self.items()} + ) + + def zeros_like(self, **kwargs): + return self.full_like(0.0) + + +class VariableLinearOperator(ABC): + """Implements the functionality of a linear operator acting + on a dictionary of values indexed by `Variable` objects + """ + + @abstractmethod + def __mul__(self, x: VariableData) -> VariableData: + pass + + @abstractmethod + def __rtruediv__(self, x: VariableData) -> VariableData: + pass + + @abstractmethod + def __rmul__(self, x: VariableData) -> VariableData: + pass + + @abstractmethod + def ldiv(self, x: VariableData) -> VariableData: + pass + + @property + @abstractmethod + def variables(self) -> VariableData: + pass + + def __getitem__(self, variable) -> "LinearOperator": + raise NotImplementedError() + + def get(self, variable, default=None): + try: + return self[variable] + except KeyError: + return default + + def __contains__(self, variable): + return variable in self.variables + + def dot(self, x): + return self * x + + __matmul__ = dot + + def inv(self) -> "InverseVariableOperator": + return InverseVariableOperator(self) + + def quad(self, M: VariableData) -> VariableData: + return (M * self).T * self + + def invquad(self, M: VariableData) -> VariableData: + return (M / self).T / self + + @abstractmethod + def update(self, *args: Tuple[VariableData, VariableData]): + pass + + def lowrankupdate(self, *values: VariableData): + return self.update(*((value, value) for value in values())) + + def lowrankdowndate(self, *values: VariableData): + return self.update(*((value, VariableData.neg(value)) for value in values())) + + def blocks(self): + return self.to_block().blocks() + + +class InverseVariableOperator(VariableLinearOperator): + def __init__(self, op): + self.operator = op + + def __mul__(self, x: VariableData) -> VariableData: + return self.operator.ldiv(x) + + def __rtruediv__(self, x: VariableData) -> VariableData: + return x * self.operator + + def __rmul__(self, x: VariableData) -> VariableData: + return x / self.operator + + def ldiv(self, x: VariableData) -> VariableData: + return self * x + + def quad(self, M: VariableData) -> VariableData: + return self.operator.invquad(M) + + def invquad(self, M: VariableData) -> VariableData: + return self.operator.quad(M) + + def inv(self) -> VariableLinearOperator: + return self.operator + + @property + def variables(self) -> Set[Variable]: + return self.operator.variables + + @property + def is_diagonal(self): + return self.operator.is_diagonal + + @cached_property + def log_det(self): + return -self.operator.log_det + + def update(self, *args: Tuple[VariableData, VariableData]): + # apply Sherman-Morrison formulat + A = self.operator + for u, v in args: + A1u = A * u + A1v = v * A + vTA1u = -A1u.dot(v) + A = A.update(A1u, A1v * vTA1u) + + return type(self)(A) + + def diagonalupdate(self, d: VariableData): + A = self.operator.diagonalupdate(d**-1) + return type(self)(A) + + def to_full(self) -> "VariableFullOperator": + full_op = self.operator.to_full() + M = np.linalg.inv(full_op.operator.to_dense()) + return full_op.from_dense(M, full_op.param_shapes) + + def diagonal(self) -> VariableData: + full_op = self.to_full() + diag = full_op.operator.to_dense().diagonal() + return full_op.param_shapes.unflatten(diag) + + def to_block(self) -> "VariableOperator": + return self.to_full().to_block() + + def __getitem__(self, variable): + return self.to_full()[variable] diff --git a/autofit/messages/__init__.py b/autofit/messages/__init__.py index ac94276b3..91cf32721 100644 --- a/autofit/messages/__init__.py +++ b/autofit/messages/__init__.py @@ -1,13 +1,13 @@ -from .abstract import AbstractMessage -from .beta import BetaMessage -from .fixed import FixedMessage -from .gamma import GammaMessage -from .normal import ( - NormalMessage, - NaturalNormal, - UniformNormalMessage, - LogNormalMessage, - Log10NormalMessage, - MultiLogitNormalMessage, - Log10UniformNormalMessage, -) +from .abstract import AbstractMessage +from .beta import BetaMessage +from .fixed import FixedMessage +from .gamma import GammaMessage +from .normal import ( + NormalMessage, + NaturalNormal, + UniformNormalMessage, + LogNormalMessage, + Log10NormalMessage, + MultiLogitNormalMessage, + Log10UniformNormalMessage, +) diff --git a/autofit/mock.py b/autofit/mock.py index 98ddd262a..642509658 100644 --- a/autofit/mock.py +++ b/autofit/mock.py @@ -1,32 +1,32 @@ -from autofit.non_linear.mock.mock_analysis import MockAnalysis -from autofit.non_linear.mock.mock_result import MockResult -from autofit.non_linear.mock.mock_result import MockResultGrid -from autofit.non_linear.mock.mock_search import MockSearch -from autofit.non_linear.mock.mock_search import MockMLE -from autofit.non_linear.mock.mock_samples_summary import MockSamplesSummary -from autofit.non_linear.mock.mock_samples import MockSamples -from autofit.non_linear.mock.mock_samples import MockSamplesNest - -from autofit.mapper.mock.mock_model import MockChildTuple -from autofit.mapper.mock.mock_model import MockClassInf -from autofit.mapper.mock.mock_model import MockClassRelativeWidth -from autofit.mapper.mock.mock_model import MockChildTuplex3 -from autofit.mapper.mock.mock_model import MockChildTuplex2 -from autofit.mapper.mock.mock_model import MockClassx3TupleFloat -from autofit.mapper.mock.mock_model import MockClassx2 -from autofit.mapper.mock.mock_model import MockClassx2Instance -from autofit.mapper.mock.mock_model import MockClassx2FormatExp -from autofit.mapper.mock.mock_model import MockClassx2NoSuperScript -from autofit.mapper.mock.mock_model import MockClassx2Tuple -from autofit.mapper.mock.mock_model import MockClassx3 -from autofit.mapper.mock.mock_model import MockClassx3TupleFloat -from autofit.mapper.mock.mock_model import MockClassx4 -from autofit.mapper.mock.mock_model import MockComplexClass -from autofit.mapper.mock.mock_model import MockComponents -from autofit.mapper.mock.mock_model import MockDeferredClass -from autofit.mapper.mock.mock_model import MockListClass -from autofit.mapper.mock.mock_model import MockOverload -from autofit.mapper.mock.mock_model import MockParent -from autofit.mapper.mock.mock_model import MockWithFloat -from autofit.mapper.mock.mock_model import MockWithTuple - +from autofit.non_linear.mock.mock_analysis import MockAnalysis +from autofit.non_linear.mock.mock_result import MockResult +from autofit.non_linear.mock.mock_result import MockResultGrid +from autofit.non_linear.mock.mock_search import MockSearch +from autofit.non_linear.mock.mock_search import MockMLE +from autofit.non_linear.mock.mock_samples_summary import MockSamplesSummary +from autofit.non_linear.mock.mock_samples import MockSamples +from autofit.non_linear.mock.mock_samples import MockSamplesNest + +from autofit.mapper.mock.mock_model import MockChildTuple +from autofit.mapper.mock.mock_model import MockClassInf +from autofit.mapper.mock.mock_model import MockClassRelativeWidth +from autofit.mapper.mock.mock_model import MockChildTuplex3 +from autofit.mapper.mock.mock_model import MockChildTuplex2 +from autofit.mapper.mock.mock_model import MockClassx3TupleFloat +from autofit.mapper.mock.mock_model import MockClassx2 +from autofit.mapper.mock.mock_model import MockClassx2Instance +from autofit.mapper.mock.mock_model import MockClassx2FormatExp +from autofit.mapper.mock.mock_model import MockClassx2NoSuperScript +from autofit.mapper.mock.mock_model import MockClassx2Tuple +from autofit.mapper.mock.mock_model import MockClassx3 +from autofit.mapper.mock.mock_model import MockClassx3TupleFloat +from autofit.mapper.mock.mock_model import MockClassx4 +from autofit.mapper.mock.mock_model import MockComplexClass +from autofit.mapper.mock.mock_model import MockComponents +from autofit.mapper.mock.mock_model import MockDeferredClass +from autofit.mapper.mock.mock_model import MockListClass +from autofit.mapper.mock.mock_model import MockOverload +from autofit.mapper.mock.mock_model import MockParent +from autofit.mapper.mock.mock_model import MockWithFloat +from autofit.mapper.mock.mock_model import MockWithTuple + diff --git a/autofit/non_linear/fitness.py b/autofit/non_linear/fitness.py index af2cd53bd..fa46c7793 100644 --- a/autofit/non_linear/fitness.py +++ b/autofit/non_linear/fitness.py @@ -1,615 +1,615 @@ -import logging -import numpy as np -from IPython.display import clear_output -import os -import time - -from timeout_decorator import timeout -from typing import Optional - -from autonerves import conf -from autonerves import cached_property - -from autofit import exc - -from autofit.text import text_util - - -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.paths.abstract import AbstractPaths -from autofit.non_linear.analysis import Analysis - - - -def get_timeout_seconds(): - - try: - return conf.instance["general"]["test"]["lh_timeout_seconds"] - except KeyError: - pass - -logger = logging.getLogger(__name__) -timeout_seconds = get_timeout_seconds() - -class Fitness: - def __init__( - self, - model : AbstractPriorModel, - analysis : Analysis, - paths : Optional[AbstractPaths] = None, - fom_is_log_likelihood: bool = True, - resample_figure_of_merit: float = None, - convert_to_chi_squared: bool = False, - store_history: bool = False, - use_jax_vmap : bool = False, - use_jax_jit : bool = False, - batch_size : Optional[int] = None, - iterations_per_quick_update: Optional[int] = None, - background_quick_update: bool = False, - live_visual_update: bool = False, - ): - """ - Interfaces with any non-linear search to fit the model to the data and return a log likelihood via - the analysis. - - The interface of a non-linear search and fitness function is summarised as follows: - - 1) The non-linear search samples a new set of model parameters, which are passed to the fitness - function's `__call__` method. - - 2) The list of parameter values are mapped to an instance of the model. - - 3) The instance is passed to the analysis class's log likelihood function, which fits the model to the - data and returns the log likelihood. - - 4) A final figure-of-merit is computed and returned to the non-linear search, which is either the log - likelihood or log posterior (e.g. adding the log prior to the log likelihood). - - Certain searches (commonly nested samplers) require the parameters to be mapped from unit values to physical - values, which is performed internally by the fitness object in step 2. - - Certain searches require the returned figure of merit to be a log posterior (often MCMC methods) whereas - others require it to be a log likelihood (often nested samples which account for priors internally) in step 4. - Which values is returned by the `fom_is_log_likelihood` bool. - - Some searches require a chi-squared value (which they minimized), given by the log likelihood multiplied - by -2.0. This is returned by the fitness if the `convert_to_chi_squared` bool is `True`. - - If a model-fit raises an exception or returns a `np.nan`, a `resample_figure_of_merit` value is returned - instead. The appropriate value depends on the search, but is typically either `None`, `-np.inf` or `1.0e99`. - All values indicate to the non-linear search that the model-fit should be resampled or ignored. - - Many searches do not store the history of the parameters and log likelihood values, often to save - memory on large model-fits. However, this can be useful, for example to plot the results of a model-fit - versus iteration number. If the `store_history` bool is `True`, the parameters and log likelihoods are stored - in the `parameters_history_list` and `figure_of_merit_history_list` attribute of the fitness object. - - Parameters - ---------- - analysis - An object that encapsulates the data and a log likelihood function which fits the model to the data - via the non-linear search. - model - The model that is fitted to the data, which is used by the non-linear search to create instances of - the model that are fitted to the data via the log likelihood function. - paths - The paths of the search, which if the search is being resumed from an old run is used to check that - the likelihood function has not changed from the previous run. - fom_is_log_likelihood - If `True`, the figure of merit returned by the fitness function is the log likelihood. If `False`, the - figure of merit is the log posterior. - resample_figure_of_merit - The figure of merit returned if the model-fit raises an exception or returns a `np.nan`. - convert_to_chi_squared - If `True`, the figure of merit returned is the log likelihood multiplied by -2.0, such that it is a - chi-squared value that is minimized. - store_history - If `True`, the parameters and log likelihood values of every model-fit are stored in lists. - """ - - self.analysis = analysis - self.model = model - self.paths = paths - self.fom_is_log_likelihood = fom_is_log_likelihood - - self.resample_figure_of_merit = resample_figure_of_merit or -self._xp.inf - self.convert_to_chi_squared = convert_to_chi_squared - self.store_history = store_history - - self.parameters_history_list = [] - self.log_likelihood_history_list = [] - - self.use_jax_vmap = use_jax_vmap - self.use_jax_jit = use_jax_jit - - if getattr(self.analysis, "_use_jax", False): - from autofit.jax.pytrees import enable_pytrees, register_model - - enable_pytrees() - register_model(self.model) - - self._call = self.call - - if self.use_jax_vmap: - self._call = self._vmap - elif self.use_jax_jit: - self._call = self._jit - - self.batch_size = batch_size - self.iterations_per_quick_update = iterations_per_quick_update - self.live_visual_update = live_visual_update - self.quick_update_max_lh_parameters = None - self.quick_update_max_lh = -self._xp.inf - self.quick_update_count = 0 - - self._background_quick_update = None - self._live_display = None - - if background_quick_update and self.iterations_per_quick_update is not None: - from autofit.non_linear.quick_update import BackgroundQuickUpdate - - convert_jax = ( - getattr(self.analysis, "_use_jax", False) - and not getattr(self.analysis, "supports_jax_visualization", False) - ) - - self._background_quick_update = BackgroundQuickUpdate( - convert_jax=convert_jax, - live_visual_update=self.live_visual_update, - ) - elif self.live_visual_update and self.iterations_per_quick_update is not None: - # Synchronous quick-update path: BackgroundQuickUpdate is off - # but the user still asked for live visuals. Manage display - # surfaces via a standalone LiveDisplay; the rendering itself - # still runs on the main thread inside `manage_quick_update`. - from autofit.non_linear.quick_update import LiveDisplay - - self._live_display = LiveDisplay(live_visual_update=True) - - if self.paths is not None: - self.check_log_likelihood(fitness=self) - - if ( - self.iterations_per_quick_update is not None - and self._xp.__name__.startswith("jax") - ): - self._warmup_visualization() - - def _warmup_visualization(self): - """Pre-compile the JAX operations used by ``fit_for_visualization``. - - The first call to ``fit_for_visualization`` triggers ~200 small - per-function JAX JIT compilations (one per profile method per - decorator). Running them here moves that cost to search setup - so every quick update during sampling is fast. - """ - logger.info( - "Warming up visualization (one-time JAX compilation)..." - ) - try: - instance = self.model.instance_from_prior_medians() - fit = self.analysis.fit_for_visualization(instance=instance) - _ = fit.model_data - except Exception: - logger.warning( - "Visualization warm-up failed (non-fatal); " - "first quick update may be slow." - ) - else: - logger.info("Visualization warm-up complete.") - - @property - def _xp(self): - return self.analysis._xp - - def call(self, parameters): - """ - A private method that calls the fitness function with the given parameters and additional keyword arguments. - This method is intended for internal use only. - - The NaN/inf guard below protects the **value only, never the gradient**. - - A model whose likelihood is NaN or inf is mapped to `resample_figure_of_merit`, so searches that read only - the figure of merit (nested samplers, MCMC) get the resample sentinel and never select the point. Gradient - consumers get no such protection: under `jax.grad`, reverse-mode differentiates *both* branches of an - `xp.where` and multiplies the unselected one by zero, so if the likelihood's derivative is also non-finite - the guard yields `0 * NaN = NaN` and the returned gradient is NaN even though the value looks handled. - - This bites only when the masked branch's *derivative* is non-finite — not merely its value. `sqrt(x)` at - x < 0 and `cholesky(A)` for non-positive-definite `A` are NaN in both value and derivative, so they trigger - it; `log(x)` at x < 0 is NaN in value but its derivative `1/x` stays finite, so it does not. - - A guard here **cannot** repair this. By the time this method receives `log_likelihood` the non-finite - derivative is already recorded on the autodiff tape, and no transformation of the output can remove it — - an output-side "double-where" does not work. Gradient-safety must be established at the site that creates - the NaN, by never *evaluating* the offending operation at the invalid input. - - See autolens_workspace_developer#104, where this was diagnosed, and - `autofit_workspace_test/scripts/jax_assertions/fitness_nan_gradient_contract.py`, which pins the behaviour - described here. - - Parameters - ---------- - parameters - The parameters (typically a list) chosen by a non-linear search, which are mapped to an instance of the - model via its priors and fitted to the data. - kwargs - Additional key-word arguments that may be necessary for specific non-linear searches. - - Returns - ------- - The figure of merit returned to the non-linear search, which is either the log likelihood or log posterior. - """ - if self._xp.__name__.startswith("jax"): - - # Get instance from model (must be side-effect free and exception-free under JAX) - instance = self.model.instance_from_vector(vector=parameters, xp=self._xp) - - # Evaluate log likelihood (must be side-effect free and exception-free) - log_likelihood = self.analysis.log_likelihood_function(instance=instance) - - else: - - try: - instance = self.model.instance_from_vector(vector=parameters, xp=self._xp) - log_likelihood = self.analysis.log_likelihood_function(instance=instance) - except exc.FitException: - return self.resample_figure_of_merit - - # Penalize NaNs in the log-likelihood. Value-only: under jax.grad these `where`s still differentiate the - # masked branch, so a non-finite derivative propagates as `0 * NaN = NaN`. See the contract in the - # docstring above -- gradient-safety belongs at the site that creates the NaN, not here. - log_likelihood = self._xp.where(self._xp.isnan(log_likelihood), self.resample_figure_of_merit, log_likelihood) - log_likelihood = self._xp.where(self._xp.isinf(log_likelihood), self.resample_figure_of_merit, log_likelihood) - - # Determine final figure of merit - if self.fom_is_log_likelihood: - figure_of_merit = log_likelihood - else: - # Ensure prior list is compatible with JAX (must return a JAX array, not list) - log_prior_array = self._xp.array(self.model.log_prior_list_from_vector(vector=parameters, xp=self._xp)) - figure_of_merit = log_likelihood + self._xp.sum(log_prior_array) - - # Convert to chi-squared scale if requested - if self.convert_to_chi_squared: - figure_of_merit *= -2.0 - - return figure_of_merit - - def call_wrap(self, parameters): - """ - Wrapper around a JAX-jitted likelihood function that optionally stores - the history of evaluated parameters and likelihood values. - - Depending on whether the figure of merit - (FoM) is defined as a log-likelihood (`self.fom_is_log_likelihood`), it - either uses the FoM directly or subtracts the summed log-prior to obtain - the log-likelihood. - - If `self.store_history` is True, both the input parameters and the - corresponding log-likelihood are appended to internal history lists - (`self.parameters_history_list`, `self.log_likelihood_history_list`). - - Parameters - ---------- - parameters - A vector of model parameters to evaluate. - - Returns - ------- - float - The computed figure of merit for the input parameters. This is either - the log-likelihood itself or another objective function value, - depending on configuration. - """ - - if self.use_jax_vmap: - if len(np.array(parameters).shape) == 1: - parameters = np.array(parameters)[None, :] - - figure_of_merit = self._call(parameters) - - if self.use_jax_jit: - figure_of_merit = float(figure_of_merit) - - if self.convert_to_chi_squared: - log_likelihood = -0.5 * figure_of_merit - else: - log_likelihood = figure_of_merit - - if not self.fom_is_log_likelihood: - log_prior_list = np.array(self.model.log_prior_list_from_vector(vector=parameters, xp=np)) - log_likelihood -= np.sum(log_prior_list) - - self.manage_quick_update(parameters=parameters, log_likelihood=log_likelihood) - - if self.store_history: - - self.parameters_history_list.append(np.array(parameters)) - self.log_likelihood_history_list.append(np.array(log_likelihood)) - - return figure_of_merit - - def manage_quick_update(self, parameters, log_likelihood): - """ - Manage quick updates during the non-linear search. - - A "quick update" is a lightweight visualization of the current best-fit - (maximum likelihood) model parameters. This provides fast feedback on the - progress of the fit without waiting for the full analysis to complete. - - It does not require leaving the active non-linear search, and is - therefore faster than the full analysis visualization. - - Workflow: - ---------- - 1. Track the number of likelihood evaluations since the last quick update. - 2. Identify the maximum log-likelihood from the current batch of evaluations. - - If `log_likelihood` is an array (batched evaluations), find the best - index with `argmax`. - - If it’s just a scalar (single evaluation), treat it as one update. - 3. If a new maximum likelihood is found, update: - - `self.quick_update_max_lh` (best log-likelihood value so far). - - `self.quick_update_max_lh_parameters` (corresponding parameter vector). - 4. Once the number of evaluations exceeds - `self.iterations_per_quick_update`, generate a quick visualization of - the current max-likelihood model via - `self.analysis.perform_quick_update()`. - - Parameters - ---------- - parameters : array-like - The parameter vectors evaluated in this batch. Shape is typically - (n_batch, n_param). - log_likelihood : float or array-like - The corresponding log-likelihood(s). If batched, must have shape - (n_batch,). - - Notes - ----- - - Quick updates are optional and controlled by - `self.iterations_per_quick_update`. - - If the `analysis` class does not implement - `perform_quick_update`, the update is silently skipped. - - This mechanism is intended for fast, coarse visualization only, - not detailed science-quality outputs. - """ - - if self.iterations_per_quick_update is None: - return - - try: - - best_idx = self._xp.argmax(log_likelihood) - best_log_likelihood = log_likelihood[best_idx] - best_parameters = parameters[best_idx] - total_updates = log_likelihood.shape[0] - - except (AttributeError, IndexError, TypeError): - - best_log_likelihood = log_likelihood - best_parameters = parameters - total_updates = 1 - - if best_log_likelihood > self.quick_update_max_lh: - self.quick_update_max_lh = best_log_likelihood - self.quick_update_max_lh_parameters = best_parameters - - self.quick_update_count += total_updates - - if self.quick_update_count >= self.iterations_per_quick_update: - - clear_output(wait=True) - - start_time = time.time() - - logger.info("Performing quick update of maximum log likelihood fit image and model.results") - - instance = self.model.instance_from_vector(vector=self.quick_update_max_lh_parameters, xp=self._xp) - - if self._background_quick_update is not None: - self._background_quick_update.submit( - self.analysis, self.paths, instance, - ) - else: - try: - self.analysis.perform_quick_update(self.paths, instance) - except NotImplementedError: - pass - else: - if self._live_display is not None: - try: - self._live_display.update(self.paths) - except Exception: - logger.exception( - "Live display update raised an exception (ignored)." - ) - - result_info = text_util.result_max_lh_info_from( - max_log_likelihood_sample=self.quick_update_max_lh_parameters.tolist(), - max_log_likelihood=self.quick_update_max_lh, - model=self.model, - ) - result_info = "\n".join(result_info) - - logger.info(result_info) - self.paths.output_model_results(result_info=result_info) - - self.quick_update_count = 0 - - logger.info(f"Quick update complete in {time.time() - start_time} seconds.") - - def shutdown_quick_update(self): - """Shut down the background quick-update worker and any live - display surfaces (matplotlib viewer subprocess) that were spawned - for this fitness instance.""" - if self._background_quick_update is not None: - self._background_quick_update.shutdown() - self._background_quick_update = None - if self._live_display is not None: - self._live_display.shutdown() - self._live_display = None - - @timeout(timeout_seconds) - def __call__(self, parameters, *kwargs): - """ - Interfaces with any non-linear in order to fit a model to the data and return a log likelihood via - an `Analysis` class. - - The interface is described in full in the `__init__` docstring above. - - Parameters - ---------- - parameters - The parameters (typically a list) chosen by a non-linear search, which are mapped to an instance of the - model via its priors and fitted to the data. - kwargs - Addition key-word arguments that may be necessary for specific non-linear searches. - - Returns - ------- - The figure of merit returned to the non-linear search, which is either the log likelihood or log posterior. - """ - return self.call_wrap(parameters) - - def __getstate__(self): - state = self.__dict__.copy() - # Strip JAX-compiled callables: jax.jit / jax.vmap / jax.grad return - # functions tied to C++ XLA state that can't roundtrip through pickle. - # cached_property values lazily recompile on first access after unpickle. - for attr in ("_call", "_jit", "_vmap", "_grad"): - state.pop(attr, None) - return state - - def __setstate__(self, state): - self.__dict__.update(state) - self._call = self.call - if getattr(self, "use_jax_vmap", False): - self._call = self._vmap - elif getattr(self, "use_jax_jit", False): - self._call = self._jit - - @cached_property - def _vmap(self): - """ - Vectorized and JIT-compiled likelihood function. - - This wraps the base likelihood function (`self.call`) with both - `jax.jit` and `jax.vmap`, producing a function that can evaluate - batches of parameter vectors efficiently in parallel. The first - call incurs compilation time, but subsequent calls are highly - optimized. - - Because this is a `cached_property`, the compiled function is stored - after its first creation, avoiding repeated JIT compilation overhead. - """ - import jax - start = time.time() - logger.info("JAX: Applying vmap and jit to likelihood function -- may take a few seconds.") - func = jax.vmap(jax.jit(self.call)) - logger.info(f"JAX: vmap and jit applied in {time.time() - start} seconds.") - return func - - @cached_property - def _jit(self): - """ - JIT-compiled likelihood function. - - This wraps the base likelihood function (`self.call`) with `jax.jit`, - producing a compiled version optimized for repeated evaluation on a - single set of parameters. The first call triggers compilation, while - later calls benefit from the compiled execution. - - As a `cached_property`, the compiled function is cached after its - first use, so JIT compilation only occurs once. - """ - import jax - start = time.time() - logger.info("JAX: Applying jit to likelihood function -- may take a few seconds.") - func = jax.jit(self.call) - logger.info(f"JAX: jit applied in {time.time() - start} seconds.") - return func - - @cached_property - def _grad(self): - """ - Gradient of the JIT-compiled likelihood function. - - This wraps the JIT-compiled likelihood function (`self._call`) with - `jax.grad`, returning a function that computes gradients of the - likelihood with respect to its input parameters. Useful for gradient- - based optimization and inference methods. - - Since this is a `cached_property`, the gradient function is compiled - and cached on first access, ensuring that expensive setup is done - only once. - """ - import jax - start = time.time() - logger.info("JAX: Applying grad to likelihood function -- may take a few seconds.") - func = jax.grad(self.call) - logger.info(f"JAX: grad applied in {time.time() - start} seconds.") - return func - - def grad(self, *args, **kwargs): - return self._grad(*args, **kwargs) - - def check_log_likelihood(self, fitness): - """ - Changes to the PyAutoGalaxy source code may inadvertantly change the numerics of how a log likelihood is - computed. Equally, one may set off a model-fit that resumes from previous results, but change the settings of - the pixelization or inversion in a way that changes the log likelihood function. - - This function performs an optional sanity check, which raises an exception if the log likelihood calculation - changes, to ensure a model-fit is not resumed with a different likelihood calculation to the previous run. - - If the model-fit has not been performed before (e.g. it is not a resume) this function outputs - the `figure_of_merit` (e.g. the log likelihood) of the maximum log likelihood model at the end of the model-fit. - - If the model-fit is a resume, it loads this `figure_of_merit` and compares it against a new value computed for - the resumed run (again using the maximum log likelihood model inferred). If the two likelihoods do not agree - and therefore the log likelihood function has changed, an exception is raised and the code execution terminated. - - Parameters - ---------- - paths - certain searches the non-linear search outputs are stored, - visualization, and pickled objects used by the database and aggregator. - result - The result containing the maximum log likelihood fit of the model. - """ - import numpy as np - - from autofit.non_linear.test_mode import skip_fit_output - if skip_fit_output(): - return - - if not conf.instance["general"]["test"]["check_likelihood_function"]: - return - - try: - samples_summary = self.paths.load_samples_summary() - except FileNotFoundError: - return - - try: - max_log_likelihood_sample = samples_summary.max_log_likelihood_sample - except AttributeError: - return - log_likelihood_old = samples_summary.max_log_likelihood_sample.log_likelihood - - parameters = max_log_likelihood_sample.parameter_lists_for_model(model=self.model) - - log_likelihood_new = fitness(parameters=parameters) - - if not np.isclose(log_likelihood_old, log_likelihood_new): - raise exc.SearchException( - f""" - Figure of merit sanity check failed. - - This means that the existing results of a model fit used a different - likelihood function compared to the one implemented now. - Old Figure of Merit = {log_likelihood_old} - New Figure of Merit = {log_likelihood_new} - """ +import logging +import numpy as np +from IPython.display import clear_output +import os +import time + +from timeout_decorator import timeout +from typing import Optional + +from autonerves import conf +from autonerves import cached_property + +from autofit import exc + +from autofit.text import text_util + + +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.paths.abstract import AbstractPaths +from autofit.non_linear.analysis import Analysis + + + +def get_timeout_seconds(): + + try: + return conf.instance["general"]["test"]["lh_timeout_seconds"] + except KeyError: + pass + +logger = logging.getLogger(__name__) +timeout_seconds = get_timeout_seconds() + +class Fitness: + def __init__( + self, + model : AbstractPriorModel, + analysis : Analysis, + paths : Optional[AbstractPaths] = None, + fom_is_log_likelihood: bool = True, + resample_figure_of_merit: float = None, + convert_to_chi_squared: bool = False, + store_history: bool = False, + use_jax_vmap : bool = False, + use_jax_jit : bool = False, + batch_size : Optional[int] = None, + iterations_per_quick_update: Optional[int] = None, + background_quick_update: bool = False, + live_visual_update: bool = False, + ): + """ + Interfaces with any non-linear search to fit the model to the data and return a log likelihood via + the analysis. + + The interface of a non-linear search and fitness function is summarised as follows: + + 1) The non-linear search samples a new set of model parameters, which are passed to the fitness + function's `__call__` method. + + 2) The list of parameter values are mapped to an instance of the model. + + 3) The instance is passed to the analysis class's log likelihood function, which fits the model to the + data and returns the log likelihood. + + 4) A final figure-of-merit is computed and returned to the non-linear search, which is either the log + likelihood or log posterior (e.g. adding the log prior to the log likelihood). + + Certain searches (commonly nested samplers) require the parameters to be mapped from unit values to physical + values, which is performed internally by the fitness object in step 2. + + Certain searches require the returned figure of merit to be a log posterior (often MCMC methods) whereas + others require it to be a log likelihood (often nested samples which account for priors internally) in step 4. + Which values is returned by the `fom_is_log_likelihood` bool. + + Some searches require a chi-squared value (which they minimized), given by the log likelihood multiplied + by -2.0. This is returned by the fitness if the `convert_to_chi_squared` bool is `True`. + + If a model-fit raises an exception or returns a `np.nan`, a `resample_figure_of_merit` value is returned + instead. The appropriate value depends on the search, but is typically either `None`, `-np.inf` or `1.0e99`. + All values indicate to the non-linear search that the model-fit should be resampled or ignored. + + Many searches do not store the history of the parameters and log likelihood values, often to save + memory on large model-fits. However, this can be useful, for example to plot the results of a model-fit + versus iteration number. If the `store_history` bool is `True`, the parameters and log likelihoods are stored + in the `parameters_history_list` and `figure_of_merit_history_list` attribute of the fitness object. + + Parameters + ---------- + analysis + An object that encapsulates the data and a log likelihood function which fits the model to the data + via the non-linear search. + model + The model that is fitted to the data, which is used by the non-linear search to create instances of + the model that are fitted to the data via the log likelihood function. + paths + The paths of the search, which if the search is being resumed from an old run is used to check that + the likelihood function has not changed from the previous run. + fom_is_log_likelihood + If `True`, the figure of merit returned by the fitness function is the log likelihood. If `False`, the + figure of merit is the log posterior. + resample_figure_of_merit + The figure of merit returned if the model-fit raises an exception or returns a `np.nan`. + convert_to_chi_squared + If `True`, the figure of merit returned is the log likelihood multiplied by -2.0, such that it is a + chi-squared value that is minimized. + store_history + If `True`, the parameters and log likelihood values of every model-fit are stored in lists. + """ + + self.analysis = analysis + self.model = model + self.paths = paths + self.fom_is_log_likelihood = fom_is_log_likelihood + + self.resample_figure_of_merit = resample_figure_of_merit or -self._xp.inf + self.convert_to_chi_squared = convert_to_chi_squared + self.store_history = store_history + + self.parameters_history_list = [] + self.log_likelihood_history_list = [] + + self.use_jax_vmap = use_jax_vmap + self.use_jax_jit = use_jax_jit + + if getattr(self.analysis, "_use_jax", False): + from autofit.jax.pytrees import enable_pytrees, register_model + + enable_pytrees() + register_model(self.model) + + self._call = self.call + + if self.use_jax_vmap: + self._call = self._vmap + elif self.use_jax_jit: + self._call = self._jit + + self.batch_size = batch_size + self.iterations_per_quick_update = iterations_per_quick_update + self.live_visual_update = live_visual_update + self.quick_update_max_lh_parameters = None + self.quick_update_max_lh = -self._xp.inf + self.quick_update_count = 0 + + self._background_quick_update = None + self._live_display = None + + if background_quick_update and self.iterations_per_quick_update is not None: + from autofit.non_linear.quick_update import BackgroundQuickUpdate + + convert_jax = ( + getattr(self.analysis, "_use_jax", False) + and not getattr(self.analysis, "supports_jax_visualization", False) + ) + + self._background_quick_update = BackgroundQuickUpdate( + convert_jax=convert_jax, + live_visual_update=self.live_visual_update, + ) + elif self.live_visual_update and self.iterations_per_quick_update is not None: + # Synchronous quick-update path: BackgroundQuickUpdate is off + # but the user still asked for live visuals. Manage display + # surfaces via a standalone LiveDisplay; the rendering itself + # still runs on the main thread inside `manage_quick_update`. + from autofit.non_linear.quick_update import LiveDisplay + + self._live_display = LiveDisplay(live_visual_update=True) + + if self.paths is not None: + self.check_log_likelihood(fitness=self) + + if ( + self.iterations_per_quick_update is not None + and self._xp.__name__.startswith("jax") + ): + self._warmup_visualization() + + def _warmup_visualization(self): + """Pre-compile the JAX operations used by ``fit_for_visualization``. + + The first call to ``fit_for_visualization`` triggers ~200 small + per-function JAX JIT compilations (one per profile method per + decorator). Running them here moves that cost to search setup + so every quick update during sampling is fast. + """ + logger.info( + "Warming up visualization (one-time JAX compilation)..." + ) + try: + instance = self.model.instance_from_prior_medians() + fit = self.analysis.fit_for_visualization(instance=instance) + _ = fit.model_data + except Exception: + logger.warning( + "Visualization warm-up failed (non-fatal); " + "first quick update may be slow." + ) + else: + logger.info("Visualization warm-up complete.") + + @property + def _xp(self): + return self.analysis._xp + + def call(self, parameters): + """ + A private method that calls the fitness function with the given parameters and additional keyword arguments. + This method is intended for internal use only. + + The NaN/inf guard below protects the **value only, never the gradient**. + + A model whose likelihood is NaN or inf is mapped to `resample_figure_of_merit`, so searches that read only + the figure of merit (nested samplers, MCMC) get the resample sentinel and never select the point. Gradient + consumers get no such protection: under `jax.grad`, reverse-mode differentiates *both* branches of an + `xp.where` and multiplies the unselected one by zero, so if the likelihood's derivative is also non-finite + the guard yields `0 * NaN = NaN` and the returned gradient is NaN even though the value looks handled. + + This bites only when the masked branch's *derivative* is non-finite — not merely its value. `sqrt(x)` at + x < 0 and `cholesky(A)` for non-positive-definite `A` are NaN in both value and derivative, so they trigger + it; `log(x)` at x < 0 is NaN in value but its derivative `1/x` stays finite, so it does not. + + A guard here **cannot** repair this. By the time this method receives `log_likelihood` the non-finite + derivative is already recorded on the autodiff tape, and no transformation of the output can remove it — + an output-side "double-where" does not work. Gradient-safety must be established at the site that creates + the NaN, by never *evaluating* the offending operation at the invalid input. + + See autolens_workspace_developer#104, where this was diagnosed, and + `autofit_workspace_test/scripts/jax_assertions/fitness_nan_gradient_contract.py`, which pins the behaviour + described here. + + Parameters + ---------- + parameters + The parameters (typically a list) chosen by a non-linear search, which are mapped to an instance of the + model via its priors and fitted to the data. + kwargs + Additional key-word arguments that may be necessary for specific non-linear searches. + + Returns + ------- + The figure of merit returned to the non-linear search, which is either the log likelihood or log posterior. + """ + if self._xp.__name__.startswith("jax"): + + # Get instance from model (must be side-effect free and exception-free under JAX) + instance = self.model.instance_from_vector(vector=parameters, xp=self._xp) + + # Evaluate log likelihood (must be side-effect free and exception-free) + log_likelihood = self.analysis.log_likelihood_function(instance=instance) + + else: + + try: + instance = self.model.instance_from_vector(vector=parameters, xp=self._xp) + log_likelihood = self.analysis.log_likelihood_function(instance=instance) + except exc.FitException: + return self.resample_figure_of_merit + + # Penalize NaNs in the log-likelihood. Value-only: under jax.grad these `where`s still differentiate the + # masked branch, so a non-finite derivative propagates as `0 * NaN = NaN`. See the contract in the + # docstring above -- gradient-safety belongs at the site that creates the NaN, not here. + log_likelihood = self._xp.where(self._xp.isnan(log_likelihood), self.resample_figure_of_merit, log_likelihood) + log_likelihood = self._xp.where(self._xp.isinf(log_likelihood), self.resample_figure_of_merit, log_likelihood) + + # Determine final figure of merit + if self.fom_is_log_likelihood: + figure_of_merit = log_likelihood + else: + # Ensure prior list is compatible with JAX (must return a JAX array, not list) + log_prior_array = self._xp.array(self.model.log_prior_list_from_vector(vector=parameters, xp=self._xp)) + figure_of_merit = log_likelihood + self._xp.sum(log_prior_array) + + # Convert to chi-squared scale if requested + if self.convert_to_chi_squared: + figure_of_merit *= -2.0 + + return figure_of_merit + + def call_wrap(self, parameters): + """ + Wrapper around a JAX-jitted likelihood function that optionally stores + the history of evaluated parameters and likelihood values. + + Depending on whether the figure of merit + (FoM) is defined as a log-likelihood (`self.fom_is_log_likelihood`), it + either uses the FoM directly or subtracts the summed log-prior to obtain + the log-likelihood. + + If `self.store_history` is True, both the input parameters and the + corresponding log-likelihood are appended to internal history lists + (`self.parameters_history_list`, `self.log_likelihood_history_list`). + + Parameters + ---------- + parameters + A vector of model parameters to evaluate. + + Returns + ------- + float + The computed figure of merit for the input parameters. This is either + the log-likelihood itself or another objective function value, + depending on configuration. + """ + + if self.use_jax_vmap: + if len(np.array(parameters).shape) == 1: + parameters = np.array(parameters)[None, :] + + figure_of_merit = self._call(parameters) + + if self.use_jax_jit: + figure_of_merit = float(figure_of_merit) + + if self.convert_to_chi_squared: + log_likelihood = -0.5 * figure_of_merit + else: + log_likelihood = figure_of_merit + + if not self.fom_is_log_likelihood: + log_prior_list = np.array(self.model.log_prior_list_from_vector(vector=parameters, xp=np)) + log_likelihood -= np.sum(log_prior_list) + + self.manage_quick_update(parameters=parameters, log_likelihood=log_likelihood) + + if self.store_history: + + self.parameters_history_list.append(np.array(parameters)) + self.log_likelihood_history_list.append(np.array(log_likelihood)) + + return figure_of_merit + + def manage_quick_update(self, parameters, log_likelihood): + """ + Manage quick updates during the non-linear search. + + A "quick update" is a lightweight visualization of the current best-fit + (maximum likelihood) model parameters. This provides fast feedback on the + progress of the fit without waiting for the full analysis to complete. + + It does not require leaving the active non-linear search, and is + therefore faster than the full analysis visualization. + + Workflow: + ---------- + 1. Track the number of likelihood evaluations since the last quick update. + 2. Identify the maximum log-likelihood from the current batch of evaluations. + - If `log_likelihood` is an array (batched evaluations), find the best + index with `argmax`. + - If it’s just a scalar (single evaluation), treat it as one update. + 3. If a new maximum likelihood is found, update: + - `self.quick_update_max_lh` (best log-likelihood value so far). + - `self.quick_update_max_lh_parameters` (corresponding parameter vector). + 4. Once the number of evaluations exceeds + `self.iterations_per_quick_update`, generate a quick visualization of + the current max-likelihood model via + `self.analysis.perform_quick_update()`. + + Parameters + ---------- + parameters : array-like + The parameter vectors evaluated in this batch. Shape is typically + (n_batch, n_param). + log_likelihood : float or array-like + The corresponding log-likelihood(s). If batched, must have shape + (n_batch,). + + Notes + ----- + - Quick updates are optional and controlled by + `self.iterations_per_quick_update`. + - If the `analysis` class does not implement + `perform_quick_update`, the update is silently skipped. + - This mechanism is intended for fast, coarse visualization only, + not detailed science-quality outputs. + """ + + if self.iterations_per_quick_update is None: + return + + try: + + best_idx = self._xp.argmax(log_likelihood) + best_log_likelihood = log_likelihood[best_idx] + best_parameters = parameters[best_idx] + total_updates = log_likelihood.shape[0] + + except (AttributeError, IndexError, TypeError): + + best_log_likelihood = log_likelihood + best_parameters = parameters + total_updates = 1 + + if best_log_likelihood > self.quick_update_max_lh: + self.quick_update_max_lh = best_log_likelihood + self.quick_update_max_lh_parameters = best_parameters + + self.quick_update_count += total_updates + + if self.quick_update_count >= self.iterations_per_quick_update: + + clear_output(wait=True) + + start_time = time.time() + + logger.info("Performing quick update of maximum log likelihood fit image and model.results") + + instance = self.model.instance_from_vector(vector=self.quick_update_max_lh_parameters, xp=self._xp) + + if self._background_quick_update is not None: + self._background_quick_update.submit( + self.analysis, self.paths, instance, + ) + else: + try: + self.analysis.perform_quick_update(self.paths, instance) + except NotImplementedError: + pass + else: + if self._live_display is not None: + try: + self._live_display.update(self.paths) + except Exception: + logger.exception( + "Live display update raised an exception (ignored)." + ) + + result_info = text_util.result_max_lh_info_from( + max_log_likelihood_sample=self.quick_update_max_lh_parameters.tolist(), + max_log_likelihood=self.quick_update_max_lh, + model=self.model, + ) + result_info = "\n".join(result_info) + + logger.info(result_info) + self.paths.output_model_results(result_info=result_info) + + self.quick_update_count = 0 + + logger.info(f"Quick update complete in {time.time() - start_time} seconds.") + + def shutdown_quick_update(self): + """Shut down the background quick-update worker and any live + display surfaces (matplotlib viewer subprocess) that were spawned + for this fitness instance.""" + if self._background_quick_update is not None: + self._background_quick_update.shutdown() + self._background_quick_update = None + if self._live_display is not None: + self._live_display.shutdown() + self._live_display = None + + @timeout(timeout_seconds) + def __call__(self, parameters, *kwargs): + """ + Interfaces with any non-linear in order to fit a model to the data and return a log likelihood via + an `Analysis` class. + + The interface is described in full in the `__init__` docstring above. + + Parameters + ---------- + parameters + The parameters (typically a list) chosen by a non-linear search, which are mapped to an instance of the + model via its priors and fitted to the data. + kwargs + Addition key-word arguments that may be necessary for specific non-linear searches. + + Returns + ------- + The figure of merit returned to the non-linear search, which is either the log likelihood or log posterior. + """ + return self.call_wrap(parameters) + + def __getstate__(self): + state = self.__dict__.copy() + # Strip JAX-compiled callables: jax.jit / jax.vmap / jax.grad return + # functions tied to C++ XLA state that can't roundtrip through pickle. + # cached_property values lazily recompile on first access after unpickle. + for attr in ("_call", "_jit", "_vmap", "_grad"): + state.pop(attr, None) + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self._call = self.call + if getattr(self, "use_jax_vmap", False): + self._call = self._vmap + elif getattr(self, "use_jax_jit", False): + self._call = self._jit + + @cached_property + def _vmap(self): + """ + Vectorized and JIT-compiled likelihood function. + + This wraps the base likelihood function (`self.call`) with both + `jax.jit` and `jax.vmap`, producing a function that can evaluate + batches of parameter vectors efficiently in parallel. The first + call incurs compilation time, but subsequent calls are highly + optimized. + + Because this is a `cached_property`, the compiled function is stored + after its first creation, avoiding repeated JIT compilation overhead. + """ + import jax + start = time.time() + logger.info("JAX: Applying vmap and jit to likelihood function -- may take a few seconds.") + func = jax.vmap(jax.jit(self.call)) + logger.info(f"JAX: vmap and jit applied in {time.time() - start} seconds.") + return func + + @cached_property + def _jit(self): + """ + JIT-compiled likelihood function. + + This wraps the base likelihood function (`self.call`) with `jax.jit`, + producing a compiled version optimized for repeated evaluation on a + single set of parameters. The first call triggers compilation, while + later calls benefit from the compiled execution. + + As a `cached_property`, the compiled function is cached after its + first use, so JIT compilation only occurs once. + """ + import jax + start = time.time() + logger.info("JAX: Applying jit to likelihood function -- may take a few seconds.") + func = jax.jit(self.call) + logger.info(f"JAX: jit applied in {time.time() - start} seconds.") + return func + + @cached_property + def _grad(self): + """ + Gradient of the JIT-compiled likelihood function. + + This wraps the JIT-compiled likelihood function (`self._call`) with + `jax.grad`, returning a function that computes gradients of the + likelihood with respect to its input parameters. Useful for gradient- + based optimization and inference methods. + + Since this is a `cached_property`, the gradient function is compiled + and cached on first access, ensuring that expensive setup is done + only once. + """ + import jax + start = time.time() + logger.info("JAX: Applying grad to likelihood function -- may take a few seconds.") + func = jax.grad(self.call) + logger.info(f"JAX: grad applied in {time.time() - start} seconds.") + return func + + def grad(self, *args, **kwargs): + return self._grad(*args, **kwargs) + + def check_log_likelihood(self, fitness): + """ + Changes to the PyAutoGalaxy source code may inadvertantly change the numerics of how a log likelihood is + computed. Equally, one may set off a model-fit that resumes from previous results, but change the settings of + the pixelization or inversion in a way that changes the log likelihood function. + + This function performs an optional sanity check, which raises an exception if the log likelihood calculation + changes, to ensure a model-fit is not resumed with a different likelihood calculation to the previous run. + + If the model-fit has not been performed before (e.g. it is not a resume) this function outputs + the `figure_of_merit` (e.g. the log likelihood) of the maximum log likelihood model at the end of the model-fit. + + If the model-fit is a resume, it loads this `figure_of_merit` and compares it against a new value computed for + the resumed run (again using the maximum log likelihood model inferred). If the two likelihoods do not agree + and therefore the log likelihood function has changed, an exception is raised and the code execution terminated. + + Parameters + ---------- + paths + certain searches the non-linear search outputs are stored, + visualization, and pickled objects used by the database and aggregator. + result + The result containing the maximum log likelihood fit of the model. + """ + import numpy as np + + from autofit.non_linear.test_mode import skip_fit_output + if skip_fit_output(): + return + + if not conf.instance["general"]["test"]["check_likelihood_function"]: + return + + try: + samples_summary = self.paths.load_samples_summary() + except FileNotFoundError: + return + + try: + max_log_likelihood_sample = samples_summary.max_log_likelihood_sample + except AttributeError: + return + log_likelihood_old = samples_summary.max_log_likelihood_sample.log_likelihood + + parameters = max_log_likelihood_sample.parameter_lists_for_model(model=self.model) + + log_likelihood_new = fitness(parameters=parameters) + + if not np.isclose(log_likelihood_old, log_likelihood_new): + raise exc.SearchException( + f""" + Figure of merit sanity check failed. + + This means that the existing results of a model fit used a different + likelihood function compared to the one implemented now. + Old Figure of Merit = {log_likelihood_old} + New Figure of Merit = {log_likelihood_new} + """ ) \ No newline at end of file diff --git a/autofit/non_linear/grid/grid_list.py b/autofit/non_linear/grid/grid_list.py index e836383fa..72dcf7311 100644 --- a/autofit/non_linear/grid/grid_list.py +++ b/autofit/non_linear/grid/grid_list.py @@ -1,75 +1,75 @@ -from functools import wraps -from typing import List, Tuple - -import numpy as np - - -def as_grid_list(func): - """ - Wrap functions with a function which converts the output list of grid search results to a `GridList` object. - - Parameters - ---------- - func - A function which computes and retrusn a list of grid search results. - - Returns - ------- - A function which converts a list of grid search results to a `GridList` object. - """ - - @wraps(func) - def wrapper(grid_search_result, *args, **kwargs) -> List: - """ - This decorator converts the output of a function which computes a list of grid search results to a `GridList`. - - Parameters - ---------- - grid_search_result - The instance of the `GridSearchResult` which is being operated on. - - Returns - ------- - The function output converted to a `GridList`. - """ - - values = func(grid_search_result, *args, **kwargs) - - return GridList(values=values, shape=grid_search_result.shape) - - return wrapper - - -class GridList(list): - def __init__(self, values: List, shape: Tuple): - """ - Many quantities of a `GridSearchResult` are stored as lists of lists. - - The number of lists corresponds to the dimensionality of the grid search and the number of elements - in each list corresponds to the number of steps in that grid search dimension. - - This class provides a wrapper around lists of lists to provide some convenience methods for accessing - the values in the lists. For example, it provides a conversion of the list of list structure to a ndarray. - - For example, for a 2x2 grid search the shape of the Numpy array is (2,2) and it is numerically ordered such - that the first search's entries(corresponding to unit priors (0.0, 0.0)) are in the first - value (E.g. entry [0, 0]) of the NumPy array. - - Parameters - ---------- - values - """ - super().__init__(values) - - self.shape = shape - - @property - def as_list(self) -> List: - return self - - @property - def native(self) -> np.ndarray: - """ - The list of lists as an ndarray. - """ - return np.reshape(np.array(self), self.shape) +from functools import wraps +from typing import List, Tuple + +import numpy as np + + +def as_grid_list(func): + """ + Wrap functions with a function which converts the output list of grid search results to a `GridList` object. + + Parameters + ---------- + func + A function which computes and retrusn a list of grid search results. + + Returns + ------- + A function which converts a list of grid search results to a `GridList` object. + """ + + @wraps(func) + def wrapper(grid_search_result, *args, **kwargs) -> List: + """ + This decorator converts the output of a function which computes a list of grid search results to a `GridList`. + + Parameters + ---------- + grid_search_result + The instance of the `GridSearchResult` which is being operated on. + + Returns + ------- + The function output converted to a `GridList`. + """ + + values = func(grid_search_result, *args, **kwargs) + + return GridList(values=values, shape=grid_search_result.shape) + + return wrapper + + +class GridList(list): + def __init__(self, values: List, shape: Tuple): + """ + Many quantities of a `GridSearchResult` are stored as lists of lists. + + The number of lists corresponds to the dimensionality of the grid search and the number of elements + in each list corresponds to the number of steps in that grid search dimension. + + This class provides a wrapper around lists of lists to provide some convenience methods for accessing + the values in the lists. For example, it provides a conversion of the list of list structure to a ndarray. + + For example, for a 2x2 grid search the shape of the Numpy array is (2,2) and it is numerically ordered such + that the first search's entries(corresponding to unit priors (0.0, 0.0)) are in the first + value (E.g. entry [0, 0]) of the NumPy array. + + Parameters + ---------- + values + """ + super().__init__(values) + + self.shape = shape + + @property + def as_list(self) -> List: + return self + + @property + def native(self) -> np.ndarray: + """ + The list of lists as an ndarray. + """ + return np.reshape(np.array(self), self.shape) diff --git a/autofit/non_linear/grid/sensitivity/__init__.py b/autofit/non_linear/grid/sensitivity/__init__.py index e2509525b..0eb8207e3 100644 --- a/autofit/non_linear/grid/sensitivity/__init__.py +++ b/autofit/non_linear/grid/sensitivity/__init__.py @@ -1,424 +1,424 @@ -import logging -import os -from copy import copy -import numpy as np -from pathlib import Path -from typing import List, Generator, Callable, ClassVar, Optional, Union, Tuple - -from autonerves import cached_property -from autonerves.dictable import to_dict -from autofit.mapper.model import ModelInstance -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.grid.grid_search import make_lists, Sequential -from autofit.non_linear.grid.sensitivity.job import Job, MaskedJobResult -from autofit.non_linear.grid.sensitivity.job import JobResult -from autofit.non_linear.grid.sensitivity.result import SensitivityResult -from autofit.non_linear.parallel import Process -from autofit.text.formatter import write_table - - -class Sensitivity: - def __init__( - self, - base_model: AbstractPriorModel, - perturb_model: AbstractPriorModel, - simulation_instance, - paths, - simulate_cls: Callable, - base_fit_cls: Callable, - perturb_fit_cls: Callable, - job_cls: ClassVar = Job, - batch_range : Tuple[int, int] = None, - visualizer_cls: Optional[Callable] = None, - perturb_model_prior_func: Optional[Callable] = None, - number_of_steps: Union[Tuple[int, ...], int] = 4, - mask: Optional[List[bool]] = None, - number_of_cores: int = 2, - limit_scale: int = 1, - ): - """ - Perform sensitivity mapping to evaluate whether a perturbation - can be detected if it occurs in different parts of an image. - - For a range from 0 to 1 with step_size, for each dimension of the - perturb_model, a perturbation is created and used in conjunction - with the instance to create an image. - - For each of these images, a fit is run with just the model and with both - the model and perturb_model to compare how much better the image - can be fit if the perturbation is included. - - Parameters - ---------- - base_model - A model that fits the instance well - perturb_model - A model which provides a perturbations to be applied to the instance - before creating images - simulation_instance - An instance of a model to which perturbations are applied prior to - images being generated - simulate_cls - A class which simulates images from each perturb instance that sensitivity mapping is performed on. - base_fit_cls - The class which fits the base model to each simulated dataset of the sensitivity map. - perturb_fit_cls - The class which fits the perturb model to each simulated dataset of the sensitivity map. - batch_range - The integer range of sensitivity mapping jobs to perform. If None, all jobs are performed. If not None, - only the jobs with indices within this range are performed. This means, for example, the range can be - used to distribute jobs to different machines. - visualizer_cls - A class which can be used to visualize the results of the sensitivity mapping after each fit is performed, - therefore providing visualization on the fly. - number_of_steps - The number of steps for each dimension of the sensitivity grid. If input as a float the dimensions are - all that value. If input as a tuple of length the number of dimensions, each tuple value is the number of - steps in that dimension. - mask - A mask to apply to the sensitivity grid, such that all `True` values are not included in the sensitivity - mapping. This is useful for removing regions of the sensitivity grid that are expected to have no - sensitivity, for example because they have no signal. - number_of_cores - How many cores does this computer have? - limit_scale - Scales the priors for each perturbation model. - A scale of 1 means priors have limits the same size as the grid square. - A scale of 2 means priors have limits larger than the grid square with - width twice a grid square. - A scale of 0.5 means priors have limits smaller than the grid square - with width half a grid square. - """ - self.logger = logging.getLogger(f"Sensitivity ({paths.name})") - - self.logger.info("Creating") - - self.instance = simulation_instance - self.model = base_model - self.perturb_model = perturb_model - - self.paths = paths - - self.simulate_cls = simulate_cls - self.base_fit_cls = base_fit_cls - self.perturb_fit_cls = perturb_fit_cls - - self.perturb_model_prior_func = perturb_model_prior_func - - self.job_cls = job_cls - self.batch_range = batch_range - self.visualizer_cls = visualizer_cls - - self.number_of_steps = number_of_steps - self.mask = None - - if mask is not None: - self.mask = np.asarray(mask) - if self.shape != self.mask.shape: - raise ValueError( - f""" - The mask of the Sensitivity object must have the same shape as the sensitivity grid. - - For your inputs, the shape of each are as follows: - - Sensitivity Grid: {self.shape} - Mask: {self.mask.shape} - """ - ) - - self.number_of_cores = number_of_cores - - self.limit_scale = limit_scale - - def run(self) -> SensitivityResult: - """ - Run fits and comparisons for all perturbations, returning - a list of results. - """ - self.logger.info("Running") - - self.paths.save_unique_tag(is_grid_search=True) - - headers = [ - "index", - *self._headers, - "log_evidence_increase", - "log_likelihood_increase", - ] - physical_values = list(self._physical_values) - - process_class = Process if self.number_of_cores > 1 else Sequential - - results = [] - jobs = [] - - for number in range(len(self._perturb_instances)): - model = self.model.copy() - model.perturb = self._perturb_models[number] - results.append( - MaskedJobResult( - number=number, - model=model, - ) - ) - - if not self._should_bypass(number=number): - jobs.append(self._make_job(number)) - - if self.batch_range is not None: - jobs = jobs[self.batch_range[0]:self.batch_range[1]] - - for result in process_class.run_jobs( - jobs, number_of_cores=self.number_of_cores - ): - if isinstance(result, Exception): - raise result - - results[result.number] = result - - sensitivity_result = SensitivityResult( - samples=[result.result.samples_summary for result in results], - perturb_samples=[ - result.perturb_result.samples_summary for result in results - ], - shape=self.shape, - path_values=self.path_values, - ) - - if self.visualizer_cls is not None: - self.visualizer_cls( - sensitivity_result=sensitivity_result, paths=self.paths - ) - - os.makedirs(self.paths.output_path, exist_ok=True) - - write_table( - headers=headers, - rows=[ - [ - result.number, - *physical_values[result.number], - result.log_evidence_increase, - result.log_likelihood_increase, - ] - for result in results - ], - filename=self.results_path, - ) - - # TODO : Had to repeat this code block to get certain unit tests to pass which presumably bypass run_jobs. - - sensitivity_result = SensitivityResult( - samples=[result.result.samples_summary for result in results], - perturb_samples=[ - result.perturb_result.samples_summary for result in results - ], - shape=self.shape, - path_values=self.path_values, - ) - - self.paths.save_json("result", to_dict(sensitivity_result)) - - return sensitivity_result - - @property - def shape(self) -> Tuple[int, ...]: - """ - Returns the shape of the sensitivity grid. - - The shape is the number of steps performed for each dimension of the `perturb_model`. If sensitivity mapping - is performed in 3D, the shape will therefore be a tuple of length 3. - - The `shape` can vary across dimensions if the `number_of_steps` parameter is input as a tuple. - - Returns - ------- - The shape of the sensitivity grid. - """ - - if isinstance(self.number_of_steps, tuple): - return self.number_of_steps - - return tuple( - self.number_of_steps for _ in range(self.perturb_model.prior_count) - ) - - def shape_index_from_number(self, number: int) -> Tuple[int, ...]: - """ - Returns the index of the sensitivity grid from a number. - - Parameters - ---------- - number - The number of the sensitivity grid. - - Returns - ------- - The index of the sensitivity grid. - """ - return np.unravel_index(number, self.shape) - - @property - def step_size(self) -> Union[float, Tuple]: - """ - Returns - ------- - step_size - The size of a step in any given dimension in hyper space. - """ - if isinstance(self.number_of_steps, tuple): - return tuple( - 1 / number_of_steps for number_of_steps in self.number_of_steps - ) - return 1 / self.number_of_steps - - @property - def results_path(self) -> Path: - return self.paths.output_path / "results.csv" - - @cached_property - def _lists(self) -> List[List[float]]: - """ - A list of hypercube vectors, used to instantiate - the perturb_model and create the individual - perturbations. - """ - return make_lists(self.perturb_model.prior_count, step_size=self.step_size) - - @cached_property - def path_values(self): - paths = [ - self.perturb_model.path_for_prior(prior) - for prior in self.perturb_model.priors_ordered_by_id - ] - - return { - path: list(values) for path, *values in zip(paths, *self._physical_values) - } - - @cached_property - def _physical_values(self) -> List[List[float]]: - """ - Lists of physical values for each grid square - """ - return [ - [ - prior.value_for(unit_value) - for prior, unit_value in zip( - self.perturb_model.priors_ordered_by_id, unit_values - ) - ] - for unit_values in self._lists - ] - - @cached_property - def _headers(self) -> Generator[str, None, None]: - """ - A name for each of the perturb priors - """ - for path, _ in self.perturb_model.prior_tuples: - yield path - - @cached_property - def _labels(self) -> List[str]: - """ - One label for each perturbation, used to distinguish - fits for each perturbation by placing them in separate - directories. - """ - labels = [] - for list_ in self._lists: - strings = list() - for value, prior_tuple in zip(list_, self.perturb_model.prior_tuples): - path, prior = prior_tuple - value = prior.value_for(value) - strings.append(f"{path}_{value}") - labels.append("_".join(strings)) - - return labels - - @cached_property - def _perturb_instances(self) -> List[ModelInstance]: - """ - A list of instances each of which defines a perturbation to - be applied to the image. - """ - - return [ - self.perturb_model.instance_from_unit_vector(list_) for list_ in self._lists - ] - - @cached_property - def _perturb_models(self) -> List[AbstractPriorModel]: - """ - A list of models representing a perturbation at each grid square. - - By default models have priors with limits at the edges of a grid square. - These limits can be scaled using the limit_scale variable. If the variable - is 2 then the priors will have width twice the step size. - """ - if isinstance(self.step_size, tuple): - step_sizes = self.step_size - else: - step_sizes = (self.step_size,) * self.perturb_model.prior_count - - half_steps = [self.limit_scale * step_size / 2 for step_size in step_sizes] - - perturb_models = [] - for list_ in self._lists: - limits = [ - ( - prior.value_for(max(0.0, centre - half_step)), - prior.value_for(min(1.0, centre + half_step)), - ) - for centre, prior, half_step in zip( - list_, - self.perturb_model.priors_ordered_by_id, - half_steps, - ) - ] - perturb_models.append(self.perturb_model.with_limits(limits)) - return perturb_models - - def _should_bypass(self, number: int) -> bool: - shape_index = self.shape_index_from_number(number=number) - return self.mask is not None and np.asarray(self.mask)[shape_index] - - def _make_jobs(self) -> Generator[Job, None, None]: - for number, _ in enumerate(self._perturb_instances): - yield self._make_job(number) - - def _make_job(self, number) -> Generator[Job, None, None]: - """ - Create a list of jobs to be run on separate processes. - - Each job fits a perturb image with the original model - and a model which includes a perturbation. - """ - perturb_instance = self._perturb_instances[number] - perturb_model = self._perturb_models[number] - label = self._labels[number] - - if self.perturb_model_prior_func is not None: - perturb_model = self.perturb_model_prior_func( - perturb_instance=perturb_instance, perturb_model=perturb_model - ) - - simulate_instance = copy(self.instance) - simulate_instance.perturb = perturb_instance - - paths = self.paths.for_sub_analysis( - label, - ) - - return self.job_cls( - simulate_instance=simulate_instance, - model=self.model, - perturb_model=perturb_model, - base_instance=self.instance, - simulate_cls=self.simulate_cls, - base_fit_cls=self.base_fit_cls, - perturb_fit_cls=self.perturb_fit_cls, - paths=paths, - number=number, - ) +import logging +import os +from copy import copy +import numpy as np +from pathlib import Path +from typing import List, Generator, Callable, ClassVar, Optional, Union, Tuple + +from autonerves import cached_property +from autonerves.dictable import to_dict +from autofit.mapper.model import ModelInstance +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.grid.grid_search import make_lists, Sequential +from autofit.non_linear.grid.sensitivity.job import Job, MaskedJobResult +from autofit.non_linear.grid.sensitivity.job import JobResult +from autofit.non_linear.grid.sensitivity.result import SensitivityResult +from autofit.non_linear.parallel import Process +from autofit.text.formatter import write_table + + +class Sensitivity: + def __init__( + self, + base_model: AbstractPriorModel, + perturb_model: AbstractPriorModel, + simulation_instance, + paths, + simulate_cls: Callable, + base_fit_cls: Callable, + perturb_fit_cls: Callable, + job_cls: ClassVar = Job, + batch_range : Tuple[int, int] = None, + visualizer_cls: Optional[Callable] = None, + perturb_model_prior_func: Optional[Callable] = None, + number_of_steps: Union[Tuple[int, ...], int] = 4, + mask: Optional[List[bool]] = None, + number_of_cores: int = 2, + limit_scale: int = 1, + ): + """ + Perform sensitivity mapping to evaluate whether a perturbation + can be detected if it occurs in different parts of an image. + + For a range from 0 to 1 with step_size, for each dimension of the + perturb_model, a perturbation is created and used in conjunction + with the instance to create an image. + + For each of these images, a fit is run with just the model and with both + the model and perturb_model to compare how much better the image + can be fit if the perturbation is included. + + Parameters + ---------- + base_model + A model that fits the instance well + perturb_model + A model which provides a perturbations to be applied to the instance + before creating images + simulation_instance + An instance of a model to which perturbations are applied prior to + images being generated + simulate_cls + A class which simulates images from each perturb instance that sensitivity mapping is performed on. + base_fit_cls + The class which fits the base model to each simulated dataset of the sensitivity map. + perturb_fit_cls + The class which fits the perturb model to each simulated dataset of the sensitivity map. + batch_range + The integer range of sensitivity mapping jobs to perform. If None, all jobs are performed. If not None, + only the jobs with indices within this range are performed. This means, for example, the range can be + used to distribute jobs to different machines. + visualizer_cls + A class which can be used to visualize the results of the sensitivity mapping after each fit is performed, + therefore providing visualization on the fly. + number_of_steps + The number of steps for each dimension of the sensitivity grid. If input as a float the dimensions are + all that value. If input as a tuple of length the number of dimensions, each tuple value is the number of + steps in that dimension. + mask + A mask to apply to the sensitivity grid, such that all `True` values are not included in the sensitivity + mapping. This is useful for removing regions of the sensitivity grid that are expected to have no + sensitivity, for example because they have no signal. + number_of_cores + How many cores does this computer have? + limit_scale + Scales the priors for each perturbation model. + A scale of 1 means priors have limits the same size as the grid square. + A scale of 2 means priors have limits larger than the grid square with + width twice a grid square. + A scale of 0.5 means priors have limits smaller than the grid square + with width half a grid square. + """ + self.logger = logging.getLogger(f"Sensitivity ({paths.name})") + + self.logger.info("Creating") + + self.instance = simulation_instance + self.model = base_model + self.perturb_model = perturb_model + + self.paths = paths + + self.simulate_cls = simulate_cls + self.base_fit_cls = base_fit_cls + self.perturb_fit_cls = perturb_fit_cls + + self.perturb_model_prior_func = perturb_model_prior_func + + self.job_cls = job_cls + self.batch_range = batch_range + self.visualizer_cls = visualizer_cls + + self.number_of_steps = number_of_steps + self.mask = None + + if mask is not None: + self.mask = np.asarray(mask) + if self.shape != self.mask.shape: + raise ValueError( + f""" + The mask of the Sensitivity object must have the same shape as the sensitivity grid. + + For your inputs, the shape of each are as follows: + + Sensitivity Grid: {self.shape} + Mask: {self.mask.shape} + """ + ) + + self.number_of_cores = number_of_cores + + self.limit_scale = limit_scale + + def run(self) -> SensitivityResult: + """ + Run fits and comparisons for all perturbations, returning + a list of results. + """ + self.logger.info("Running") + + self.paths.save_unique_tag(is_grid_search=True) + + headers = [ + "index", + *self._headers, + "log_evidence_increase", + "log_likelihood_increase", + ] + physical_values = list(self._physical_values) + + process_class = Process if self.number_of_cores > 1 else Sequential + + results = [] + jobs = [] + + for number in range(len(self._perturb_instances)): + model = self.model.copy() + model.perturb = self._perturb_models[number] + results.append( + MaskedJobResult( + number=number, + model=model, + ) + ) + + if not self._should_bypass(number=number): + jobs.append(self._make_job(number)) + + if self.batch_range is not None: + jobs = jobs[self.batch_range[0]:self.batch_range[1]] + + for result in process_class.run_jobs( + jobs, number_of_cores=self.number_of_cores + ): + if isinstance(result, Exception): + raise result + + results[result.number] = result + + sensitivity_result = SensitivityResult( + samples=[result.result.samples_summary for result in results], + perturb_samples=[ + result.perturb_result.samples_summary for result in results + ], + shape=self.shape, + path_values=self.path_values, + ) + + if self.visualizer_cls is not None: + self.visualizer_cls( + sensitivity_result=sensitivity_result, paths=self.paths + ) + + os.makedirs(self.paths.output_path, exist_ok=True) + + write_table( + headers=headers, + rows=[ + [ + result.number, + *physical_values[result.number], + result.log_evidence_increase, + result.log_likelihood_increase, + ] + for result in results + ], + filename=self.results_path, + ) + + # TODO : Had to repeat this code block to get certain unit tests to pass which presumably bypass run_jobs. + + sensitivity_result = SensitivityResult( + samples=[result.result.samples_summary for result in results], + perturb_samples=[ + result.perturb_result.samples_summary for result in results + ], + shape=self.shape, + path_values=self.path_values, + ) + + self.paths.save_json("result", to_dict(sensitivity_result)) + + return sensitivity_result + + @property + def shape(self) -> Tuple[int, ...]: + """ + Returns the shape of the sensitivity grid. + + The shape is the number of steps performed for each dimension of the `perturb_model`. If sensitivity mapping + is performed in 3D, the shape will therefore be a tuple of length 3. + + The `shape` can vary across dimensions if the `number_of_steps` parameter is input as a tuple. + + Returns + ------- + The shape of the sensitivity grid. + """ + + if isinstance(self.number_of_steps, tuple): + return self.number_of_steps + + return tuple( + self.number_of_steps for _ in range(self.perturb_model.prior_count) + ) + + def shape_index_from_number(self, number: int) -> Tuple[int, ...]: + """ + Returns the index of the sensitivity grid from a number. + + Parameters + ---------- + number + The number of the sensitivity grid. + + Returns + ------- + The index of the sensitivity grid. + """ + return np.unravel_index(number, self.shape) + + @property + def step_size(self) -> Union[float, Tuple]: + """ + Returns + ------- + step_size + The size of a step in any given dimension in hyper space. + """ + if isinstance(self.number_of_steps, tuple): + return tuple( + 1 / number_of_steps for number_of_steps in self.number_of_steps + ) + return 1 / self.number_of_steps + + @property + def results_path(self) -> Path: + return self.paths.output_path / "results.csv" + + @cached_property + def _lists(self) -> List[List[float]]: + """ + A list of hypercube vectors, used to instantiate + the perturb_model and create the individual + perturbations. + """ + return make_lists(self.perturb_model.prior_count, step_size=self.step_size) + + @cached_property + def path_values(self): + paths = [ + self.perturb_model.path_for_prior(prior) + for prior in self.perturb_model.priors_ordered_by_id + ] + + return { + path: list(values) for path, *values in zip(paths, *self._physical_values) + } + + @cached_property + def _physical_values(self) -> List[List[float]]: + """ + Lists of physical values for each grid square + """ + return [ + [ + prior.value_for(unit_value) + for prior, unit_value in zip( + self.perturb_model.priors_ordered_by_id, unit_values + ) + ] + for unit_values in self._lists + ] + + @cached_property + def _headers(self) -> Generator[str, None, None]: + """ + A name for each of the perturb priors + """ + for path, _ in self.perturb_model.prior_tuples: + yield path + + @cached_property + def _labels(self) -> List[str]: + """ + One label for each perturbation, used to distinguish + fits for each perturbation by placing them in separate + directories. + """ + labels = [] + for list_ in self._lists: + strings = list() + for value, prior_tuple in zip(list_, self.perturb_model.prior_tuples): + path, prior = prior_tuple + value = prior.value_for(value) + strings.append(f"{path}_{value}") + labels.append("_".join(strings)) + + return labels + + @cached_property + def _perturb_instances(self) -> List[ModelInstance]: + """ + A list of instances each of which defines a perturbation to + be applied to the image. + """ + + return [ + self.perturb_model.instance_from_unit_vector(list_) for list_ in self._lists + ] + + @cached_property + def _perturb_models(self) -> List[AbstractPriorModel]: + """ + A list of models representing a perturbation at each grid square. + + By default models have priors with limits at the edges of a grid square. + These limits can be scaled using the limit_scale variable. If the variable + is 2 then the priors will have width twice the step size. + """ + if isinstance(self.step_size, tuple): + step_sizes = self.step_size + else: + step_sizes = (self.step_size,) * self.perturb_model.prior_count + + half_steps = [self.limit_scale * step_size / 2 for step_size in step_sizes] + + perturb_models = [] + for list_ in self._lists: + limits = [ + ( + prior.value_for(max(0.0, centre - half_step)), + prior.value_for(min(1.0, centre + half_step)), + ) + for centre, prior, half_step in zip( + list_, + self.perturb_model.priors_ordered_by_id, + half_steps, + ) + ] + perturb_models.append(self.perturb_model.with_limits(limits)) + return perturb_models + + def _should_bypass(self, number: int) -> bool: + shape_index = self.shape_index_from_number(number=number) + return self.mask is not None and np.asarray(self.mask)[shape_index] + + def _make_jobs(self) -> Generator[Job, None, None]: + for number, _ in enumerate(self._perturb_instances): + yield self._make_job(number) + + def _make_job(self, number) -> Generator[Job, None, None]: + """ + Create a list of jobs to be run on separate processes. + + Each job fits a perturb image with the original model + and a model which includes a perturbation. + """ + perturb_instance = self._perturb_instances[number] + perturb_model = self._perturb_models[number] + label = self._labels[number] + + if self.perturb_model_prior_func is not None: + perturb_model = self.perturb_model_prior_func( + perturb_instance=perturb_instance, perturb_model=perturb_model + ) + + simulate_instance = copy(self.instance) + simulate_instance.perturb = perturb_instance + + paths = self.paths.for_sub_analysis( + label, + ) + + return self.job_cls( + simulate_instance=simulate_instance, + model=self.model, + perturb_model=perturb_model, + base_instance=self.instance, + simulate_cls=self.simulate_cls, + base_fit_cls=self.base_fit_cls, + perturb_fit_cls=self.perturb_fit_cls, + paths=paths, + number=number, + ) diff --git a/autofit/non_linear/grid/sensitivity/job.py b/autofit/non_linear/grid/sensitivity/job.py index 1d1cdf216..dba63aff2 100644 --- a/autofit/non_linear/grid/sensitivity/job.py +++ b/autofit/non_linear/grid/sensitivity/job.py @@ -1,185 +1,185 @@ -from copy import copy -from itertools import count -from typing import Callable, Optional - -from autofit.mapper.model import ModelInstance -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.parallel import AbstractJob, AbstractJobResult -from autofit.non_linear.paths.abstract import AbstractPaths -from autofit.non_linear.result import Result - - -class JobResult(AbstractJobResult): - def __init__(self, number: int, result: Result, perturb_result: Result): - """ - The result of a single sensitivity comparison - - Parameters - ---------- - result - perturb_result - """ - super().__init__(number) - self.result = result - self.perturb_result = perturb_result - - @property - def log_evidence_increase(self) -> Optional[float]: - """ - Returns a tuple of the log evidence of the base model, the perturbed model and the difference between them. - - This is used to ouptut the sensitivity mapping results to .csv files. - - If the log evidence is not available, a tuple containing 3 None's is returned. - """ - - if hasattr(self.result.samples, "log_evidence"): - if self.result.samples.log_evidence is not None and self.perturb_result.samples.log_evidence is not None: - return float( - self.perturb_result.samples.log_evidence - - self.result.samples.log_evidence - ) - - @property - def log_likelihood_increase(self) -> Optional[float]: - """ - Returns a tuple of the log likelihood of the base model, the perturbed model and the difference between them. - - This is used to ouptut the sensitivity mapping results to .csv files. - """ - - return float(self.perturb_result.log_likelihood - self.result.log_likelihood) - - -class MaskedJobResult(AbstractJobResult): - """ - A placeholder result for a job that has been masked out. - """ - - def __init__(self, number, model): - super().__init__(number) - self.model = model - - @property - def result(self): - return self - - @property - def perturb_result(self): - return self - - def __getattr__(self, item): - return None - - @property - def samples_summary(self): - return self - - @property - def log_evidence(self): - return 0.0 - - @property - def log_likelihood(self): - return 0.0 - - -class Job(AbstractJob): - _number = count() - - def __init__( - self, - model: AbstractPriorModel, - simulate_cls: Callable, - perturb_model: AbstractPriorModel, - simulate_instance: ModelInstance, - base_instance: ModelInstance, - base_fit_cls: Callable, - perturb_fit_cls: Callable, - paths: AbstractPaths, - number: int, - ): - """ - Job to run non-linear searches comparing how well a model and a model with a perturbation fit the image. - - Parameters - ---------- - model - A base model that fits the image without a perturbation - perturb_model - A model of the perturbation which has been added to the underlying image - base_fit_cls - A class which defines the function which fits the base model to each simulated dataset of the sensitivity - map. - perturb_fit_cls - A class which defines the function which fits the perturbed model to each simulated dataset of the - sensitivity map. - paths - The paths defining the output directory structure of the sensitivity mapping. - """ - super().__init__(number=number) - - self.model = model - self.simulate_cls = simulate_cls - self.perturb_model = perturb_model - self.simulate_instance = simulate_instance - self.base_instance = base_instance - self.base_fit_cls = base_fit_cls - self.perturb_fit_cls = perturb_fit_cls - self.paths = paths - - @property - def base_paths(self): - return self.paths.for_sub_analysis("[base]") - - @property - def perturb_paths(self): - return self.paths.for_sub_analysis("[perturb]") - - @property - def is_complete(self) -> bool: - """ - Returns True if the job has been completed, False otherwise. - """ - return (self.base_paths.is_complete and self.perturb_paths.is_complete) or ( - (self.paths.output_path / "[base].zip").exists() - and (self.paths.output_path / "[perturb].zip").exists() - ) - - def perform(self) -> JobResult: - """ - - Create one model with a perturbation and another without - - Fit each model against the perturbed image - - Returns - ------- - An object comprising the results of the two fits - """ - - dataset = self.simulate_cls( - instance=self.simulate_instance, - simulate_path=self.paths.image_path.with_name("simulate"), - ) - - result = self.base_fit_cls( - model=self.model, - dataset=dataset, - paths=self.base_paths, - instance=self.simulate_instance, - ) - - perturb_model = copy(self.model) - perturb_model.perturb = self.perturb_model - - perturb_result = self.perturb_fit_cls( - model=perturb_model, - dataset=dataset, - paths=self.perturb_paths, - instance=self.simulate_instance, - ) - - return JobResult( - number=self.number, - result=result, - perturb_result=perturb_result, - ) +from copy import copy +from itertools import count +from typing import Callable, Optional + +from autofit.mapper.model import ModelInstance +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.parallel import AbstractJob, AbstractJobResult +from autofit.non_linear.paths.abstract import AbstractPaths +from autofit.non_linear.result import Result + + +class JobResult(AbstractJobResult): + def __init__(self, number: int, result: Result, perturb_result: Result): + """ + The result of a single sensitivity comparison + + Parameters + ---------- + result + perturb_result + """ + super().__init__(number) + self.result = result + self.perturb_result = perturb_result + + @property + def log_evidence_increase(self) -> Optional[float]: + """ + Returns a tuple of the log evidence of the base model, the perturbed model and the difference between them. + + This is used to ouptut the sensitivity mapping results to .csv files. + + If the log evidence is not available, a tuple containing 3 None's is returned. + """ + + if hasattr(self.result.samples, "log_evidence"): + if self.result.samples.log_evidence is not None and self.perturb_result.samples.log_evidence is not None: + return float( + self.perturb_result.samples.log_evidence + - self.result.samples.log_evidence + ) + + @property + def log_likelihood_increase(self) -> Optional[float]: + """ + Returns a tuple of the log likelihood of the base model, the perturbed model and the difference between them. + + This is used to ouptut the sensitivity mapping results to .csv files. + """ + + return float(self.perturb_result.log_likelihood - self.result.log_likelihood) + + +class MaskedJobResult(AbstractJobResult): + """ + A placeholder result for a job that has been masked out. + """ + + def __init__(self, number, model): + super().__init__(number) + self.model = model + + @property + def result(self): + return self + + @property + def perturb_result(self): + return self + + def __getattr__(self, item): + return None + + @property + def samples_summary(self): + return self + + @property + def log_evidence(self): + return 0.0 + + @property + def log_likelihood(self): + return 0.0 + + +class Job(AbstractJob): + _number = count() + + def __init__( + self, + model: AbstractPriorModel, + simulate_cls: Callable, + perturb_model: AbstractPriorModel, + simulate_instance: ModelInstance, + base_instance: ModelInstance, + base_fit_cls: Callable, + perturb_fit_cls: Callable, + paths: AbstractPaths, + number: int, + ): + """ + Job to run non-linear searches comparing how well a model and a model with a perturbation fit the image. + + Parameters + ---------- + model + A base model that fits the image without a perturbation + perturb_model + A model of the perturbation which has been added to the underlying image + base_fit_cls + A class which defines the function which fits the base model to each simulated dataset of the sensitivity + map. + perturb_fit_cls + A class which defines the function which fits the perturbed model to each simulated dataset of the + sensitivity map. + paths + The paths defining the output directory structure of the sensitivity mapping. + """ + super().__init__(number=number) + + self.model = model + self.simulate_cls = simulate_cls + self.perturb_model = perturb_model + self.simulate_instance = simulate_instance + self.base_instance = base_instance + self.base_fit_cls = base_fit_cls + self.perturb_fit_cls = perturb_fit_cls + self.paths = paths + + @property + def base_paths(self): + return self.paths.for_sub_analysis("[base]") + + @property + def perturb_paths(self): + return self.paths.for_sub_analysis("[perturb]") + + @property + def is_complete(self) -> bool: + """ + Returns True if the job has been completed, False otherwise. + """ + return (self.base_paths.is_complete and self.perturb_paths.is_complete) or ( + (self.paths.output_path / "[base].zip").exists() + and (self.paths.output_path / "[perturb].zip").exists() + ) + + def perform(self) -> JobResult: + """ + - Create one model with a perturbation and another without + - Fit each model against the perturbed image + + Returns + ------- + An object comprising the results of the two fits + """ + + dataset = self.simulate_cls( + instance=self.simulate_instance, + simulate_path=self.paths.image_path.with_name("simulate"), + ) + + result = self.base_fit_cls( + model=self.model, + dataset=dataset, + paths=self.base_paths, + instance=self.simulate_instance, + ) + + perturb_model = copy(self.model) + perturb_model.perturb = self.perturb_model + + perturb_result = self.perturb_fit_cls( + model=perturb_model, + dataset=dataset, + paths=self.perturb_paths, + instance=self.simulate_instance, + ) + + return JobResult( + number=self.number, + result=result, + perturb_result=perturb_result, + ) diff --git a/autofit/non_linear/grid/sensitivity/result.py b/autofit/non_linear/grid/sensitivity/result.py index 8e5fd4839..72a1b21aa 100644 --- a/autofit/non_linear/grid/sensitivity/result.py +++ b/autofit/non_linear/grid/sensitivity/result.py @@ -1,129 +1,129 @@ -from typing import List, Tuple, Union, Dict - -from autofit.non_linear.grid.grid_list import GridList, as_grid_list -from autofit.non_linear.grid.grid_search.result import AbstractGridSearchResult -from autofit.non_linear.samples.interface import SamplesInterface - - -# noinspection PyTypeChecker -class SensitivityResult(AbstractGridSearchResult): - def __init__( - self, - samples: List[SamplesInterface], - perturb_samples: List[SamplesInterface], - shape: Tuple[int, ...], - path_values: Dict[Tuple[str, ...], List[float]], - ): - """ - The result of a sensitivity mapping - - Parameters - ---------- - shape - The shape of the sensitivity mapping grid. - path_values - A list of tuples of the path to the grid priors and the physical values themselves. - """ - super().__init__(GridList(samples, shape)) - self.perturb_samples = GridList(perturb_samples, shape) - self.shape = shape - self.path_values = path_values - - def perturbed_physical_centres_list_from( - self, path: Union[str, Tuple[str, ...]] - ) -> GridList: - """ - Returns the physical centres of the perturbed model for each sensitivity fit - - Parameters - ---------- - path - The path to the physical centres in the samples - """ - if isinstance(path, str): - path = tuple(path.split(".")) - return self.path_values[path] - - def __getitem__(self, item): - return self.samples[item] - - def __iter__(self): - return iter(self.samples) - - def __len__(self): - return len(self.samples) - - @property - @as_grid_list - def log_evidences_base(self) -> GridList: - """ - The log evidences of the base model for each sensitivity fit - """ - return [sample.log_evidence for sample in self.samples] - - @property - @as_grid_list - def log_evidences_perturbed(self) -> GridList: - """ - The log evidences of the perturbed model for each sensitivity fit - """ - return [sample.log_evidence for sample in self.perturb_samples] - - @property - @as_grid_list - def log_evidence_differences(self) -> GridList: - """ - The log evidence differences between the base and perturbed models - """ - return [ - log_evidence_perturbed - log_evidence_base - for log_evidence_perturbed, log_evidence_base in zip( - self.log_evidences_perturbed, self.log_evidences_base - ) - ] - - @property - @as_grid_list - def log_likelihoods_base(self) -> GridList: - """ - The log likelihoods of the base model for each sensitivity fit - """ - return [sample.log_likelihood for sample in self.samples] - - @property - @as_grid_list - def log_likelihoods_perturbed(self) -> GridList: - """ - The log likelihoods of the perturbed model for each sensitivity fit - """ - return [sample.log_likelihood for sample in self.perturb_samples] - - @property - @as_grid_list - def log_likelihood_differences(self) -> GridList: - """ - The log likelihood differences between the base and perturbed models - """ - return [ - log_likelihood_perturbed - log_likelihood_base - for log_likelihood_perturbed, log_likelihood_base in zip( - self.log_likelihoods_perturbed, self.log_likelihoods_base - ) - ] - - def figure_of_merits( - self, - use_log_evidences: bool, - ) -> GridList: - """ - Convenience method to get either the log likelihoods difference or log evidence difference of the grid search. - - Parameters - ---------- - use_log_evidences - If true, the log evidences are returned, otherwise the log likelihoods are returned. - """ - - if use_log_evidences: - return self.log_evidence_differences - return self.log_likelihood_differences +from typing import List, Tuple, Union, Dict + +from autofit.non_linear.grid.grid_list import GridList, as_grid_list +from autofit.non_linear.grid.grid_search.result import AbstractGridSearchResult +from autofit.non_linear.samples.interface import SamplesInterface + + +# noinspection PyTypeChecker +class SensitivityResult(AbstractGridSearchResult): + def __init__( + self, + samples: List[SamplesInterface], + perturb_samples: List[SamplesInterface], + shape: Tuple[int, ...], + path_values: Dict[Tuple[str, ...], List[float]], + ): + """ + The result of a sensitivity mapping + + Parameters + ---------- + shape + The shape of the sensitivity mapping grid. + path_values + A list of tuples of the path to the grid priors and the physical values themselves. + """ + super().__init__(GridList(samples, shape)) + self.perturb_samples = GridList(perturb_samples, shape) + self.shape = shape + self.path_values = path_values + + def perturbed_physical_centres_list_from( + self, path: Union[str, Tuple[str, ...]] + ) -> GridList: + """ + Returns the physical centres of the perturbed model for each sensitivity fit + + Parameters + ---------- + path + The path to the physical centres in the samples + """ + if isinstance(path, str): + path = tuple(path.split(".")) + return self.path_values[path] + + def __getitem__(self, item): + return self.samples[item] + + def __iter__(self): + return iter(self.samples) + + def __len__(self): + return len(self.samples) + + @property + @as_grid_list + def log_evidences_base(self) -> GridList: + """ + The log evidences of the base model for each sensitivity fit + """ + return [sample.log_evidence for sample in self.samples] + + @property + @as_grid_list + def log_evidences_perturbed(self) -> GridList: + """ + The log evidences of the perturbed model for each sensitivity fit + """ + return [sample.log_evidence for sample in self.perturb_samples] + + @property + @as_grid_list + def log_evidence_differences(self) -> GridList: + """ + The log evidence differences between the base and perturbed models + """ + return [ + log_evidence_perturbed - log_evidence_base + for log_evidence_perturbed, log_evidence_base in zip( + self.log_evidences_perturbed, self.log_evidences_base + ) + ] + + @property + @as_grid_list + def log_likelihoods_base(self) -> GridList: + """ + The log likelihoods of the base model for each sensitivity fit + """ + return [sample.log_likelihood for sample in self.samples] + + @property + @as_grid_list + def log_likelihoods_perturbed(self) -> GridList: + """ + The log likelihoods of the perturbed model for each sensitivity fit + """ + return [sample.log_likelihood for sample in self.perturb_samples] + + @property + @as_grid_list + def log_likelihood_differences(self) -> GridList: + """ + The log likelihood differences between the base and perturbed models + """ + return [ + log_likelihood_perturbed - log_likelihood_base + for log_likelihood_perturbed, log_likelihood_base in zip( + self.log_likelihoods_perturbed, self.log_likelihoods_base + ) + ] + + def figure_of_merits( + self, + use_log_evidences: bool, + ) -> GridList: + """ + Convenience method to get either the log likelihoods difference or log evidence difference of the grid search. + + Parameters + ---------- + use_log_evidences + If true, the log evidences are returned, otherwise the log likelihoods are returned. + """ + + if use_log_evidences: + return self.log_evidence_differences + return self.log_likelihood_differences diff --git a/autofit/non_linear/initializer.py b/autofit/non_linear/initializer.py index 1bacd349d..e768cdf76 100644 --- a/autofit/non_linear/initializer.py +++ b/autofit/non_linear/initializer.py @@ -1,477 +1,477 @@ -import configparser -import logging -import os -import random -from abc import ABC, abstractmethod -from typing import Dict, Tuple, List, Optional - -import numpy as np - -from autofit import exc -from autofit.non_linear.test_mode import is_test_mode -from autofit.non_linear.paths.abstract import AbstractPaths -from autofit.mapper.prior.abstract import Prior -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.parallel import SneakyPool - -logger = logging.getLogger(__name__) - - -class AbstractInitializer(ABC): - - @abstractmethod - def _generate_unit_parameter_list(self, model): - pass - - def info_from_model(self, model : AbstractPriorModel) -> str: - raise NotImplementedError - - @staticmethod - def figure_of_metric(args) -> Optional[float]: - fitness, parameter_list = args - try: - figure_of_merit = fitness(parameters=parameter_list) - - if np.isnan(figure_of_merit) or figure_of_merit < -1e98: - return None - - return float(figure_of_merit) - except exc.FitException: - return None - - def samples_from_model( - self, - total_points: int, - model: AbstractPriorModel, - fitness, - paths: AbstractPaths, - use_prior_medians: bool = False, - test_mode_samples: bool = True, - n_cores: int = 1, - ): - """ - Generate the initial points of the non-linear search, by randomly drawing unit values from a uniform - distribution between the ball_lower_limit and ball_upper_limit values. - - Parameters - ---------- - total_points - The number of points in non-linear paramemter space which initial points are created for. - model - An object that represents possible instances of some model with a given dimensionality which is the number - of free dimensions of the model. - """ - - if is_test_mode() and test_mode_samples: - return self.samples_in_test_mode(total_points=total_points, model=model) - - if n_cores == 1: - return self.samples_jax( - total_points=total_points, - model=model, - fitness=fitness, - use_prior_medians=use_prior_medians - ) - - unit_parameter_lists = [] - parameter_lists = [] - figures_of_merit_list = [] - - sneaky_pool = SneakyPool(n_cores, fitness, paths) - - logger.info(f"Generating initial samples of model using {n_cores} cores") - - while len(figures_of_merit_list) < total_points: - remaining_points = total_points - len(figures_of_merit_list) - batch_size = min(remaining_points, n_cores) - parameter_lists_ = [] - unit_parameter_lists_ = [] - - for _ in range(batch_size): - if not use_prior_medians: - unit_parameter_list = self._generate_unit_parameter_list(model) - else: - unit_parameter_list = [0.5] * model.prior_count - - parameter_list = model.vector_from_unit_vector( - unit_vector=unit_parameter_list - ) - - parameter_lists_.append(parameter_list) - unit_parameter_lists_.append(unit_parameter_list) - - for figure_of_merit, unit_parameter_list, parameter_list in zip( - sneaky_pool.map( - function=self.figure_of_metric, - args_list=[(fitness, parameter_list) for parameter_list in parameter_lists_], - log_info=False - ), - unit_parameter_lists_, - parameter_lists_, - ): - if figure_of_merit is not None: - unit_parameter_lists.append(unit_parameter_list) - parameter_lists.append(parameter_list) - figures_of_merit_list.append(figure_of_merit) - - if total_points > 1 and np.allclose( - a=figures_of_merit_list[0], b=figures_of_merit_list[1:] - ): - raise exc.InitializerException( - """ - The initial samples all have the same figure of merit (e.g. log likelihood values). - - The non-linear search will therefore not progress correctly. - - Possible causes for this behaviour are: - - - The `log_likelihood_function` of the analysis class is defined incorrectly. - - The model parameterization creates numerically inaccurate log likelihoods. - - The`log_likelihood_function` is always returning `nan` values. - """ - ) - - logger.info(f"Initial samples generated, starting non-linear search") - - return unit_parameter_lists, parameter_lists, figures_of_merit_list - - def samples_jax( - self, - total_points: int, - model: AbstractPriorModel, - fitness, - use_prior_medians: bool = False, - ): - """ - Generate the initial points of the non-linear search, by randomly drawing unit values from a uniform - distribution between the ball_lower_limit and ball_upper_limit values. - - Parameters - ---------- - total_points - The number of points in non-linear paramemter space which initial points are created for. - model - An object that represents possible instances of some model with a given dimensionality which is the number - of free dimensions of the model. - """ - - unit_parameter_lists = [] - parameter_lists = [] - figures_of_merit_list = [] - - logger.info(f"Generating initial samples of model using JAX LH Function cores") - - while len(figures_of_merit_list) < total_points: - - if not use_prior_medians: - unit_parameter_list = self._generate_unit_parameter_list(model) - else: - unit_parameter_list = [0.5] * model.prior_count - - parameter_list = model.vector_from_unit_vector( - unit_vector=unit_parameter_list - ) - - figure_of_merit = self.figure_of_metric((fitness, parameter_list)) - - if figure_of_merit is not None: - unit_parameter_lists.append(unit_parameter_list) - parameter_lists.append(parameter_list) - figures_of_merit_list.append(figure_of_merit) - - if total_points > 1 and np.allclose( - a=figures_of_merit_list[0], b=figures_of_merit_list[1:] - ): - raise exc.InitializerException( - """ - The initial samples all have the same figure of merit (e.g. log likelihood values). - - The non-linear search will therefore not progress correctly. - - Possible causes for this behaviour are: - - - The `log_likelihood_function` of the analysis class is defined incorrectly. - - The model parameterization creates numerically inaccurate log likelihoods. - - The`log_likelihood_function` is always returning `nan` values. - """ - ) - - logger.info(f"Initial samples generated, starting non-linear search") - - return unit_parameter_lists, parameter_lists, figures_of_merit_list - - def samples_in_test_mode(self, total_points: int, model: AbstractPriorModel): - """ - Generate the initial points of the non-linear search in test mode. Like normal, test model draws points, by - randomly drawing unit values from a uniform distribution between the ball_lower_limit and ball_upper_limit - values. - - However, the log likelihood function is bypassed and all likelihoods are returned with a value -1.0e99. This - is so that integration testing of large-scale model-fitting projects can be performed efficiently by bypassing - sampling of points using the `log_likelihood_function`. - - Parameters - ---------- - total_points - The number of points in non-linear paramemter space which initial points are created for. - model - An object that represents possible instances of some model with a given dimensionality which is the number - of free dimensions of the model. - """ - - logger.warning( - "TEST MODE 1 (reduced iterations): Initial samples assigned " - "arbitrary large likelihoods to accelerate sampler convergence." - ) - - unit_parameter_lists = [] - parameter_lists = [] - figure_of_merit_list = [] - - point_index = 0 - - figure_of_merit = -1.0e99 - - while point_index < total_points: - try: - unit_parameter_list = self._generate_unit_parameter_list(model) - parameter_list = model.vector_from_unit_vector( - unit_vector=unit_parameter_list - ) - model.instance_from_vector(vector=parameter_list) - unit_parameter_lists.append(unit_parameter_list) - parameter_lists.append(parameter_list) - figure_of_merit_list.append(figure_of_merit) - figure_of_merit *= 10.0 - point_index += 1 - except exc.FitException: - pass - - return unit_parameter_lists, parameter_lists, figure_of_merit_list - - -class InitializerParamBounds(AbstractInitializer): - def __init__( - self, - parameter_dict: Dict[Prior, Tuple[float, float]], - lower_limit=0.0, - upper_limit=1.0, - ): - """ - Initializer which uses the bounds on input parameters as the starting point for the search (e.g. where - an MLE optimization starts or MCMC walkers are initialized). - - Parameters - ---------- - parameter_dict - A dictionary mapping each parameter path to bounded ranges of physical values that - are where the search begins. - lower_limit - A default, unit lower limit used when a prior is not specified - upper_limit - A default, unit upper limit used when a prior is not specified - """ - - self.parameter_dict = parameter_dict - self.lower_limit = lower_limit - self.upper_limit = upper_limit - - self._generated_warnings = set() - - def _generate_unit_parameter_list(self, model: AbstractPriorModel) -> List[float]: - """ - Generate a unit vector for the model. The default limits are used for any - priors which the model has but are not found in the parameter dict. - - Parameters - ---------- - model - A model for which initial points are required - - Returns - ------- - A unit vector - """ - - unit_parameter_list = [] - for prior in model.priors_ordered_by_id: - - try: - lower, upper = map(prior.unit_value_for, self.parameter_dict[prior]) - value = random.uniform(lower, upper) - except KeyError: - key = ".".join(model.path_for_prior(prior)) - if key not in self._generated_warnings: - logger.warning( - f"Range for {key} not set in the InitializerParamBounds. " - f"Using defaults." - ) - self._generated_warnings.add(key) - - lower = self.lower_limit - upper = self.upper_limit - - value = prior.unit_value_for(prior.random(lower, upper)) - - unit_parameter_list.append(value) - - return unit_parameter_list - - def info_from_model(self, model : AbstractPriorModel) -> str: - """ - Returns a string showing the bounds of the parameters in the initializer. - """ - info = "Total Free Parameters = " + str(model.prior_count) + "\n" - info += "Total Starting Points = " + str(len(self.parameter_dict)) + "\n\n" - for prior in model.priors_ordered_by_id: - - key = ".".join(model.path_for_prior(prior)) - - try: - - value = self.info_value_from(self.parameter_dict[prior]) - - info += f"{key}: Start[{value}]\n" - - except KeyError: - - info += f"{key}: {prior})\n" - - return info - - def info_value_from(self, value : Tuple[float, float]) -> Tuple[float, float]: - """ - Returns the value that is used to display the bounds of the parameters in the initializer. - - This function simply returns the input value, but it can be overridden in subclasses for diffferent - initializers. - - Parameters - ---------- - value - The value to be displayed in the initializer info which is a tuple of the lower and upper bounds of the - parameter. - """ - return value - - -class InitializerParamStartPoints(InitializerParamBounds): - def __init__( - self, - parameter_dict: Dict[Prior, float], - ): - """ - Initializer which input values of the parameters as the starting point for the search (e.g. where - an MLE optimization starts or MCMC walkers are initialized). - - Parameters - ---------- - parameter_dict - A dictionary mapping each parameter path to the starting point physical values that - are where the search begins. - lower_limit - A default, unit lower limit used when a prior is not specified - upper_limit - A default, unit upper limit used when a prior is not specified - """ - parameter_dict_new = {} - - for key, value in parameter_dict.items(): - parameter_dict_new[key] = (value - 1.0e-8, value + 1.0e-8) - - super().__init__(parameter_dict=parameter_dict_new) - - def info_value_from(self, value : Tuple[float, float]) -> float: - """ - Returns the value that is used to display the starting point of the parameters in the initializer. - - This function returns the mean of the input value, as the starting point is a single value in the center of the - bounds. - - Parameters - ---------- - value - The value to be displayed in the initializer info which is a tuple of the lower and upper bounds of the - parameter. - """ - return (value[1] + value[0]) / 2.0 - - -class Initializer(AbstractInitializer): - def __init__(self, lower_limit: float, upper_limit: float): - """ - The Initializer creates the initial set of samples in non-linear parameter space that can be passed into a - `NonLinearSearch` to define where to begin sampling. - - Although most non-linear searches have in-built functionality to do this, some do not cope well with parameter - resamples that are raised as FitException's. Thus, PyAutoFit uses its own initializer to bypass these problems. - """ - self.lower_limit = lower_limit - self.upper_limit = upper_limit - - @classmethod - def from_config(cls, config): - """ - Load the Initializer from a non_linear config file. - """ - - try: - initializer = config("initialize", "method") - - except configparser.NoSectionError: - return None - - if initializer in "prior": - return InitializerPrior() - - elif initializer in "ball": - ball_lower_limit = config("initialize", "ball_lower_limit") - ball_upper_limit = config("initialize", "ball_upper_limit") - - return InitializerBall( - lower_limit=ball_lower_limit, upper_limit=ball_upper_limit - ) - - def _generate_unit_parameter_list(self, model): - return model.random_unit_vector_within_limits( - lower_limit=self.lower_limit, upper_limit=self.upper_limit - ) - - -class InitializerPrior(Initializer): - def __init__(self): - """ - The Initializer creates the initial set of samples in non-linear parameter space that can be passed into a - `NonLinearSearch` to define where to begin sampling. - - Although most non-linear searches have in-built functionality to do this, some do not cope well with parameter - resamples that are raised as FitException's. Thus, PyAutoFit uses its own initializer to bypass these problems. - - The InitializerPrior class generates from the priors, by drawing all values as unit values between 0.0 and 1.0 - and mapping them to physical values via the prior. - """ - super().__init__(lower_limit=0.0, upper_limit=1.0) - - -class InitializerBall(Initializer): - def __init__(self, lower_limit: float, upper_limit: float): - """ - The Initializer creates the initial set of samples in non-linear parameter space that can be passed into a - `NonLinearSearch` to define where to begin sampling. - - Although most non-linear searches have in-built functionality to do this, some do not cope well with parameter - resamples that are raised as FitException's. Thus, PyAutoFit uses its own initializer to bypass these problems. - - The InitializerBall class generates the samples in a small compact volume or 'ball' in parameter space, which is - the recommended initialization strategy for the MCMC `NonLinearSearch` Emcee. - - Parameters - ---------- - lower_limit - The lower limit of the uniform distribution unit values are drawn from when initializing walkers in a small - compact ball. - upper_limit - The upper limit of the uniform distribution unit values are drawn from when initializing walkers in a small - compact ball. - """ - super().__init__(lower_limit=lower_limit, upper_limit=upper_limit) +import configparser +import logging +import os +import random +from abc import ABC, abstractmethod +from typing import Dict, Tuple, List, Optional + +import numpy as np + +from autofit import exc +from autofit.non_linear.test_mode import is_test_mode +from autofit.non_linear.paths.abstract import AbstractPaths +from autofit.mapper.prior.abstract import Prior +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.parallel import SneakyPool + +logger = logging.getLogger(__name__) + + +class AbstractInitializer(ABC): + + @abstractmethod + def _generate_unit_parameter_list(self, model): + pass + + def info_from_model(self, model : AbstractPriorModel) -> str: + raise NotImplementedError + + @staticmethod + def figure_of_metric(args) -> Optional[float]: + fitness, parameter_list = args + try: + figure_of_merit = fitness(parameters=parameter_list) + + if np.isnan(figure_of_merit) or figure_of_merit < -1e98: + return None + + return float(figure_of_merit) + except exc.FitException: + return None + + def samples_from_model( + self, + total_points: int, + model: AbstractPriorModel, + fitness, + paths: AbstractPaths, + use_prior_medians: bool = False, + test_mode_samples: bool = True, + n_cores: int = 1, + ): + """ + Generate the initial points of the non-linear search, by randomly drawing unit values from a uniform + distribution between the ball_lower_limit and ball_upper_limit values. + + Parameters + ---------- + total_points + The number of points in non-linear paramemter space which initial points are created for. + model + An object that represents possible instances of some model with a given dimensionality which is the number + of free dimensions of the model. + """ + + if is_test_mode() and test_mode_samples: + return self.samples_in_test_mode(total_points=total_points, model=model) + + if n_cores == 1: + return self.samples_jax( + total_points=total_points, + model=model, + fitness=fitness, + use_prior_medians=use_prior_medians + ) + + unit_parameter_lists = [] + parameter_lists = [] + figures_of_merit_list = [] + + sneaky_pool = SneakyPool(n_cores, fitness, paths) + + logger.info(f"Generating initial samples of model using {n_cores} cores") + + while len(figures_of_merit_list) < total_points: + remaining_points = total_points - len(figures_of_merit_list) + batch_size = min(remaining_points, n_cores) + parameter_lists_ = [] + unit_parameter_lists_ = [] + + for _ in range(batch_size): + if not use_prior_medians: + unit_parameter_list = self._generate_unit_parameter_list(model) + else: + unit_parameter_list = [0.5] * model.prior_count + + parameter_list = model.vector_from_unit_vector( + unit_vector=unit_parameter_list + ) + + parameter_lists_.append(parameter_list) + unit_parameter_lists_.append(unit_parameter_list) + + for figure_of_merit, unit_parameter_list, parameter_list in zip( + sneaky_pool.map( + function=self.figure_of_metric, + args_list=[(fitness, parameter_list) for parameter_list in parameter_lists_], + log_info=False + ), + unit_parameter_lists_, + parameter_lists_, + ): + if figure_of_merit is not None: + unit_parameter_lists.append(unit_parameter_list) + parameter_lists.append(parameter_list) + figures_of_merit_list.append(figure_of_merit) + + if total_points > 1 and np.allclose( + a=figures_of_merit_list[0], b=figures_of_merit_list[1:] + ): + raise exc.InitializerException( + """ + The initial samples all have the same figure of merit (e.g. log likelihood values). + + The non-linear search will therefore not progress correctly. + + Possible causes for this behaviour are: + + - The `log_likelihood_function` of the analysis class is defined incorrectly. + - The model parameterization creates numerically inaccurate log likelihoods. + - The`log_likelihood_function` is always returning `nan` values. + """ + ) + + logger.info(f"Initial samples generated, starting non-linear search") + + return unit_parameter_lists, parameter_lists, figures_of_merit_list + + def samples_jax( + self, + total_points: int, + model: AbstractPriorModel, + fitness, + use_prior_medians: bool = False, + ): + """ + Generate the initial points of the non-linear search, by randomly drawing unit values from a uniform + distribution between the ball_lower_limit and ball_upper_limit values. + + Parameters + ---------- + total_points + The number of points in non-linear paramemter space which initial points are created for. + model + An object that represents possible instances of some model with a given dimensionality which is the number + of free dimensions of the model. + """ + + unit_parameter_lists = [] + parameter_lists = [] + figures_of_merit_list = [] + + logger.info(f"Generating initial samples of model using JAX LH Function cores") + + while len(figures_of_merit_list) < total_points: + + if not use_prior_medians: + unit_parameter_list = self._generate_unit_parameter_list(model) + else: + unit_parameter_list = [0.5] * model.prior_count + + parameter_list = model.vector_from_unit_vector( + unit_vector=unit_parameter_list + ) + + figure_of_merit = self.figure_of_metric((fitness, parameter_list)) + + if figure_of_merit is not None: + unit_parameter_lists.append(unit_parameter_list) + parameter_lists.append(parameter_list) + figures_of_merit_list.append(figure_of_merit) + + if total_points > 1 and np.allclose( + a=figures_of_merit_list[0], b=figures_of_merit_list[1:] + ): + raise exc.InitializerException( + """ + The initial samples all have the same figure of merit (e.g. log likelihood values). + + The non-linear search will therefore not progress correctly. + + Possible causes for this behaviour are: + + - The `log_likelihood_function` of the analysis class is defined incorrectly. + - The model parameterization creates numerically inaccurate log likelihoods. + - The`log_likelihood_function` is always returning `nan` values. + """ + ) + + logger.info(f"Initial samples generated, starting non-linear search") + + return unit_parameter_lists, parameter_lists, figures_of_merit_list + + def samples_in_test_mode(self, total_points: int, model: AbstractPriorModel): + """ + Generate the initial points of the non-linear search in test mode. Like normal, test model draws points, by + randomly drawing unit values from a uniform distribution between the ball_lower_limit and ball_upper_limit + values. + + However, the log likelihood function is bypassed and all likelihoods are returned with a value -1.0e99. This + is so that integration testing of large-scale model-fitting projects can be performed efficiently by bypassing + sampling of points using the `log_likelihood_function`. + + Parameters + ---------- + total_points + The number of points in non-linear paramemter space which initial points are created for. + model + An object that represents possible instances of some model with a given dimensionality which is the number + of free dimensions of the model. + """ + + logger.warning( + "TEST MODE 1 (reduced iterations): Initial samples assigned " + "arbitrary large likelihoods to accelerate sampler convergence." + ) + + unit_parameter_lists = [] + parameter_lists = [] + figure_of_merit_list = [] + + point_index = 0 + + figure_of_merit = -1.0e99 + + while point_index < total_points: + try: + unit_parameter_list = self._generate_unit_parameter_list(model) + parameter_list = model.vector_from_unit_vector( + unit_vector=unit_parameter_list + ) + model.instance_from_vector(vector=parameter_list) + unit_parameter_lists.append(unit_parameter_list) + parameter_lists.append(parameter_list) + figure_of_merit_list.append(figure_of_merit) + figure_of_merit *= 10.0 + point_index += 1 + except exc.FitException: + pass + + return unit_parameter_lists, parameter_lists, figure_of_merit_list + + +class InitializerParamBounds(AbstractInitializer): + def __init__( + self, + parameter_dict: Dict[Prior, Tuple[float, float]], + lower_limit=0.0, + upper_limit=1.0, + ): + """ + Initializer which uses the bounds on input parameters as the starting point for the search (e.g. where + an MLE optimization starts or MCMC walkers are initialized). + + Parameters + ---------- + parameter_dict + A dictionary mapping each parameter path to bounded ranges of physical values that + are where the search begins. + lower_limit + A default, unit lower limit used when a prior is not specified + upper_limit + A default, unit upper limit used when a prior is not specified + """ + + self.parameter_dict = parameter_dict + self.lower_limit = lower_limit + self.upper_limit = upper_limit + + self._generated_warnings = set() + + def _generate_unit_parameter_list(self, model: AbstractPriorModel) -> List[float]: + """ + Generate a unit vector for the model. The default limits are used for any + priors which the model has but are not found in the parameter dict. + + Parameters + ---------- + model + A model for which initial points are required + + Returns + ------- + A unit vector + """ + + unit_parameter_list = [] + for prior in model.priors_ordered_by_id: + + try: + lower, upper = map(prior.unit_value_for, self.parameter_dict[prior]) + value = random.uniform(lower, upper) + except KeyError: + key = ".".join(model.path_for_prior(prior)) + if key not in self._generated_warnings: + logger.warning( + f"Range for {key} not set in the InitializerParamBounds. " + f"Using defaults." + ) + self._generated_warnings.add(key) + + lower = self.lower_limit + upper = self.upper_limit + + value = prior.unit_value_for(prior.random(lower, upper)) + + unit_parameter_list.append(value) + + return unit_parameter_list + + def info_from_model(self, model : AbstractPriorModel) -> str: + """ + Returns a string showing the bounds of the parameters in the initializer. + """ + info = "Total Free Parameters = " + str(model.prior_count) + "\n" + info += "Total Starting Points = " + str(len(self.parameter_dict)) + "\n\n" + for prior in model.priors_ordered_by_id: + + key = ".".join(model.path_for_prior(prior)) + + try: + + value = self.info_value_from(self.parameter_dict[prior]) + + info += f"{key}: Start[{value}]\n" + + except KeyError: + + info += f"{key}: {prior})\n" + + return info + + def info_value_from(self, value : Tuple[float, float]) -> Tuple[float, float]: + """ + Returns the value that is used to display the bounds of the parameters in the initializer. + + This function simply returns the input value, but it can be overridden in subclasses for diffferent + initializers. + + Parameters + ---------- + value + The value to be displayed in the initializer info which is a tuple of the lower and upper bounds of the + parameter. + """ + return value + + +class InitializerParamStartPoints(InitializerParamBounds): + def __init__( + self, + parameter_dict: Dict[Prior, float], + ): + """ + Initializer which input values of the parameters as the starting point for the search (e.g. where + an MLE optimization starts or MCMC walkers are initialized). + + Parameters + ---------- + parameter_dict + A dictionary mapping each parameter path to the starting point physical values that + are where the search begins. + lower_limit + A default, unit lower limit used when a prior is not specified + upper_limit + A default, unit upper limit used when a prior is not specified + """ + parameter_dict_new = {} + + for key, value in parameter_dict.items(): + parameter_dict_new[key] = (value - 1.0e-8, value + 1.0e-8) + + super().__init__(parameter_dict=parameter_dict_new) + + def info_value_from(self, value : Tuple[float, float]) -> float: + """ + Returns the value that is used to display the starting point of the parameters in the initializer. + + This function returns the mean of the input value, as the starting point is a single value in the center of the + bounds. + + Parameters + ---------- + value + The value to be displayed in the initializer info which is a tuple of the lower and upper bounds of the + parameter. + """ + return (value[1] + value[0]) / 2.0 + + +class Initializer(AbstractInitializer): + def __init__(self, lower_limit: float, upper_limit: float): + """ + The Initializer creates the initial set of samples in non-linear parameter space that can be passed into a + `NonLinearSearch` to define where to begin sampling. + + Although most non-linear searches have in-built functionality to do this, some do not cope well with parameter + resamples that are raised as FitException's. Thus, PyAutoFit uses its own initializer to bypass these problems. + """ + self.lower_limit = lower_limit + self.upper_limit = upper_limit + + @classmethod + def from_config(cls, config): + """ + Load the Initializer from a non_linear config file. + """ + + try: + initializer = config("initialize", "method") + + except configparser.NoSectionError: + return None + + if initializer in "prior": + return InitializerPrior() + + elif initializer in "ball": + ball_lower_limit = config("initialize", "ball_lower_limit") + ball_upper_limit = config("initialize", "ball_upper_limit") + + return InitializerBall( + lower_limit=ball_lower_limit, upper_limit=ball_upper_limit + ) + + def _generate_unit_parameter_list(self, model): + return model.random_unit_vector_within_limits( + lower_limit=self.lower_limit, upper_limit=self.upper_limit + ) + + +class InitializerPrior(Initializer): + def __init__(self): + """ + The Initializer creates the initial set of samples in non-linear parameter space that can be passed into a + `NonLinearSearch` to define where to begin sampling. + + Although most non-linear searches have in-built functionality to do this, some do not cope well with parameter + resamples that are raised as FitException's. Thus, PyAutoFit uses its own initializer to bypass these problems. + + The InitializerPrior class generates from the priors, by drawing all values as unit values between 0.0 and 1.0 + and mapping them to physical values via the prior. + """ + super().__init__(lower_limit=0.0, upper_limit=1.0) + + +class InitializerBall(Initializer): + def __init__(self, lower_limit: float, upper_limit: float): + """ + The Initializer creates the initial set of samples in non-linear parameter space that can be passed into a + `NonLinearSearch` to define where to begin sampling. + + Although most non-linear searches have in-built functionality to do this, some do not cope well with parameter + resamples that are raised as FitException's. Thus, PyAutoFit uses its own initializer to bypass these problems. + + The InitializerBall class generates the samples in a small compact volume or 'ball' in parameter space, which is + the recommended initialization strategy for the MCMC `NonLinearSearch` Emcee. + + Parameters + ---------- + lower_limit + The lower limit of the uniform distribution unit values are drawn from when initializing walkers in a small + compact ball. + upper_limit + The upper limit of the uniform distribution unit values are drawn from when initializing walkers in a small + compact ball. + """ + super().__init__(lower_limit=lower_limit, upper_limit=upper_limit) diff --git a/autofit/non_linear/mock/mock_analysis.py b/autofit/non_linear/mock/mock_analysis.py index 80bd97459..f0f960eca 100644 --- a/autofit/non_linear/mock/mock_analysis.py +++ b/autofit/non_linear/mock/mock_analysis.py @@ -1,34 +1,34 @@ -from autofit.non_linear.analysis import Analysis -from autofit.non_linear.samples import Sample - -def samples_with_log_likelihood_list( - log_likelihood_list -): - return [ - Sample( - log_likelihood=log_likelihood, - log_prior=0, - weight=0 - ) - for log_likelihood - in log_likelihood_list - ] - - -class MockAnalysis(Analysis): - prior_count = 2 - - def __init__(self): - super().__init__() - self.fit_instances = list() - - def log_likelihood_function(self, instance): - self.fit_instances.append(instance) - return [1] - - def visualize(self, paths, instance, during_analysis): - pass - - def log(self, instance): - pass - +from autofit.non_linear.analysis import Analysis +from autofit.non_linear.samples import Sample + +def samples_with_log_likelihood_list( + log_likelihood_list +): + return [ + Sample( + log_likelihood=log_likelihood, + log_prior=0, + weight=0 + ) + for log_likelihood + in log_likelihood_list + ] + + +class MockAnalysis(Analysis): + prior_count = 2 + + def __init__(self): + super().__init__() + self.fit_instances = list() + + def log_likelihood_function(self, instance): + self.fit_instances.append(instance) + return [1] + + def visualize(self, paths, instance, during_analysis): + pass + + def log(self, instance): + pass + diff --git a/autofit/non_linear/mock/mock_result.py b/autofit/non_linear/mock/mock_result.py index d220a1382..b38a3021e 100644 --- a/autofit/non_linear/mock/mock_result.py +++ b/autofit/non_linear/mock/mock_result.py @@ -1,78 +1,78 @@ -from autofit.mapper.model import ModelInstance -from autofit.mapper.model_mapper import ModelMapper -from autofit.non_linear.result import Result - -from autofit.non_linear.mock.mock_samples_summary import MockSamplesSummary -from autofit.non_linear.mock.mock_samples import MockSamples - - -class MockResult(Result): - def __init__( - self, - samples_summary: MockSamplesSummary = None, - paths=None, - samples=None, - instance=None, - analysis=None, - search=None, - model=None, - ): - super().__init__( - samples_summary=samples_summary - or MockSamplesSummary( - model=model or ModelMapper(), - ), - paths=paths, - samples=samples, - search_internal=None, - ) - - self._instance = instance or ModelInstance() - self._samples = samples or MockSamples( - model=model - or ModelMapper() - ) - - self.prior_means = None - self.analysis = analysis - self.search = search - self.model = model - - @property - def model_centred(self): - try: - return self.samples_summary.model_centred - except AttributeError: - return self.model - - def model_centred_absolute(self, a): - try: - return self.samples_summary.model_centred_absolute(a) - except AttributeError: - return self.model - - def model_centred_relative(self, r): - try: - return self.samples_summary.model_centred_relative(r) - except AttributeError: - return self.model - - @property - def last(self): - return self - - -class MockResultGrid(Result): - def __init__(self, log_likelihood): - # noinspection PyTypeChecker - super().__init__(None, None) - self._log_likelihood = log_likelihood - self.model = log_likelihood - - @property - def log_likelihood(self): - return self._log_likelihood - - @property - def best_model(self): - return self.model +from autofit.mapper.model import ModelInstance +from autofit.mapper.model_mapper import ModelMapper +from autofit.non_linear.result import Result + +from autofit.non_linear.mock.mock_samples_summary import MockSamplesSummary +from autofit.non_linear.mock.mock_samples import MockSamples + + +class MockResult(Result): + def __init__( + self, + samples_summary: MockSamplesSummary = None, + paths=None, + samples=None, + instance=None, + analysis=None, + search=None, + model=None, + ): + super().__init__( + samples_summary=samples_summary + or MockSamplesSummary( + model=model or ModelMapper(), + ), + paths=paths, + samples=samples, + search_internal=None, + ) + + self._instance = instance or ModelInstance() + self._samples = samples or MockSamples( + model=model + or ModelMapper() + ) + + self.prior_means = None + self.analysis = analysis + self.search = search + self.model = model + + @property + def model_centred(self): + try: + return self.samples_summary.model_centred + except AttributeError: + return self.model + + def model_centred_absolute(self, a): + try: + return self.samples_summary.model_centred_absolute(a) + except AttributeError: + return self.model + + def model_centred_relative(self, r): + try: + return self.samples_summary.model_centred_relative(r) + except AttributeError: + return self.model + + @property + def last(self): + return self + + +class MockResultGrid(Result): + def __init__(self, log_likelihood): + # noinspection PyTypeChecker + super().__init__(None, None) + self._log_likelihood = log_likelihood + self.model = log_likelihood + + @property + def log_likelihood(self): + return self._log_likelihood + + @property + def best_model(self): + return self.model diff --git a/autofit/non_linear/mock/mock_samples.py b/autofit/non_linear/mock/mock_samples.py index a32e6442c..14f89fe85 100644 --- a/autofit/non_linear/mock/mock_samples.py +++ b/autofit/non_linear/mock/mock_samples.py @@ -1,77 +1,77 @@ -from autofit.non_linear.samples import SamplesPDF, Sample, SamplesNest - - -def samples_with_log_likelihood_list(log_likelihood_list): - return [ - Sample(log_likelihood=log_likelihood, log_prior=0, weight=0) - for log_likelihood in log_likelihood_list - ] - - -class MockSamples(SamplesPDF): - def __init__( - self, - model=None, - sample_list=None, - samples_info=None, - log_likelihood_list=None, - prior_means=None, - **kwargs, - ): - self._log_likelihood_list = log_likelihood_list - - self.model = model - - sample_list = sample_list or self.default_sample_list - - samples_info = samples_info or {"unconverged_sample_size": 0} - - super().__init__( - model=model, - sample_list=sample_list, - samples_info=samples_info, - **kwargs, - ) - - @property - def default_sample_list(self): - return [ - Sample( - log_likelihood=log_likelihood, - log_prior=0.0, - weight=0.0, - kwargs={path: 1.0 for path in self.model.paths} if self.model else {}, - ) - for log_likelihood in range(3) - ] - - @property - def log_likelihood_list(self): - if self._log_likelihood_list is None: - return super().log_likelihood_list - - return self._log_likelihood_list - - @property - def unconverged_sample_size(self): - return self.samples_info["unconverged_sample_size"] - - -class MockSamplesNest(SamplesNest): - def __init__( - self, - model, - sample_list=None, - samples_info=None, - ): - self.model = model - - if sample_list is None: - sample_list = [ - Sample(log_likelihood=log_likelihood, log_prior=0.0, weight=0.0) - for log_likelihood in self.log_likelihood_list - ] - - super().__init__( - model=model, sample_list=sample_list, samples_info=samples_info - ) +from autofit.non_linear.samples import SamplesPDF, Sample, SamplesNest + + +def samples_with_log_likelihood_list(log_likelihood_list): + return [ + Sample(log_likelihood=log_likelihood, log_prior=0, weight=0) + for log_likelihood in log_likelihood_list + ] + + +class MockSamples(SamplesPDF): + def __init__( + self, + model=None, + sample_list=None, + samples_info=None, + log_likelihood_list=None, + prior_means=None, + **kwargs, + ): + self._log_likelihood_list = log_likelihood_list + + self.model = model + + sample_list = sample_list or self.default_sample_list + + samples_info = samples_info or {"unconverged_sample_size": 0} + + super().__init__( + model=model, + sample_list=sample_list, + samples_info=samples_info, + **kwargs, + ) + + @property + def default_sample_list(self): + return [ + Sample( + log_likelihood=log_likelihood, + log_prior=0.0, + weight=0.0, + kwargs={path: 1.0 for path in self.model.paths} if self.model else {}, + ) + for log_likelihood in range(3) + ] + + @property + def log_likelihood_list(self): + if self._log_likelihood_list is None: + return super().log_likelihood_list + + return self._log_likelihood_list + + @property + def unconverged_sample_size(self): + return self.samples_info["unconverged_sample_size"] + + +class MockSamplesNest(SamplesNest): + def __init__( + self, + model, + sample_list=None, + samples_info=None, + ): + self.model = model + + if sample_list is None: + sample_list = [ + Sample(log_likelihood=log_likelihood, log_prior=0.0, weight=0.0) + for log_likelihood in self.log_likelihood_list + ] + + super().__init__( + model=model, sample_list=sample_list, samples_info=samples_info + ) diff --git a/autofit/non_linear/mock/mock_samples_summary.py b/autofit/non_linear/mock/mock_samples_summary.py index fddc5035c..509c9c493 100644 --- a/autofit/non_linear/mock/mock_samples_summary.py +++ b/autofit/non_linear/mock/mock_samples_summary.py @@ -1,72 +1,72 @@ -from autofit.mapper.prior_model.collection import Collection -from autofit.non_linear.samples.sample import Sample -from autofit.non_linear.samples.summary import SamplesSummary - - -class MockSamplesSummary(SamplesSummary): - def __init__( - self, - model=None, - max_log_likelihood_sample=None, - median_pdf_sample=None, - log_evidence=None, - max_log_likelihood_instance=None, - prior_means=None, - **kwargs, - ): - super().__init__( - model=model, - max_log_likelihood_sample=max_log_likelihood_sample, - median_pdf_sample=median_pdf_sample, - log_evidence=log_evidence, - ) - - self._max_log_likelihood_instance = max_log_likelihood_instance - self._prior_means = prior_means - self._kwargs = {path: 1.0 for path in self.model.paths} if self.model else {} - - @property - def max_log_likelihood_sample(self): - if self._max_log_likelihood_sample is not None: - return self._max_log_likelihood_sample - - return Sample( - log_likelihood=1.0, - log_prior=0.0, - weight=0.0, - kwargs=self._kwargs, - ) - - @property - def median_pdf_sample(self): - if self._median_pdf_sample is not None: - return self._median_pdf_sample - - return Sample( - log_likelihood=1.0, - log_prior=0.0, - weight=0.0, - kwargs=self._kwargs, - ) - - def max_log_likelihood(self, as_instance: bool = True): - if self._max_log_likelihood_instance is None: - try: - return super().max_log_likelihood(as_instance=as_instance) - except (KeyError, AttributeError): - pass - - return self._max_log_likelihood_instance - - @property - def prior_means(self): - if self._prior_means is None: - return super().prior_means - - return self._prior_means - - @classmethod - def default(cls): - return MockSamplesSummary( - model=Collection(), - ) +from autofit.mapper.prior_model.collection import Collection +from autofit.non_linear.samples.sample import Sample +from autofit.non_linear.samples.summary import SamplesSummary + + +class MockSamplesSummary(SamplesSummary): + def __init__( + self, + model=None, + max_log_likelihood_sample=None, + median_pdf_sample=None, + log_evidence=None, + max_log_likelihood_instance=None, + prior_means=None, + **kwargs, + ): + super().__init__( + model=model, + max_log_likelihood_sample=max_log_likelihood_sample, + median_pdf_sample=median_pdf_sample, + log_evidence=log_evidence, + ) + + self._max_log_likelihood_instance = max_log_likelihood_instance + self._prior_means = prior_means + self._kwargs = {path: 1.0 for path in self.model.paths} if self.model else {} + + @property + def max_log_likelihood_sample(self): + if self._max_log_likelihood_sample is not None: + return self._max_log_likelihood_sample + + return Sample( + log_likelihood=1.0, + log_prior=0.0, + weight=0.0, + kwargs=self._kwargs, + ) + + @property + def median_pdf_sample(self): + if self._median_pdf_sample is not None: + return self._median_pdf_sample + + return Sample( + log_likelihood=1.0, + log_prior=0.0, + weight=0.0, + kwargs=self._kwargs, + ) + + def max_log_likelihood(self, as_instance: bool = True): + if self._max_log_likelihood_instance is None: + try: + return super().max_log_likelihood(as_instance=as_instance) + except (KeyError, AttributeError): + pass + + return self._max_log_likelihood_instance + + @property + def prior_means(self): + if self._prior_means is None: + return super().prior_means + + return self._prior_means + + @classmethod + def default(cls): + return MockSamplesSummary( + model=Collection(), + ) diff --git a/autofit/non_linear/parallel/__init__.py b/autofit/non_linear/parallel/__init__.py index bc52b22e2..ade7cf9db 100644 --- a/autofit/non_linear/parallel/__init__.py +++ b/autofit/non_linear/parallel/__init__.py @@ -1,6 +1,6 @@ -from .process import AbstractJob -from .process import AbstractJobResult -from .process import Process -from .sneaky import SneakyJob -from .sneaky import SneakyPool -from .sneaky import SneakierPool +from .process import AbstractJob +from .process import AbstractJobResult +from .process import Process +from .sneaky import SneakyJob +from .sneaky import SneakyPool +from .sneaky import SneakierPool diff --git a/autofit/non_linear/paths/directory.py b/autofit/non_linear/paths/directory.py index 60497f7ee..394846970 100644 --- a/autofit/non_linear/paths/directory.py +++ b/autofit/non_linear/paths/directory.py @@ -1,540 +1,540 @@ -import shutil - -import dill -import json -import numpy as np -import os -from pathlib import Path -from typing import Optional, Union, cast, Type -import logging - -from autonerves import conf -from autonerves.class_path import get_class -from autonerves.dictable import to_dict, from_dict -from autonerves.output import conditional_output, should_output -from autofit.text import formatter -from autofit.tools.util import open_ -from autofit.non_linear.samples.samples import Samples - -from .abstract import AbstractPaths, _test_mode_segment - -from ..samples import load_from_table -from autofit.non_linear.samples.pdf import SamplesPDF -from autofit.non_linear.samples.summary import SamplesSummary - -from ...visualise import VisualiseGraph - -logger = logging.getLogger(__name__) - - -class DirectoryPaths(AbstractPaths): - def _path_for_pickle(self, name: str, prefix: str = "") -> Path: - return self._files_path / prefix / f"{name}.pickle" - - def _path_for_json(self, name, prefix: str = "") -> Path: - if isinstance(name, Path): - return name - return self._files_path / prefix / f"{name}.json" - - def _path_for_csv(self, name) -> Path: - return self._files_path / f"{name}.csv" - - def _path_for_fits(self, name, prefix: str = "") -> Path: - os.makedirs(self._files_path / prefix, exist_ok=True) - - return self._files_path / prefix / f"{name}.fits" - - @conditional_output - def save_object(self, name: str, obj: object, prefix: str = ""): - """ - Serialise an object using dill and save it to the pickles - directory of the search. - - Parameters - ---------- - name - The name of the object - obj - A serialisable object - prefix - A prefix to add to the path which is the name of the folder the file is saved in. - """ - with open_(self._path_for_pickle(name, prefix), "wb") as f: - dill.dump(obj, f) - - @conditional_output - def save_json(self, name, object_dict: Union[dict, list], prefix: str = ""): - """ - Save a dictionary as a json file in the jsons directory of the search. - - Parameters - ---------- - name - The name of the json file - object_dict - The dictionary to save - prefix - A prefix to add to the path which is the name of the folder the file is saved in. - """ - with open_(self._path_for_json(name, prefix), "w+") as f: - json.dump(object_dict, f, indent=4) - - def load_json(self, name, prefix: str = ""): - with open_(self._path_for_json(name, prefix)) as f: - return json.load(f) - - @conditional_output - def save_array(self, name: str, array: np.ndarray): - """ - Save a numpy array as a csv file in the csvs directory of the search. - - Parameters - ---------- - name - The name of the csv file - array - The numpy array to save - """ - # noinspection PyTypeChecker - np.savetxt(self._path_for_csv(name), array, delimiter=",") - - def load_array(self, name: str): - return np.loadtxt(self._path_for_csv(name), delimiter=",") - - @conditional_output - def save_fits(self, name: str, fits, prefix: str = ""): - """ - Save an HDU as a fits file in the fits directory of the search. - - Parameters - ---------- - name - The name of the fits file - fits - The HDUList to save - prefix - A prefix to add to the path which is the name of the folder the file is saved in. - """ - fits.writeto(self._path_for_fits(name, prefix), overwrite=True) - - def load_fits(self, name: str, prefix: str = ""): - """ - Load an HDU from a fits file in the fits directory of the search. - - Parameters - ---------- - name - The name of the fits file - prefix - A prefix to add to the path which is the name of the folder the file is saved in. - - Returns - ------- - The loaded HDU. - """ - from astropy.io import fits - - return fits.open(self._path_for_fits(name, prefix))[0] - - def load_object(self, name: str, prefix: str = ""): - """ - Load a serialised object with the given name. - - e.g. if the name is 'model' then pickles/model.pickle is loaded. - - Parameters - ---------- - name - The name of a serialised object - prefix - A prefix to add to the path which is the name of the folder the file is saved in. - - Returns - ------- - The deserialised object - """ - with open_(self._path_for_pickle(name, prefix), "rb") as f: - return dill.load(f) - - def remove_object(self, name: str): - """ - Remove the object with the given name from the pickles folder. - - Parameters - ---------- - name - The name of a pickle file excluding .pickle - """ - try: - os.remove(self._path_for_pickle(name)) - except FileNotFoundError: - pass - - def is_object(self, name: str) -> bool: - """ - Is there a file pickles/{name}.pickle? - """ - return self._path_for_pickle(name).exists() - - @property - def is_complete(self) -> bool: - """ - Has the search been completed? - """ - return self._has_completed_path.exists() - - def save_search_internal(self, obj): - """ - Save the internal representation of a non-linear search as dill file. - - The results in this representation are required to use a search's in-built tools for visualization, - analysing samples and other tasks. - """ - filename = self.search_internal_path / "search_internal.dill" - - with open_(filename, "wb") as f: - dill.dump(obj, f) - - def load_search_internal(self): - """ - Load the internal representation of a non-linear search from a pickle or dill file. - - The results in this representation are required to use a search's in-built tools for visualization, - analysing samples and other tasks. - - Returns - ------- - The results of the non-linear search in its internal representation. - """ - - # This is a nasty hack to load emcee backends. It will be removed once the source code is more stable. - - try: - import emcee - - backend_filename = self.search_internal_path / "search_internal.hdf" - if backend_filename.is_file(): - return emcee.backends.HDFBackend(filename=str(backend_filename)) - except ImportError: - pass - - filename = self.search_internal_path / "search_internal.dill" - - with open_(filename, "rb") as f: - return dill.load(f) - - def remove_search_internal(self): - """ - Remove the internal representation of a non-linear search. - - This deletes the entire `search_internal` folder, including a .pickle / .dill file containing the interal - results and files with the timer values. - - This folder can often have a large filesize, thus deleting it can reduce hard-disk use of the model-fit. - """ - shutil.rmtree(self.search_internal_path) - - def completed(self): - """ - Mark the search as complete by saving a file - """ - open_(self._has_completed_path, "w+").close() - - def load_samples(self): - return load_from_table(filename=self._samples_file) - - @property - def samples(self): - """ - Load the samples associated with the search from the output directory. - """ - sample_list = self.load_samples() - samples_info = self.load_samples_info() - - cls = cast(Type[Samples], get_class(samples_info["class_path"])) - - return cls.from_list_info_and_model( - sample_list=sample_list, - samples_info=samples_info, - model=self.model, - ) - - def save_latent_samples( - self, - latent_samples, - ): - """ - Write out the latent variables of the model to a file. - - Parameters - ---------- - latent_samples - Samples describing the latent variables of the model - """ - self._save_samples(latent_samples, name="latent") - - def save_samples(self, samples): - """ - Save the final-result samples associated with the phase as a pickle - """ - self._save_samples(samples) - - def _save_samples(self, samples, name=None): - """ - Save the final-result samples associated with the phase as a pickle - """ - - if name is not None: - directory = self._files_path / name - else: - directory = self._files_path - name = "samples" - if conf.instance["general"]["output"]["samples_to_csv"] and should_output(name): - self.save_json(directory / "samples_info.json", samples.samples_info) - - if isinstance(samples, SamplesPDF): - try: - samples.save_covariance_matrix(directory / "covariance.csv") - except (ValueError, ZeroDivisionError) as e: - logger.warning( - f"Could not save covariance matrix because of the following error:\n{e}" - ) - - samples.write_table(filename=directory / "samples.csv") - - def save_samples_summary( - self, samples_summary: SamplesSummary, name="samples_summary" - ): - model = samples_summary.model - - filter_args = tuple( - name - for name in ( - "errors_at_sigma_1", - "errors_at_sigma_3", - "values_at_sigma_1", - "values_at_sigma_3", - "max_log_likelihood_sample", - "median_pdf_sample", - ) - if not should_output(name) - ) - - samples_summary.model = None - self.save_json( - name, - to_dict( - samples_summary, - filter_args=filter_args, - ), - ) - samples_summary.model = model - - def load_samples_summary(self) -> SamplesSummary: - samples_summary = from_dict(self.load_json(name="samples_summary")) - samples_summary.model = self.model - - return samples_summary - - def load_latent_samples(self): - return load_from_table(filename=self._files_path / "latent/samples.csv") - - def load_samples_info(self): - with open_(self._info_file) as infile: - return json.load(infile) - - def save_all(self, search_config_dict=None, info=None): - info = info or {} - - self.save_identifier() - self.save_parent_identifier() - self._save_model_info(model=self.model) - # VisualiseGraph( - # model=self.model, - # ).save(str(self.output_path / "model_graph.html")) - - if info: - self.save_json("info", info) - - self.save_json("search", to_dict(self.search)) - try: - info_start = self.search.initializer.info_from_model(model=self.model) - self._save_model_start_point(info=info_start) - except (NotImplementedError, AttributeError): - pass - - self.save_json("model", to_dict(self.model)) - self._save_metadata(search_name=type(self.search).__name__.lower()) - - @AbstractPaths.parent.setter - def parent(self, parent: AbstractPaths): - """ - The search performed before this search. For example, a search - that is then compared to searches during a grid search. - """ - self._parent = parent - - def save_parent_identifier(self): - if self.parent is not None: - with open_(self._parent_identifier_path, "w+") as f: - f.write(self.parent.identifier) - self.parent.save_unique_tag() - - def save_unique_tag(self, is_grid_search=False): - if is_grid_search: - with open_(self._grid_search_path, "w+") as f: - if self.unique_tag is not None: - f.write(self.unique_tag) - - @property - def _parent_identifier_path(self) -> Path: - return self.output_path / ".parent_identifier" - - @property - def _grid_search_path(self) -> Path: - return self.output_path / ".is_grid_search" - - @property - def is_grid_search(self) -> bool: - """ - Is this a grid search which comprises a number of child searches? - """ - return self._grid_search_path.exists() - - def create_child( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - is_identifier_in_paths: Optional[bool] = None, - identifier: Optional[str] = None, - ) -> "AbstractPaths": - """ - Create a paths object which is the child of some parent - paths object. This is done during a GridSearch so that - results can be stored in the correct directory. - - Parameters - ---------- - name - path_prefix - is_identifier_in_paths - If False then this path's identifier will not be - added to its output path. - identifier - - Returns - ------- - A new paths object - """ - child = type(self)( - name=name or self.name, - path_prefix=path_prefix or self.path_prefix, - is_identifier_in_paths=( - is_identifier_in_paths - if is_identifier_in_paths is not None - else self.is_identifier_in_paths - ), - parent=self, - ) - child.model = self.model - child.search = self.search - child._identifier = identifier - return child - - def for_sub_analysis(self, analysis_name: str): - """ - Paths for an analysis which is a child of another analysis. - - The analysis name forms a new directory on the end of the original - analysis output path. - """ - from .sub_directory_paths import SubDirectoryPaths - - return SubDirectoryPaths(parent=self, analysis_name=analysis_name) - - def _save_metadata(self, search_name): - """ - Save metadata associated with the phase, such as the name of the pipeline, the - name of the phase and the name of the dataset being fit - """ - with open_(self.output_path / "metadata", "a") as f: - f.write( - f"""name={self.name}\nnon_linear_search={search_name} - """ - ) - - def _save_model_info(self, model): - """ - Save the model.info file, which summarises every parameter and prior. - """ - with open_(self.output_path / "model.info", "w+") as f: - f.write(model.info) - - if should_output("model_graph") and hasattr(model, "graph_info"): - with open_(self.output_path / "model.graph", "w+") as f: - f.write(model.graph_info) - - def _save_model_start_point(self, info): - """ - Save the model.start file, which summarises the start point of every parameter. - """ - with open_(self.output_path / "model.start", "w+") as f: - f.write(info) - - def _save_parameter_names_file(self, model): - """ - Create the param_names file listing every parameter's label and Latex tag, which is used for corner.py - visualization. - - The parameter labels are determined using the label.ini and label_format.ini config files. - """ - - parameter_names = model.model_component_and_parameter_names - parameter_labels = model.parameter_labels - subscripts = model.superscripts_overwrite_via_config - parameter_labels_with_subscript = [ - f"{label}_{subscript}" - for label, subscript in zip(parameter_labels, subscripts) - ] - - parameter_name_and_label = [] - - for i in range(model.prior_count): - line = formatter.add_whitespace( - str0=parameter_names[i], - str1=parameter_labels_with_subscript[i], - whitespace=70, - ) - parameter_name_and_label += [f"{line}\n"] - - formatter.output_list_of_strings_to_file( - file=self._files_path / "model.paramnames", - list_of_strings=parameter_name_and_label, - ) - - @property - def _info_file(self) -> Path: - return self._files_path / "samples_info.json" - - @property - def _has_completed_path(self) -> Path: - """ - A file indicating that a `NonLinearSearch` has been completed previously - """ - return self.output_path / ".completed" - - def _make_path(self) -> str: - """ - Returns the path to the folder at which the metadata should be saved - - The path terminates with the identifier, unless the identifier has already - been added to the path. - """ - path_ = Path(conf.instance.output_path) - segment = _test_mode_segment() - if segment: - path_ = path_ / segment - path_ = path_ / self.path_prefix / self.name - if self.is_identifier_in_paths: - path_ = path_ / self.identifier - return path_ +import shutil + +import dill +import json +import numpy as np +import os +from pathlib import Path +from typing import Optional, Union, cast, Type +import logging + +from autonerves import conf +from autonerves.class_path import get_class +from autonerves.dictable import to_dict, from_dict +from autonerves.output import conditional_output, should_output +from autofit.text import formatter +from autofit.tools.util import open_ +from autofit.non_linear.samples.samples import Samples + +from .abstract import AbstractPaths, _test_mode_segment + +from ..samples import load_from_table +from autofit.non_linear.samples.pdf import SamplesPDF +from autofit.non_linear.samples.summary import SamplesSummary + +from ...visualise import VisualiseGraph + +logger = logging.getLogger(__name__) + + +class DirectoryPaths(AbstractPaths): + def _path_for_pickle(self, name: str, prefix: str = "") -> Path: + return self._files_path / prefix / f"{name}.pickle" + + def _path_for_json(self, name, prefix: str = "") -> Path: + if isinstance(name, Path): + return name + return self._files_path / prefix / f"{name}.json" + + def _path_for_csv(self, name) -> Path: + return self._files_path / f"{name}.csv" + + def _path_for_fits(self, name, prefix: str = "") -> Path: + os.makedirs(self._files_path / prefix, exist_ok=True) + + return self._files_path / prefix / f"{name}.fits" + + @conditional_output + def save_object(self, name: str, obj: object, prefix: str = ""): + """ + Serialise an object using dill and save it to the pickles + directory of the search. + + Parameters + ---------- + name + The name of the object + obj + A serialisable object + prefix + A prefix to add to the path which is the name of the folder the file is saved in. + """ + with open_(self._path_for_pickle(name, prefix), "wb") as f: + dill.dump(obj, f) + + @conditional_output + def save_json(self, name, object_dict: Union[dict, list], prefix: str = ""): + """ + Save a dictionary as a json file in the jsons directory of the search. + + Parameters + ---------- + name + The name of the json file + object_dict + The dictionary to save + prefix + A prefix to add to the path which is the name of the folder the file is saved in. + """ + with open_(self._path_for_json(name, prefix), "w+") as f: + json.dump(object_dict, f, indent=4) + + def load_json(self, name, prefix: str = ""): + with open_(self._path_for_json(name, prefix)) as f: + return json.load(f) + + @conditional_output + def save_array(self, name: str, array: np.ndarray): + """ + Save a numpy array as a csv file in the csvs directory of the search. + + Parameters + ---------- + name + The name of the csv file + array + The numpy array to save + """ + # noinspection PyTypeChecker + np.savetxt(self._path_for_csv(name), array, delimiter=",") + + def load_array(self, name: str): + return np.loadtxt(self._path_for_csv(name), delimiter=",") + + @conditional_output + def save_fits(self, name: str, fits, prefix: str = ""): + """ + Save an HDU as a fits file in the fits directory of the search. + + Parameters + ---------- + name + The name of the fits file + fits + The HDUList to save + prefix + A prefix to add to the path which is the name of the folder the file is saved in. + """ + fits.writeto(self._path_for_fits(name, prefix), overwrite=True) + + def load_fits(self, name: str, prefix: str = ""): + """ + Load an HDU from a fits file in the fits directory of the search. + + Parameters + ---------- + name + The name of the fits file + prefix + A prefix to add to the path which is the name of the folder the file is saved in. + + Returns + ------- + The loaded HDU. + """ + from astropy.io import fits + + return fits.open(self._path_for_fits(name, prefix))[0] + + def load_object(self, name: str, prefix: str = ""): + """ + Load a serialised object with the given name. + + e.g. if the name is 'model' then pickles/model.pickle is loaded. + + Parameters + ---------- + name + The name of a serialised object + prefix + A prefix to add to the path which is the name of the folder the file is saved in. + + Returns + ------- + The deserialised object + """ + with open_(self._path_for_pickle(name, prefix), "rb") as f: + return dill.load(f) + + def remove_object(self, name: str): + """ + Remove the object with the given name from the pickles folder. + + Parameters + ---------- + name + The name of a pickle file excluding .pickle + """ + try: + os.remove(self._path_for_pickle(name)) + except FileNotFoundError: + pass + + def is_object(self, name: str) -> bool: + """ + Is there a file pickles/{name}.pickle? + """ + return self._path_for_pickle(name).exists() + + @property + def is_complete(self) -> bool: + """ + Has the search been completed? + """ + return self._has_completed_path.exists() + + def save_search_internal(self, obj): + """ + Save the internal representation of a non-linear search as dill file. + + The results in this representation are required to use a search's in-built tools for visualization, + analysing samples and other tasks. + """ + filename = self.search_internal_path / "search_internal.dill" + + with open_(filename, "wb") as f: + dill.dump(obj, f) + + def load_search_internal(self): + """ + Load the internal representation of a non-linear search from a pickle or dill file. + + The results in this representation are required to use a search's in-built tools for visualization, + analysing samples and other tasks. + + Returns + ------- + The results of the non-linear search in its internal representation. + """ + + # This is a nasty hack to load emcee backends. It will be removed once the source code is more stable. + + try: + import emcee + + backend_filename = self.search_internal_path / "search_internal.hdf" + if backend_filename.is_file(): + return emcee.backends.HDFBackend(filename=str(backend_filename)) + except ImportError: + pass + + filename = self.search_internal_path / "search_internal.dill" + + with open_(filename, "rb") as f: + return dill.load(f) + + def remove_search_internal(self): + """ + Remove the internal representation of a non-linear search. + + This deletes the entire `search_internal` folder, including a .pickle / .dill file containing the interal + results and files with the timer values. + + This folder can often have a large filesize, thus deleting it can reduce hard-disk use of the model-fit. + """ + shutil.rmtree(self.search_internal_path) + + def completed(self): + """ + Mark the search as complete by saving a file + """ + open_(self._has_completed_path, "w+").close() + + def load_samples(self): + return load_from_table(filename=self._samples_file) + + @property + def samples(self): + """ + Load the samples associated with the search from the output directory. + """ + sample_list = self.load_samples() + samples_info = self.load_samples_info() + + cls = cast(Type[Samples], get_class(samples_info["class_path"])) + + return cls.from_list_info_and_model( + sample_list=sample_list, + samples_info=samples_info, + model=self.model, + ) + + def save_latent_samples( + self, + latent_samples, + ): + """ + Write out the latent variables of the model to a file. + + Parameters + ---------- + latent_samples + Samples describing the latent variables of the model + """ + self._save_samples(latent_samples, name="latent") + + def save_samples(self, samples): + """ + Save the final-result samples associated with the phase as a pickle + """ + self._save_samples(samples) + + def _save_samples(self, samples, name=None): + """ + Save the final-result samples associated with the phase as a pickle + """ + + if name is not None: + directory = self._files_path / name + else: + directory = self._files_path + name = "samples" + if conf.instance["general"]["output"]["samples_to_csv"] and should_output(name): + self.save_json(directory / "samples_info.json", samples.samples_info) + + if isinstance(samples, SamplesPDF): + try: + samples.save_covariance_matrix(directory / "covariance.csv") + except (ValueError, ZeroDivisionError) as e: + logger.warning( + f"Could not save covariance matrix because of the following error:\n{e}" + ) + + samples.write_table(filename=directory / "samples.csv") + + def save_samples_summary( + self, samples_summary: SamplesSummary, name="samples_summary" + ): + model = samples_summary.model + + filter_args = tuple( + name + for name in ( + "errors_at_sigma_1", + "errors_at_sigma_3", + "values_at_sigma_1", + "values_at_sigma_3", + "max_log_likelihood_sample", + "median_pdf_sample", + ) + if not should_output(name) + ) + + samples_summary.model = None + self.save_json( + name, + to_dict( + samples_summary, + filter_args=filter_args, + ), + ) + samples_summary.model = model + + def load_samples_summary(self) -> SamplesSummary: + samples_summary = from_dict(self.load_json(name="samples_summary")) + samples_summary.model = self.model + + return samples_summary + + def load_latent_samples(self): + return load_from_table(filename=self._files_path / "latent/samples.csv") + + def load_samples_info(self): + with open_(self._info_file) as infile: + return json.load(infile) + + def save_all(self, search_config_dict=None, info=None): + info = info or {} + + self.save_identifier() + self.save_parent_identifier() + self._save_model_info(model=self.model) + # VisualiseGraph( + # model=self.model, + # ).save(str(self.output_path / "model_graph.html")) + + if info: + self.save_json("info", info) + + self.save_json("search", to_dict(self.search)) + try: + info_start = self.search.initializer.info_from_model(model=self.model) + self._save_model_start_point(info=info_start) + except (NotImplementedError, AttributeError): + pass + + self.save_json("model", to_dict(self.model)) + self._save_metadata(search_name=type(self.search).__name__.lower()) + + @AbstractPaths.parent.setter + def parent(self, parent: AbstractPaths): + """ + The search performed before this search. For example, a search + that is then compared to searches during a grid search. + """ + self._parent = parent + + def save_parent_identifier(self): + if self.parent is not None: + with open_(self._parent_identifier_path, "w+") as f: + f.write(self.parent.identifier) + self.parent.save_unique_tag() + + def save_unique_tag(self, is_grid_search=False): + if is_grid_search: + with open_(self._grid_search_path, "w+") as f: + if self.unique_tag is not None: + f.write(self.unique_tag) + + @property + def _parent_identifier_path(self) -> Path: + return self.output_path / ".parent_identifier" + + @property + def _grid_search_path(self) -> Path: + return self.output_path / ".is_grid_search" + + @property + def is_grid_search(self) -> bool: + """ + Is this a grid search which comprises a number of child searches? + """ + return self._grid_search_path.exists() + + def create_child( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + is_identifier_in_paths: Optional[bool] = None, + identifier: Optional[str] = None, + ) -> "AbstractPaths": + """ + Create a paths object which is the child of some parent + paths object. This is done during a GridSearch so that + results can be stored in the correct directory. + + Parameters + ---------- + name + path_prefix + is_identifier_in_paths + If False then this path's identifier will not be + added to its output path. + identifier + + Returns + ------- + A new paths object + """ + child = type(self)( + name=name or self.name, + path_prefix=path_prefix or self.path_prefix, + is_identifier_in_paths=( + is_identifier_in_paths + if is_identifier_in_paths is not None + else self.is_identifier_in_paths + ), + parent=self, + ) + child.model = self.model + child.search = self.search + child._identifier = identifier + return child + + def for_sub_analysis(self, analysis_name: str): + """ + Paths for an analysis which is a child of another analysis. + + The analysis name forms a new directory on the end of the original + analysis output path. + """ + from .sub_directory_paths import SubDirectoryPaths + + return SubDirectoryPaths(parent=self, analysis_name=analysis_name) + + def _save_metadata(self, search_name): + """ + Save metadata associated with the phase, such as the name of the pipeline, the + name of the phase and the name of the dataset being fit + """ + with open_(self.output_path / "metadata", "a") as f: + f.write( + f"""name={self.name}\nnon_linear_search={search_name} + """ + ) + + def _save_model_info(self, model): + """ + Save the model.info file, which summarises every parameter and prior. + """ + with open_(self.output_path / "model.info", "w+") as f: + f.write(model.info) + + if should_output("model_graph") and hasattr(model, "graph_info"): + with open_(self.output_path / "model.graph", "w+") as f: + f.write(model.graph_info) + + def _save_model_start_point(self, info): + """ + Save the model.start file, which summarises the start point of every parameter. + """ + with open_(self.output_path / "model.start", "w+") as f: + f.write(info) + + def _save_parameter_names_file(self, model): + """ + Create the param_names file listing every parameter's label and Latex tag, which is used for corner.py + visualization. + + The parameter labels are determined using the label.ini and label_format.ini config files. + """ + + parameter_names = model.model_component_and_parameter_names + parameter_labels = model.parameter_labels + subscripts = model.superscripts_overwrite_via_config + parameter_labels_with_subscript = [ + f"{label}_{subscript}" + for label, subscript in zip(parameter_labels, subscripts) + ] + + parameter_name_and_label = [] + + for i in range(model.prior_count): + line = formatter.add_whitespace( + str0=parameter_names[i], + str1=parameter_labels_with_subscript[i], + whitespace=70, + ) + parameter_name_and_label += [f"{line}\n"] + + formatter.output_list_of_strings_to_file( + file=self._files_path / "model.paramnames", + list_of_strings=parameter_name_and_label, + ) + + @property + def _info_file(self) -> Path: + return self._files_path / "samples_info.json" + + @property + def _has_completed_path(self) -> Path: + """ + A file indicating that a `NonLinearSearch` has been completed previously + """ + return self.output_path / ".completed" + + def _make_path(self) -> str: + """ + Returns the path to the folder at which the metadata should be saved + + The path terminates with the identifier, unless the identifier has already + been added to the path. + """ + path_ = Path(conf.instance.output_path) + segment = _test_mode_segment() + if segment: + path_ = path_ / segment + path_ = path_ / self.path_prefix / self.name + if self.is_identifier_in_paths: + path_ = path_ / self.identifier + return path_ diff --git a/autofit/non_linear/result.py b/autofit/non_linear/result.py index 327774a11..d9418b844 100644 --- a/autofit/non_linear/result.py +++ b/autofit/non_linear/result.py @@ -1,502 +1,502 @@ -from __future__ import annotations -import logging -from abc import ABC, abstractmethod -import numpy as np -from typing import TYPE_CHECKING, Optional -import warnings - -if TYPE_CHECKING: - from autofit.non_linear.analysis.analysis import Analysis - -from autofit import exc -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.paths.abstract import AbstractPaths -from autofit.non_linear.samples import Samples -from autofit.non_linear.samples.summary import SamplesSummary -from autofit.text import text_util - - -class Placeholder: - def __getattr__(self, item): - """ - Placeholders return None to represent the missing result's value - """ - return None - - def __getstate__(self): - return {} - - def __setstate__(self, state): - pass - - def __gt__(self, other): - return False - - def __lt__(self, other): - return True - - @property - def samples(self): - return self - - @property - def log_likelihood(self): - return -np.inf - - def summary(self): - return self - - -class AbstractResult(ABC): - """ - @DynamicAttrs - """ - - def __init__(self, samples_summary, paths): - """ - Abstract result of a non-linear search. - - Parameters - ---------- - samples_summary - A summary of the most important samples of the non-linear search (e.g. maximum log likelihood, median PDF). - paths - The paths to the results of the search. - """ - - self._samples_summary = samples_summary - self.paths = paths - - @property - def samples_summary(self): - return self._samples_summary - - @property - @abstractmethod - def samples(self): - pass - - @property - @abstractmethod - def model(self): - pass - - @property - def info(self) -> str: - return text_util.result_info_from( - samples=self.samples, - ) - - def __gt__(self, other): - """ - Results are sorted by their associated log_likelihood. - - Placeholders are always low. - """ - if isinstance(other, Placeholder): - return True - return self.log_likelihood > other.log_likelihood - - def __lt__(self, other): - """ - Results are sorted by their associated log_likelihood. - - Placeholders are always low. - """ - if isinstance(other, Placeholder): - return False - return self.log_likelihood < other.log_likelihood - - @property - def log_likelihood(self): - return self.samples_summary.max_log_likelihood_sample.log_likelihood - - @property - def instance(self): - try: - return self.samples_summary.instance - except AttributeError as e: - logging.warning(e) - return None - - @property - def max_log_likelihood_instance(self): - return self.instance - - @property - def model_centred(self) -> AbstractPriorModel: - """ - Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's - inferred maximum log likelihood parameter values and `sigma` the input absolute value `a`. - - For example, a previous result may infer a parameter to have a maximum log likelihood value of 2. - - If this result is used for search chaining, `model_centred_absolute(a=0.1)` will assign this free parameter - `GaussianPrior(mean=2.0, sigma=0.1)` in the new model, where `sigma` is linked to the input `a`. - - Parameters - ---------- - a - The absolute width of gaussian priors - - Returns - ------- - A model mapper created by taking results from this search and creating priors with the defined absolute - width. - """ - return self.samples_summary.model_centred - - def model_centred_absolute(self, a: float) -> AbstractPriorModel: - """ - Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's - inferred maximum log likelihood parameter values and `sigma` the input absolute value `a`. - - For example, a previous result may infer a parameter to have a maximum log likelihood value of 2. - - If this result is used for search chaining, `model_centred_absolute(a=0.1)` will assign this free parameter - `GaussianPrior(mean=2.0, sigma=0.1)` in the new model, where `sigma` is linked to the input `a`. - - Parameters - ---------- - a - The absolute width of gaussian priors - - Returns - ------- - A model mapper created by taking results from this search and creating priors with the defined absolute - width. - """ - return self.samples_summary.model_centred_absolute(a) - - def model_centred_relative(self, r: float) -> AbstractPriorModel: - """ - Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's - inferred maximum log likelihood parameter values and `sigma` a relative value from the result `r`. - - For example, a previous result may infer a parameter to have a maximum log likelihood value of 2 and - an error at the input `sigma` of 0.5. - - If this result is used for search chaining, `model_centred_relative(r=0.1)` will assign this free parameter - `GaussianPrior(mean=2.0, sigma=0.5*0.1)` in the new model, where `sigma` is the inferred error times `r`. - - Parameters - ---------- - r - The relative width of gaussian priors - - Returns - ------- - A model mapper created by taking results from this search and creating priors with the defined relative - width. - """ - return self.samples_summary.model_centred_relative(r) - - def model_centred_max_lh_bounded(self, b: float) -> AbstractPriorModel: - """ - Returns a model where every free parameter is a `UniformPrior` with `lower_limit` and `upper_limit` the previous - result's inferred maximum log likelihood parameter values minus and plus the bound `b`. - - For example, a previous result may infer a parameter to have a maximum log likelihood value of 2. - - If this result is used for search chaining, `model_bound(b=0.1)` will assign this free parameter - `UniformPrior(lower_limit=1.9, upper_limit=2.1)` in the new model. - - Parameters - ---------- - b - The size of the bounds of the uniform prior - - Returns - ------- - A model mapper created by taking results from this search and creating priors with the defined bounded - uniform priors. - """ - return self.samples_summary.model_centred_max_lh_bounded(b) - - -class Result(AbstractResult): - def __init__( - self, - samples_summary: SamplesSummary, - paths: Optional[AbstractPaths] = None, - samples: Optional[Samples] = None, - search_internal: Optional[object] = None, - analysis: Optional[Analysis] = None, - ): - """ - The result of a non-linear search. - - The default behaviour is for all key results to be in the `samples_summary` attribute, which is a concise - summary of the results of the non-linear search. The reasons for this to be the main attribute are: - - - It is concise and therefore has minimal I/O overhead, which is important because when runs are resumed - the results are loaded often, which can become very slow for large results via a `samples.csv`. - - - The `output.yaml` config files can be used to disable the output of the `samples.csv` file - and `search_internal.dill` files. This means in order for results to be loaded in a way that allows a run to - resume, the `samples_summary` must contain all results necessary to resume the run. - - For this reason, the `samples` and `search_internal` attributes are optional. On the first run of a model-fit, - they will always contain values as they are passed in via memory from the results of the search. However, if - a run is resumed they are no longer available in memory, and they will only be available if their corresponding - `samples.csv` and `search_internal.dill` files are output on disk and available to load. - - This object includes: - - - The `samples_summary` attribute, which is a summary of the results of the non-linear search. - - - The `paths` attribute, which contains the path structure to the results of the search on the hard-disk and - is used to load the samples and search internal attributes if they are required and not available in memory. - - - The samples of the non-linear search (E.g. MCMC chains, nested sampling samples) which are used to compute - the maximum likelihood model, posteriors and other properties. - - - The non-linear search used to perform the model fit in its internal format (e.g. the Dynesty sampler used - by dynesty itself as opposed to PyAutoFit abstract classes). - - Parameters - ---------- - samples_summary - A summary of the most important samples of the non-linear search (e.g. maximum log likelihood, median PDF). - paths - The paths to the results of the search, used to load the samples and search internal attributes if they are - required and not available in memory. - samples - The samples of the non-linear search, for example the MCMC chains. - search_internal - The non-linear search used to perform the model fit in its internal format. - analysis - The `Analysis` object that was used to perform the model-fit from which this result is inferred. - """ - super().__init__(samples_summary=samples_summary, paths=paths) - - self._samples = samples - self._search_internal = search_internal - - self.analysis = analysis - - self.__model = None - - self.child_results = None - - def dict(self) -> dict: - """ - Human-readable dictionary representation of the results - """ - return { - "max_log_likelihood": self.samples_summary.max_log_likelihood_sample.model_dict(), - "median pdf": self.samples_summary.median_pdf_sample.model_dict(), - } - - @property - def samples(self) -> Optional[Samples]: - """ - Returns the samples of the non-linear search, for example the MCMC chains or nested sampling samples. - - When a model-fit is run the first time, the samples are passed into the result via memory and therefore - always available. - - However, if a model-fit is resumed the samples are not available in memory and they only way to load them is - via the `samples.csv` file output on the hard-disk. This property handles the loading of the samples from - the `samples.csv` file if they are not available in memory. - - Returns - ------- - The samples of the non-linear search. - """ - - if self._samples is not None: - return self._samples - - try: - return self.paths.samples - except FileNotFoundError: - return None - - @property - def search_internal(self): - """ - Returns the non-linear search used to perform the model fit in its internal sampler format. - - When a model-fit is run the first time, the search internal is passed into the result via memory and therefore - always available. - - However, if a model-fit is resumed the search internal is not available in memory and they only way to load - it is via the `search_internal.dill` file output on the hard-disk. This property handles the loading of - the search internal from the `search_internal.dill` file if it is not available in memory. - - Returns - ------- - The non-linear search used to perform the model fit in its internal sampler format. - """ - if self._search_internal is not None: - return self._search_internal - - try: - return self.paths.load_search_internal() - except FileNotFoundError: - pass - - @property - def projected_model(self) -> AbstractPriorModel: - """ - Create a new model with the same structure as the previous model, - replacing each prior with a new prior created by calculating sufficient - statistics from samples and corresponding weights for that prior. - - `Samples.weight_list` holds *linear* importance weights; the message - projection is an importance-weighted moment match over *log* weights, - so they are converted here. Zero-weight samples map to -inf and drop - out of the moments. - """ - weights = np.asarray(self.samples.weight_list) - if not np.any(weights > 0.0): - raise ValueError( - "Cannot project a model from samples whose weights are all zero." - ) - with np.errstate(divide="ignore"): - log_weight_list = np.log(weights) - arguments = { - prior: prior.project( - samples=np.array(self.samples.values_for_path(path)), - log_weight_list=log_weight_list, - ) - for path, prior in self.samples.model.path_priors_tuples - } - return self.samples.model.mapper_from_prior_arguments(arguments) - - @property - def model(self): - if self.__model is None: - self.__model = self.samples_summary.model.mapper_via_defaults_from() - - return self.__model - - @model.setter - def model(self, model): - self.__model = model - - def __str__(self): - return "Analysis Result:\n{}".format( - "\n".join( - ["{}: {}".format(key, value) for key, value in self.__dict__.items()] - ) - ) - - def __getitem__(self, item): - return self.child_results[item] - - def __iter__(self): - return iter(self.child_results) - - def __len__(self): - return len(self.child_results) - - -class ResultsCollection: - def __init__(self, result_list=None): - """ - A collection of results from previous searches. Results can be obtained using an index or the name of the search - from whence they came. - """ - self.__result_list = [] - self.__result_dict = {} - - if result_list is not None: - for result in result_list: - self.add(name="", result=result) - - def copy(self): - collection = ResultsCollection() - collection.__result_dict = self.__result_dict - collection.__result_list = self.__result_list - return collection - - @property - def reversed(self): - return reversed(self.__result_list) - - @property - def last(self): - """ - The result of the last search - """ - if len(self.__result_list) > 0: - return self.__result_list[-1] - return None - - @property - def first(self): - """ - The result of the first search - """ - if len(self.__result_list) > 0: - return self.__result_list[0] - return None - - def add(self, name, result): - """ - Add the result of a search. - - Parameters - ---------- - name: str - The name of the search - result - The result of that search - """ - try: - self.__result_list[self.__result_list.index(result)] = result - except ValueError: - self.__result_list.append(result) - self.__result_dict[name] = result - - def __getitem__(self, item): - """ - Get the result of a previous search by index - - Parameters - ---------- - item: int - The index of the result - - Returns - ------- - result: Result - The result of a previous search - """ - return self.__result_list[item] - - def __len__(self): - return len(self.__result_list) - - def from_name(self, name): - """ - Returns the result of a previous search by its name - - Parameters - ---------- - name: str - The name of a previous search - - Returns - ------- - result: Result - The result of that search - - Raises - ------ - exc.PipelineException - If no search with the expected result is found - """ - try: - return self.__result_dict[name] - except KeyError: - raise exc.PipelineException( - "No previous search named {} found in results ({})".format( - name, ", ".join(self.__result_dict.keys()) - ) - ) - - def __contains__(self, item): - return item in self.__result_dict +from __future__ import annotations +import logging +from abc import ABC, abstractmethod +import numpy as np +from typing import TYPE_CHECKING, Optional +import warnings + +if TYPE_CHECKING: + from autofit.non_linear.analysis.analysis import Analysis + +from autofit import exc +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.paths.abstract import AbstractPaths +from autofit.non_linear.samples import Samples +from autofit.non_linear.samples.summary import SamplesSummary +from autofit.text import text_util + + +class Placeholder: + def __getattr__(self, item): + """ + Placeholders return None to represent the missing result's value + """ + return None + + def __getstate__(self): + return {} + + def __setstate__(self, state): + pass + + def __gt__(self, other): + return False + + def __lt__(self, other): + return True + + @property + def samples(self): + return self + + @property + def log_likelihood(self): + return -np.inf + + def summary(self): + return self + + +class AbstractResult(ABC): + """ + @DynamicAttrs + """ + + def __init__(self, samples_summary, paths): + """ + Abstract result of a non-linear search. + + Parameters + ---------- + samples_summary + A summary of the most important samples of the non-linear search (e.g. maximum log likelihood, median PDF). + paths + The paths to the results of the search. + """ + + self._samples_summary = samples_summary + self.paths = paths + + @property + def samples_summary(self): + return self._samples_summary + + @property + @abstractmethod + def samples(self): + pass + + @property + @abstractmethod + def model(self): + pass + + @property + def info(self) -> str: + return text_util.result_info_from( + samples=self.samples, + ) + + def __gt__(self, other): + """ + Results are sorted by their associated log_likelihood. + + Placeholders are always low. + """ + if isinstance(other, Placeholder): + return True + return self.log_likelihood > other.log_likelihood + + def __lt__(self, other): + """ + Results are sorted by their associated log_likelihood. + + Placeholders are always low. + """ + if isinstance(other, Placeholder): + return False + return self.log_likelihood < other.log_likelihood + + @property + def log_likelihood(self): + return self.samples_summary.max_log_likelihood_sample.log_likelihood + + @property + def instance(self): + try: + return self.samples_summary.instance + except AttributeError as e: + logging.warning(e) + return None + + @property + def max_log_likelihood_instance(self): + return self.instance + + @property + def model_centred(self) -> AbstractPriorModel: + """ + Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's + inferred maximum log likelihood parameter values and `sigma` the input absolute value `a`. + + For example, a previous result may infer a parameter to have a maximum log likelihood value of 2. + + If this result is used for search chaining, `model_centred_absolute(a=0.1)` will assign this free parameter + `GaussianPrior(mean=2.0, sigma=0.1)` in the new model, where `sigma` is linked to the input `a`. + + Parameters + ---------- + a + The absolute width of gaussian priors + + Returns + ------- + A model mapper created by taking results from this search and creating priors with the defined absolute + width. + """ + return self.samples_summary.model_centred + + def model_centred_absolute(self, a: float) -> AbstractPriorModel: + """ + Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's + inferred maximum log likelihood parameter values and `sigma` the input absolute value `a`. + + For example, a previous result may infer a parameter to have a maximum log likelihood value of 2. + + If this result is used for search chaining, `model_centred_absolute(a=0.1)` will assign this free parameter + `GaussianPrior(mean=2.0, sigma=0.1)` in the new model, where `sigma` is linked to the input `a`. + + Parameters + ---------- + a + The absolute width of gaussian priors + + Returns + ------- + A model mapper created by taking results from this search and creating priors with the defined absolute + width. + """ + return self.samples_summary.model_centred_absolute(a) + + def model_centred_relative(self, r: float) -> AbstractPriorModel: + """ + Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's + inferred maximum log likelihood parameter values and `sigma` a relative value from the result `r`. + + For example, a previous result may infer a parameter to have a maximum log likelihood value of 2 and + an error at the input `sigma` of 0.5. + + If this result is used for search chaining, `model_centred_relative(r=0.1)` will assign this free parameter + `GaussianPrior(mean=2.0, sigma=0.5*0.1)` in the new model, where `sigma` is the inferred error times `r`. + + Parameters + ---------- + r + The relative width of gaussian priors + + Returns + ------- + A model mapper created by taking results from this search and creating priors with the defined relative + width. + """ + return self.samples_summary.model_centred_relative(r) + + def model_centred_max_lh_bounded(self, b: float) -> AbstractPriorModel: + """ + Returns a model where every free parameter is a `UniformPrior` with `lower_limit` and `upper_limit` the previous + result's inferred maximum log likelihood parameter values minus and plus the bound `b`. + + For example, a previous result may infer a parameter to have a maximum log likelihood value of 2. + + If this result is used for search chaining, `model_bound(b=0.1)` will assign this free parameter + `UniformPrior(lower_limit=1.9, upper_limit=2.1)` in the new model. + + Parameters + ---------- + b + The size of the bounds of the uniform prior + + Returns + ------- + A model mapper created by taking results from this search and creating priors with the defined bounded + uniform priors. + """ + return self.samples_summary.model_centred_max_lh_bounded(b) + + +class Result(AbstractResult): + def __init__( + self, + samples_summary: SamplesSummary, + paths: Optional[AbstractPaths] = None, + samples: Optional[Samples] = None, + search_internal: Optional[object] = None, + analysis: Optional[Analysis] = None, + ): + """ + The result of a non-linear search. + + The default behaviour is for all key results to be in the `samples_summary` attribute, which is a concise + summary of the results of the non-linear search. The reasons for this to be the main attribute are: + + - It is concise and therefore has minimal I/O overhead, which is important because when runs are resumed + the results are loaded often, which can become very slow for large results via a `samples.csv`. + + - The `output.yaml` config files can be used to disable the output of the `samples.csv` file + and `search_internal.dill` files. This means in order for results to be loaded in a way that allows a run to + resume, the `samples_summary` must contain all results necessary to resume the run. + + For this reason, the `samples` and `search_internal` attributes are optional. On the first run of a model-fit, + they will always contain values as they are passed in via memory from the results of the search. However, if + a run is resumed they are no longer available in memory, and they will only be available if their corresponding + `samples.csv` and `search_internal.dill` files are output on disk and available to load. + + This object includes: + + - The `samples_summary` attribute, which is a summary of the results of the non-linear search. + + - The `paths` attribute, which contains the path structure to the results of the search on the hard-disk and + is used to load the samples and search internal attributes if they are required and not available in memory. + + - The samples of the non-linear search (E.g. MCMC chains, nested sampling samples) which are used to compute + the maximum likelihood model, posteriors and other properties. + + - The non-linear search used to perform the model fit in its internal format (e.g. the Dynesty sampler used + by dynesty itself as opposed to PyAutoFit abstract classes). + + Parameters + ---------- + samples_summary + A summary of the most important samples of the non-linear search (e.g. maximum log likelihood, median PDF). + paths + The paths to the results of the search, used to load the samples and search internal attributes if they are + required and not available in memory. + samples + The samples of the non-linear search, for example the MCMC chains. + search_internal + The non-linear search used to perform the model fit in its internal format. + analysis + The `Analysis` object that was used to perform the model-fit from which this result is inferred. + """ + super().__init__(samples_summary=samples_summary, paths=paths) + + self._samples = samples + self._search_internal = search_internal + + self.analysis = analysis + + self.__model = None + + self.child_results = None + + def dict(self) -> dict: + """ + Human-readable dictionary representation of the results + """ + return { + "max_log_likelihood": self.samples_summary.max_log_likelihood_sample.model_dict(), + "median pdf": self.samples_summary.median_pdf_sample.model_dict(), + } + + @property + def samples(self) -> Optional[Samples]: + """ + Returns the samples of the non-linear search, for example the MCMC chains or nested sampling samples. + + When a model-fit is run the first time, the samples are passed into the result via memory and therefore + always available. + + However, if a model-fit is resumed the samples are not available in memory and they only way to load them is + via the `samples.csv` file output on the hard-disk. This property handles the loading of the samples from + the `samples.csv` file if they are not available in memory. + + Returns + ------- + The samples of the non-linear search. + """ + + if self._samples is not None: + return self._samples + + try: + return self.paths.samples + except FileNotFoundError: + return None + + @property + def search_internal(self): + """ + Returns the non-linear search used to perform the model fit in its internal sampler format. + + When a model-fit is run the first time, the search internal is passed into the result via memory and therefore + always available. + + However, if a model-fit is resumed the search internal is not available in memory and they only way to load + it is via the `search_internal.dill` file output on the hard-disk. This property handles the loading of + the search internal from the `search_internal.dill` file if it is not available in memory. + + Returns + ------- + The non-linear search used to perform the model fit in its internal sampler format. + """ + if self._search_internal is not None: + return self._search_internal + + try: + return self.paths.load_search_internal() + except FileNotFoundError: + pass + + @property + def projected_model(self) -> AbstractPriorModel: + """ + Create a new model with the same structure as the previous model, + replacing each prior with a new prior created by calculating sufficient + statistics from samples and corresponding weights for that prior. + + `Samples.weight_list` holds *linear* importance weights; the message + projection is an importance-weighted moment match over *log* weights, + so they are converted here. Zero-weight samples map to -inf and drop + out of the moments. + """ + weights = np.asarray(self.samples.weight_list) + if not np.any(weights > 0.0): + raise ValueError( + "Cannot project a model from samples whose weights are all zero." + ) + with np.errstate(divide="ignore"): + log_weight_list = np.log(weights) + arguments = { + prior: prior.project( + samples=np.array(self.samples.values_for_path(path)), + log_weight_list=log_weight_list, + ) + for path, prior in self.samples.model.path_priors_tuples + } + return self.samples.model.mapper_from_prior_arguments(arguments) + + @property + def model(self): + if self.__model is None: + self.__model = self.samples_summary.model.mapper_via_defaults_from() + + return self.__model + + @model.setter + def model(self, model): + self.__model = model + + def __str__(self): + return "Analysis Result:\n{}".format( + "\n".join( + ["{}: {}".format(key, value) for key, value in self.__dict__.items()] + ) + ) + + def __getitem__(self, item): + return self.child_results[item] + + def __iter__(self): + return iter(self.child_results) + + def __len__(self): + return len(self.child_results) + + +class ResultsCollection: + def __init__(self, result_list=None): + """ + A collection of results from previous searches. Results can be obtained using an index or the name of the search + from whence they came. + """ + self.__result_list = [] + self.__result_dict = {} + + if result_list is not None: + for result in result_list: + self.add(name="", result=result) + + def copy(self): + collection = ResultsCollection() + collection.__result_dict = self.__result_dict + collection.__result_list = self.__result_list + return collection + + @property + def reversed(self): + return reversed(self.__result_list) + + @property + def last(self): + """ + The result of the last search + """ + if len(self.__result_list) > 0: + return self.__result_list[-1] + return None + + @property + def first(self): + """ + The result of the first search + """ + if len(self.__result_list) > 0: + return self.__result_list[0] + return None + + def add(self, name, result): + """ + Add the result of a search. + + Parameters + ---------- + name: str + The name of the search + result + The result of that search + """ + try: + self.__result_list[self.__result_list.index(result)] = result + except ValueError: + self.__result_list.append(result) + self.__result_dict[name] = result + + def __getitem__(self, item): + """ + Get the result of a previous search by index + + Parameters + ---------- + item: int + The index of the result + + Returns + ------- + result: Result + The result of a previous search + """ + return self.__result_list[item] + + def __len__(self): + return len(self.__result_list) + + def from_name(self, name): + """ + Returns the result of a previous search by its name + + Parameters + ---------- + name: str + The name of a previous search + + Returns + ------- + result: Result + The result of that search + + Raises + ------ + exc.PipelineException + If no search with the expected result is found + """ + try: + return self.__result_dict[name] + except KeyError: + raise exc.PipelineException( + "No previous search named {} found in results ({})".format( + name, ", ".join(self.__result_dict.keys()) + ) + ) + + def __contains__(self, item): + return item in self.__result_dict diff --git a/autofit/non_linear/samples/__init__.py b/autofit/non_linear/samples/__init__.py index ff21c4c15..531f573f8 100644 --- a/autofit/non_linear/samples/__init__.py +++ b/autofit/non_linear/samples/__init__.py @@ -1,6 +1,6 @@ -from .mcmc import SamplesMCMC -from .nest import SamplesNest -from .samples import Samples -from .pdf import SamplesPDF -from .sample import Sample, load_from_table -from .stored import SamplesStored +from .mcmc import SamplesMCMC +from .nest import SamplesNest +from .samples import Samples +from .pdf import SamplesPDF +from .sample import Sample, load_from_table +from .stored import SamplesStored diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index 46fc5e731..a1b88883f 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -1,1253 +1,1253 @@ -from __future__ import annotations -import copy -import gc -import logging -import multiprocessing as mp -import numpy as np -import os -import time -import warnings -from abc import ABC, abstractmethod -from collections import Counter -from functools import wraps -from pathlib import Path -from typing import TYPE_CHECKING, Optional, Union, Tuple, List, Dict - -import psutil - -if TYPE_CHECKING: - from autofit.non_linear.result import Result - -from autonerves import conf - -from autonerves.output import should_output - -from autofit import exc -from autofit.database.sqlalchemy_ import sa -from autofit.graphical import ( - MeanField, - AnalysisFactor, - _HierarchicalFactor, - FactorApproximation, -) -from autofit.graphical.utils import Status -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.mapper.model import ModelInstance -from autofit.non_linear.initializer import Initializer -from autofit.non_linear.fitness import Fitness -from autofit.non_linear.parallel import SneakyPool, SneakierPool -from autofit.non_linear.paths.abstract import AbstractPaths -from autofit.non_linear.paths.database import DatabasePaths -from autofit.non_linear.paths.directory import DirectoryPaths -from autofit.non_linear.paths.sub_directory_paths import SubDirectoryPaths -from autofit.non_linear.samples.samples import Samples -from autofit.non_linear.samples.summary import SamplesSummary -from autofit.non_linear.timer import Timer -from autofit.non_linear.analysis import Analysis -from autofit.non_linear.paths.null import NullPaths -from autofit.graphical.declarative.abstract import PriorFactor -from autofit.graphical.expectation_propagation import AbstractFactorOptimiser - -from autofit.non_linear.fitness import get_timeout_seconds -from autofit.non_linear.test_mode import ( - test_mode_level, - test_mode_samples, - skip_fit_output, -) - -logger = logging.getLogger(__name__) - - -def check_cores(func): - """ - Checks how many cores the search has been configured to - use and then returns None instead of calling the pool - creation function in the case that only one core has - been set. - - Parameters - ---------- - func - A function that creates a pool - - Returns - ------- - None or a pool - """ - - @wraps(func) - def wrapper(self, *args, **kwargs): - if self.number_of_cores == 1: - return None - return func(self, *args, **kwargs) - - return wrapper - - -def configure_handler(func): - """ - Add a file handler for logging during the course of the search. - - Optionally outputs 'search.log' to the search's output directory. Can be - turned on or off in the output.yaml file. - - Parameters - ---------- - func - Some function for which logging should be output to file - - Returns - ------- - A decorated version of the function - """ - root_logger = logging.getLogger() - - def decorated(self, *args, **kwargs): - if not should_output("search_log"): - return func(self, *args, **kwargs) - if self.disable_output: - return func(self, *args, **kwargs) - try: - os.makedirs( - self.paths.output_path, - exist_ok=True, - ) - handler = logging.FileHandler(self.paths.output_path / "search.log") - root_logger.addHandler(handler) - except AttributeError: - return func(self, *args, **kwargs) - - try: - return func(self, *args, **kwargs) - finally: - root_logger.removeHandler(handler) - - return decorated - - -class NonLinearSearch(AbstractFactorOptimiser, ABC): - def __init__( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - unique_tag: Optional[str] = None, - initializer: Initializer = None, - iterations_per_quick_update: Optional[int] = None, - iterations_per_full_update: int = None, - live_visual_update: Optional[bool] = None, - number_of_cores: int = 1, - silence: bool = False, - session: Optional[sa.orm.Session] = None, - paths: Optional[AbstractPaths] = None, - **kwargs, - ): - """ - Abstract base class for non-linear searches. - - This class sets up the file structure for the non-linear search, which are standardized across all non-linear - searches. - - Parameters - ---------- - name - The name of the search, controlling the last folder results are output. - path_prefix - The path of folders prefixing the name folder where results are output. - unique_tag - The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database - and also acts as the folder after the path prefix and before the search name. - initializer - Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). - silence - If True, the default print output of the non-linear search is silenced. - session - An SQLAlchemy session instance so the results of the model-fit are written to an SQLite database. - """ - super().__init__() - - if name is None and path_prefix is None: - self.disable_output = True - else: - self.disable_output = False - - from autofit.non_linear.paths.database import DatabasePaths - - if name: - path_prefix = Path(path_prefix or "") - - self.path_prefix = path_prefix - - self.path_prefix_no_unique_tag = path_prefix - - self._logger = None - - self.unique_tag = unique_tag - - if paths: - self.paths = paths - elif session is not None: - logger.debug("Session found. Using database.") - self.paths = DatabasePaths( - name=name, - path_prefix=path_prefix, - session=session, - save_all_samples=kwargs.get("save_all_samples", False), - unique_tag=unique_tag, - ) - elif name is not None or path_prefix: - logger.debug("Session not found. Using directory output.") - self.paths = DirectoryPaths( - name=name, path_prefix=path_prefix, unique_tag=unique_tag - ) - else: - self.paths = NullPaths() - - self.force_pickle_overwrite = conf.instance["general"]["output"][ - "force_pickle_overwrite" - ] - - self.force_visualize_overwrite = conf.instance["general"]["output"][ - "force_visualize_overwrite" - ] - - if initializer is not None: - self.initializer = initializer - - self.iterations_per_quick_update = float((iterations_per_quick_update or - conf.instance["general"]["updates"]["iterations_per_quick_update"])) - - self.iterations_per_full_update = float((iterations_per_full_update or - conf.instance["general"]["updates"]["iterations_per_full_update"])) - - self.quick_update_background = bool( - conf.instance["general"]["updates"].get( - "quick_update_background", False, - ) - ) - - self.live_visual_update = bool( - live_visual_update - if live_visual_update is not None - else conf.instance["general"]["updates"].get( - "live_visual_update", False, - ) - ) - - if conf.instance["general"]["hpc"]["hpc_mode"]: - self.iterations_per_quick_update = float(conf.instance["general"]["hpc"][ - "iterations_per_quick_update" - ]) - self.iterations_per_full_update = float(conf.instance["general"]["hpc"][ - "iterations_per_full_update" - ]) - - self.iterations = 0 - - self.silence = silence - - if conf.instance["general"]["hpc"]["hpc_mode"]: - self.silence = True - - self.kwargs = kwargs - - self.number_of_cores = number_of_cores - - if number_of_cores > 1 and any( - os.environ.get(key) != "1" - for key in ( - "OPENBLAS_NUM_THREADS", - "MKL_NUM_THREADS", - "OMP_NUM_THREADS", - "VECLIB_MAXIMUM_THREADS", - "NUMEXPR_NUM_THREADS", - ) - ): - if conf.instance["general"]["parallel"]["warn_environment_variables"]: - warnings.warn(exc.SearchWarning("")) - logger.warning( - """ - The non-linear search is using multiprocessing (number_of_cores>1). - - However, the following environment variables have not been set to 1: - - OPENBLAS_NUM_THREADS - MKL_NUM_THREADS - OMP_NUM_THREADS - VECLIB_MAXIMUM_THREADS - NUMEXPR_NUM_THREADS - - This can lead to performance issues, because both the non-linear search and libraries that may be - used in your `log_likelihood_function` evaluation (e.g. NumPy, SciPy, scikit-learn) may attempt to - parallelize over all cores available. - - This will lead to slow-down, due to overallocation of tasks over the CPUs. - - To mitigate this, set the environment variables to 1 via the following command on your - bash terminal / command line: - - export OPENBLAS_NUM_THREADS=1 - export MKL_NUM_THREADS=1 - export OMP_NUM_THREADS=1 - export VECLIB_MAXIMUM_THREADS=1 - export NUMEXPR_NUM_THREADS=1 - - This means only the non-linear search is parallelized over multiple cores. - - If you "know what you are doing" and do not want these environment variables to be set to one, you - can disable this warning by changing the following entry in the config files: - - `config -> general.yaml -> parallel: -> warn_environment_variables=False` - """ - ) - - self.optimisation_counter = Counter() - - __identifier_fields__ = tuple() - - def optimise( - self, - factor_approx: FactorApproximation, - status: Status = Status(), - ) -> Tuple[MeanField, Status]: - """ - Perform optimisation for expectation propagation. Currently only - applicable for ModelFactors created by the declarative interface. - - 1. Analysis and model classes are extracted from the factor. - 2. Priors are updated from the mean field. - 3. Analysis and model are fit as usual. - 4. A new mean field is constructed with the (posterior) 'linking' priors. - 5. Projection is performed to produce an updated EPMeanField object. - - Output directories are generated according to the factor and the number - of the search. For example a factor called "factor" would output: - - factor/optimization_0/ - factor/optimization_1/ - factor/optimization_2/ - - For the first, second and third optimizations respectively. - - Parameters - ---------- - factor_approx - A collection of messages defining the current best approximation to - some global model - status - - Returns - ------- - An updated approximation to the model having performed optimisation on - a single factor. - """ - - factor = factor_approx.factor - - _ = status - if not isinstance(factor, (AnalysisFactor, PriorFactor, _HierarchicalFactor)): - raise NotImplementedError( - f"Optimizer {self.__class__.__name__} can only be applied to" - f" AnalysisFactors, HierarchicalFactors and PriorFactors" - ) - - model = factor.prior_model.mapper_from_prior_arguments( - { - prior: prior.with_message(message) - for prior, message in factor_approx.cavity_dist.arguments.items() - } - ) - - analysis = factor.analysis - - number = self.optimisation_counter[factor.name] - - self.optimisation_counter[factor.name] += 1 - - self.paths = SubDirectoryPaths( - parent=self.paths, - analysis_name=f"{factor.name}/optimization_{number}", - is_flat=True, - ) - - result = self.fit(model=model, analysis=analysis) - - # Record the sampler's log-evidence of this tilted-distribution fit on - # the projected mean field — the per-factor Ẑₐ that README §5 documents - # `MeanField.log_norm` as carrying (#1332 F7(b)). Previously always 0, - # so `EPMeanField.log_evidence` could not be trusted for model - # comparison in sampler-driven EP fits. Searches with no evidence - # estimate (MCMC / MLE) yield None and keep the 0.0 default — evidence- - # correct model comparison requires nested-sampling factor searches. - # (Both levels guarded: e.g. StaticResult carries no samples at all.) - log_evidence = getattr( - getattr(result, "samples", None), "log_evidence", None - ) - - new_model_dist = MeanField.from_priors( - result.projected_model.priors, - log_norm=log_evidence if log_evidence is not None else 0.0, - ) - - status.result = result - - return new_model_dist, status - - @property - def name(self): - return self.paths.name - - def __getstate__(self): - """ - Remove the logger for pickling - """ - state = self.__dict__.copy() - if "_logger" in state: - del state["_logger"] - if "paths" in state: - del state["paths"] - return state - - @property - def logger(self): - if not hasattr(self, "_logger"): - self._logger = None - if self._logger is None: - logger_ = logging.getLogger(self.name) - self._logger = logger_ - return self._logger - - @property - def timer(self) -> Optional[Timer]: - """ - Returns the timer of the search, which is used to output informaiton such as how long the search took and - how much parallelization sped up the search time. - - If the search is running in `NullPaths` mode, meaning that no output is written to the hard-disk, the timer - is disabled and a `None` is returned. - - Returns - ------- - An object which times the non-linear search. - """ - try: - return Timer(self.paths.search_internal_path) - except TypeError: - pass - - @property - def paths(self) -> Optional[AbstractPaths]: - return self._paths - - @paths.setter - def paths(self, paths: Optional[AbstractPaths]): - if paths is not None: - paths.search = self - self._paths = paths - - def copy_with_paths(self, paths): - self.logger.debug(f"Creating a copy of {self._paths.name}") - search_instance = copy.copy(self) - search_instance.paths = paths - search_instance._logger = None - - return search_instance - - def fit( - self, - model: AbstractPriorModel, - analysis: Analysis, - info: Optional[Dict] = None, - ) -> Union[Result, List[Result]]: - """ - Fit a model, M with some function f that takes instances of the - class represented by model M and gives a score for their fitness. - - A model which represents possible instances with some dimensionality is fit. - - The analysis provides two functions. One visualises an instance of a model and the - other scores an instance based on how well it fits some data. The search - produces instances of the model by picking points in an N dimensional space. - - Parameters - ---------- - analysis - An object that encapsulates the data and a log likelihood function which fits the model to the data - via the non-linear search. - model - The model that is fitted to the data, which is used by the non-linear search to create instances of - the model that are fitted to the data via the log likelihood function. - info - Optional dictionary containing information about the fit that can be saved in the `files` folder - (e.g. as `files/info.json`) and can be loaded via the database. - - Returns - ------- - An object encapsulating how well the model fit the data, the best fit instance - and an updated model with free parameters updated to represent beliefs - produced by this fit. - - Raises - ------ - AssertionError - If the model has 0 dimensions. - """ - self.check_model(model=model) - - if getattr(analysis, "_use_jax", False): - try: - import jax - devices = jax.devices() - device = devices[0] - backend = device.platform.upper() - device_name = getattr(device, "device_kind", backend) - logger.info( - f"Starting non-linear search with JAX ({backend}: {device_name})." - ) - except Exception: - logger.info("Starting non-linear search with JAX.") - else: - logger.info(f"Starting non-linear search with {self.number_of_cores} cores.") - self._log_process_state() - - model = analysis.modify_model(model) - self.paths.model = model - self.paths.unique_tag = self.unique_tag - - self.paths.restore() - - model.freeze() - analysis = analysis.modify_before_fit(paths=self.paths, model=model) - model.unfreeze() - - if not skip_fit_output(): - self.pre_fit_output( - analysis=analysis, - model=model, - info=info, - ) - else: - # Skip mode still needs the metadata + identifier files written - # so downstream aggregator scraping can discover the search - # directory. `save_all` is lightweight (a handful of JSON dumps) - # and skips the expensive `analysis.save_attributes` / - # `visualize_before_fit` calls that `pre_fit_output` would add. - if hasattr(self.paths, "save_all"): - self.paths.save_all( - info=info, - ) - - if not self.paths.is_complete: - result = self.start_resume_fit( - analysis=analysis, - model=model, - ) - else: - result = self.result_via_completed_fit( - analysis=analysis, - model=model, - ) - - if not skip_fit_output(): - analysis = analysis.modify_after_fit( - paths=self.paths, model=model, result=result - ) - - self.post_fit_output( - search_internal=result.search_internal, - ) - - gc.collect() - - self.logger.info("Search complete, returning result") - - return result - - @staticmethod - def _log_process_state(): - total_files = 0 - - for process in psutil.process_iter(attrs=["pid"]): - try: - proc_info = process.as_dict(attrs=["pid"]) - logger.debug( - f"Process ID: {proc_info['pid']} has the following open files:" - ) - - open_files = process.open_files() - for file in open_files: - logger.debug(file) - total_files += 1 - - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - - if conf.instance["logging"]["total_files_open"]: - logger.info(f"Total Files Open: {total_files}") - - def pre_fit_output( - self, analysis: Analysis, model: AbstractPriorModel, info: Optional[Dict] = None - ): - """ - Outputs attributes of fit before the non-linear search begins. - - The following attributes of a fit may be output before the search begins: - - - The model composition, which is output as a .json file (`files/model.json`). - - - The non-linear search settings, which are output as a .json file (`files/search.json`). - - - Custom attributes of the analysis defined via the `save_attributes` method of the analysis class, for - example the data (e.g. `files/data.json`). - - - Custom Visualization associated with the analysis, defined via the `visualize_before_fit` - and `visualize_before_fit_combined` methods. This is typically quantities that do not change during the - model-fit (e.g. the data). - - Parameters - ---------- - analysis - An object that encapsulates the data and a log likelihood function which fits the model to the data - via the non-linear search. - model - The model that is fitted to the data, which is used by the non-linear search to create instances of - the model that are fitted to the data via the log likelihood function. - info - Optional dictionary containing information about the fit that can be saved in the `files` folder - (e.g. as `files/info.json`) and can be loaded via the database. - """ - - if not self.disable_output: - self.logger.info(f"The output path of this fit is {self.paths.output_path}") - else: - self.logger.info( - "Output to hard-disk disabled, input a search name to enable." - ) - - if not self.paths.is_complete or self.force_pickle_overwrite: - if not self.disable_output: - self.logger.info( - f"Outputting pre-fit files (e.g. model.info, visualization)." - ) - - self.paths.save_all( - info=info, - ) - analysis.save_attributes(paths=self.paths) - - if analysis.should_visualize(paths=self.paths): - analysis.visualize_before_fit( - paths=self.paths, - model=model, - ) - analysis.visualize_before_fit_combined( - paths=self.paths, - model=model, - ) - - timeout_seconds = get_timeout_seconds() - - if timeout_seconds is not None: - logger.info( - f"\n\n ***Log Likelihood Function timeout is " - f"turned on and set to {timeout_seconds} seconds.***\n" - ) - - @configure_handler - def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Result: - """ - Start a non-linear search from scratch, or resumes one which was previously terminated mid-way through. - - If the search is resumed, the model-fit will begin by loading the samples from the previous search and - from where it left off. - - After the search is completed, a `.completed` file is output so that if the search is resumed in the future - it is not repeated and results are loaded via the `update_completed_fit` method. - - Results are also output to hard-disk in the `files` folder via the `save_results` method of the analysis class. - - Parameters - ---------- - analysis - An object that encapsulates the data and a log likelihood function which fits the model to the data - via the non-linear search. - model - The model that is fitted to the data, which is used by the non-linear search to create instances of - the model that are fitted to the data via the log likelihood function. - - Returns - ------- - The result of the non-linear search, which includes the best-fit model instance and best-fit log likelihood - and errors on the model parameters. - """ - if not isinstance(self.paths, DatabasePaths) and not isinstance( - self.paths, NullPaths - ): - self.timer.start() - - mode = test_mode_level() - if mode >= 2: - return self._fit_bypass_test_mode( - model=model, - analysis=analysis, - call_likelihood=(mode == 2), - ) - - model.freeze() - search_internal, fitness = self._fit( - model=model, - analysis=analysis, - ) - - if hasattr(fitness, "shutdown_quick_update"): - fitness.shutdown_quick_update() - - samples = self.perform_update( - model=model, - analysis=analysis, - search_internal=search_internal, - fitness=fitness, - during_analysis=False, - ) - - result = analysis.make_result( - samples_summary=samples.summary(), - paths=self.paths, - samples=samples, - search_internal=search_internal, - ) - - analysis.save_results(paths=self.paths, result=result) - analysis.save_results_combined(paths=self.paths, result=result) - - model.unfreeze() - - self.paths.completed() - - return result - - def result_via_completed_fit( - self, - analysis: Analysis, - model: AbstractPriorModel, - ) -> Result: - """ - Returns the result of the non-linear search of a completed model-fit. - - The result contains the non-linear search samples summary, which contains the maximum log likelihood instance - that is used for visualization and prior passing via the search chaining API. - - This funciton may also load the full samples of the completed fit, for example if visualization of the - seatch chains (e.g. a corner plot) is performed. This task is optional and be slow due to loading times. - - Optional tasks can be performed to update the results of the model-fit on hard-disk depending on the following - entries of the `general.yaml` config file's `output` section: - - ` `force_visualize_overwrite=True`: the visualization of the model-fit is performed again (e.g. to - add new visualizations or replot figures with a different source code). - - - `force_pickle_overwrite=True`: the output files of the model-fit are recreated (e.g. to add a new attribute - that was previously not output). - - Parameters - ---------- - analysis - An object that encapsulates the data and a log likelihood function which fits the model to the data - via the non-linear search. - model - The model that is fitted to the data, which is used by the non-linear search to create instances of - the model that are fitted to the data via the log likelihood function. - - Returns - ------- - The result of the non-linear search, which includes the best-fit model instance and best-fit log likelihood - and errors on the model parameters. - """ - - model.freeze() - samples_summary = self.paths.load_samples_summary() - try: - samples = self.paths.samples - except FileNotFoundError: - samples = None - - result = analysis.make_result( - samples_summary=samples_summary, - samples=samples, - paths=self.paths, - ) - - self.logger.info(f"Fit Already Completed: skipping non-linear search.") - - if self.force_visualize_overwrite: - self.perform_visualization( - model=model, - analysis=analysis, - samples_summary=samples_summary, - during_analysis=False, - ) - - if self.force_pickle_overwrite: - self.logger.info("Forcing pickle overwrite") - - analysis.save_results(paths=self.paths, result=result) - analysis.save_results_combined(paths=self.paths, result=result) - - model.unfreeze() - - return result - - def post_fit_output(self, search_internal): - """ - Cleans up the output folderds after a completed non-linear search. - - The main task this performs is removing the folder containing the results of a non-linear search such that only - its corresponding `.zip` file is left. This is use for supercomputers, where users often have a file limit on - the number of files they can store in their home directory, so storing them all in just a .zip file is - advantageous. - - This only occurs if `remove_files=False` in the `general.yaml` config file's `output` section. - - Parameters - ---------- - search_internal - The internal search. - """ - if not conf.instance["output"]["search_internal"]: - self.logger.info("Removing search internal folder.") - self.paths.remove_search_internal() - elif search_internal is not None: - self.output_search_internal(search_internal=search_internal) - - if not self.disable_output: - self.logger.info("Removing all files except for .zip file") - - self.paths.zip_remove() - - def _fit_bypass_test_mode( - self, - model: AbstractPriorModel, - analysis: Analysis, - call_likelihood: bool = True, - ): - """ - Bypass the sampler entirely in test mode (levels 2 and 3). - - Generates fake samples and writes all expected output files so that - downstream code sees a complete result folder. - - Parameters - ---------- - model - The model being fitted. - analysis - The analysis object with the log likelihood function. - call_likelihood - If True (mode 2), call the likelihood function once to verify it - works. If False (mode 3), skip the likelihood call entirely. - """ - from autofit.non_linear.samples.pdf import SamplesPDF - from autofit.non_linear.samples.sample import Sample - - mode = test_mode_level() - if mode == 2: - logger.warning( - "TEST MODE 2 (bypass + likelihood): Skipping sampler, " - "calling likelihood function once to verify it works." - ) - else: - logger.warning( - "TEST MODE 3 (full bypass): Skipping sampler and likelihood " - "entirely for maximum speed. No likelihood verification." - ) - - model.freeze() - - unit_vector = [0.5] * model.prior_count - parameter_vector = [ - float(v) for v in model.vector_from_unit_vector( - unit_vector=unit_vector, - ) - ] - - log_likelihood = -1.0e99 - if call_likelihood: - instance = model.instance_from_vector(vector=parameter_vector) - try: - log_likelihood = float( - analysis.log_likelihood_function(instance) - ) - except exc.FitException as e: - # A `FitException` means this particular instance is pathological - # (e.g. a non-positive-definite inversion, or a degenerate mesh - # that yields NaN vertices). In a real search the sampler absorbs - # this by resampling; test mode has no sampler, so a single - # unlucky verification eval must not hard-fail the run. Keep the - # `-1.0e99` sentinel — the same effect a resample-to-reject has — - # and log the cause so a genuinely broken likelihood stays visible. - # Only `FitException` is caught: real code errors still propagate. - logger.warning( - "TEST MODE 2: likelihood verification raised FitException " - f"({e.__cause__ or e!r}); treating as a resample-rejected " - "instance and continuing with the sentinel log likelihood." - ) - - sample_list = self._build_fake_samples( - model=model, - parameter_vector=parameter_vector, - log_likelihood=log_likelihood, - ) - - # Stub log_evidence in samples_info so downstream arithmetic - # (grid search log_evidences, subhalo Bayesian model comparison, - # scrape aggregator assertions) doesn't crash on None. SamplesPDF - # reads log_evidence from samples_info. - samples_info = { - "total_iterations": 1, - "time": 0.0, - "log_evidence": log_likelihood, - } - samples_info.update(self._test_mode_samples_info()) - samples = SamplesPDF( - model=model, - sample_list=sample_list, - samples_info=samples_info, - ) - - samples_summary = samples.summary() - - # Persist samples + summary to disk so downstream code that reads - # from the output folder (database scrape, paths.load_samples_summary) - # sees a complete result. Matches the docstring's promise. NullPaths - # and DatabasePaths both handle these calls safely. - self.paths.save_samples_summary(samples_summary=samples_summary) - self.paths.save_samples(samples=samples) - - result = analysis.make_result( - samples_summary=samples_summary, - paths=self.paths, - samples=samples, - search_internal=None, - ) - - model.unfreeze() - - # Mark the fit complete, exactly as start_resume_fit does — a bypassed - # fit must be resumable (paths.is_complete -> result_via_completed_fit - # on the next run), or every rerun re-bypasses the whole pipeline. - self.paths.completed() - - return result - - def _test_mode_samples_info(self) -> dict: - """ - Sampler-specific keys to merge into ``samples_info`` when the - sampler is bypassed via ``PYAUTO_TEST_MODE=2`` or ``=3``. - - Override in subclasses to add the diagnostic keys that the real - run would populate (e.g. NUTS ESS, MCMC autocorrelations) so that - tutorial scripts and downstream code can access those keys - without ``KeyError``. Use NaN/0 placeholders — the bypass did not - actually sample. - """ - return {} - - @staticmethod - def _build_fake_samples(model, parameter_vector, log_likelihood): - """ - Build a list of fake Sample objects for test mode bypass. - - Creates a deterministic sample set: the "best" at the prior median - and additional slightly perturbed parameters with worse likelihoods. - The default of four samples keeps bypass mode cheap while allowing - downstream structural checks to exercise multi-batch sample handling. - - ``PYAUTO_TEST_MODE_SAMPLES=N`` raises the sample count so the - bypass run's ``samples.csv`` row count and byte size match a - production sampler stage (N ~ 10k-100k), keeping resume/load - timings measured against the output honest. The N > 4 samples are - synthesized vectorized (numpy) then materialised through the same - ``Sample.from_lists`` path a real sampler run uses, so structure - and cost are representative by construction. The best (first) - sample is the unperturbed prior median in both branches. - """ - from autofit.non_linear.samples.sample import Sample - - total_samples = test_mode_samples() - - if total_samples == 4: - parameter_lists = [parameter_vector] - for scale in (1.001, 0.999, 1.002): - parameter_lists.append( - [p * scale if p != 0.0 else scale - 1.0 for p in parameter_vector] - ) - - return Sample.from_lists( - model=model, - parameter_lists=parameter_lists, - log_likelihood_list=[ - log_likelihood - offset for offset in range(len(parameter_lists)) - ], - log_prior_list=[0.0] * len(parameter_lists), - weight_list=[1.0, 0.5, 0.25, 0.125], - ) - - rng = np.random.default_rng(0) - base = np.asarray(parameter_vector, dtype=float) - scatter = 1.0e-3 * rng.standard_normal((total_samples, base.shape[0])) - parameters = np.where(base == 0.0, scatter, base * (1.0 + scatter)) - parameters[0] = base - - # Weights decay over ~N/10 samples so the effective sample size stays - # a healthy fraction of N and the smallest weight, ~(10/N)e^-10, sits - # above the output.yaml samples_weight_threshold of 1e-10 for N <= 1e5. - weights = np.exp( - -np.arange(total_samples, dtype=float) / (total_samples / 10.0) - ) - - return Sample.from_lists( - model=model, - parameter_lists=parameters.tolist(), - log_likelihood_list=( - log_likelihood - np.arange(total_samples, dtype=float) - ).tolist(), - log_prior_list=[0.0] * total_samples, - weight_list=(weights / weights.sum()).tolist(), - ) - - @abstractmethod - def _fit(self, model: AbstractPriorModel, analysis: Analysis): - pass - - def check_model(self, model: AbstractPriorModel): - if model is not None and model.prior_count == 0: - raise AssertionError("Model has no priors! Cannot fit a 0 dimension model.") - - def apply_test_mode(self): - """ - Override in subclasses to reduce sampler iterations for test mode. - - Called during __init__ when test mode is active (level 1). - Subclasses should directly mutate instance attributes to minimize - the number of iterations the sampler performs. - """ - pass - - def output_search_internal(self, search_internal): - self.paths.save_search_internal( - obj=search_internal, - ) - - @property - def _updater(self): - # The cached ``SearchUpdater`` must be invalidated whenever - # ``self.paths`` is reassigned to a new object — otherwise the - # updater holds a stale reference to the old paths and writes - # output (samples, visualizations, profiles) under the wrong - # directory. This happens routinely when a single search - # instance is reused across factor optimisations in the EP - # loop: ``AbstractSearch.optimise(factor_approx)`` mutates - # ``self.paths = SubDirectoryPaths(...)`` per factor and per EP - # iteration, but the updater would otherwise stay pinned to - # whichever paths were live the first time ``_updater`` was - # accessed. Identity comparison (``is not``) is the right test: - # the search instance receives a freshly-constructed - # ``SubDirectoryPaths`` each time, never an in-place mutation. - cached = getattr(self, "_search_updater", None) - if cached is None or cached._paths is not self.paths: - from autofit.non_linear.search.updater import SearchUpdater - - self._search_updater = SearchUpdater( - paths=self.paths, - timer=self.timer, - search_logger=self.logger, - plot_results_func=self.plot_results, - samples_from_func=self.samples_from, - disable_output=self.disable_output, - iterations_per_full_update=self.iterations_per_full_update, - ) - return self._search_updater - - def perform_update( - self, - model: AbstractPriorModel, - analysis: Analysis, - during_analysis: bool, - fitness: Optional[Fitness] = None, - search_internal=None, - ) -> Samples: - """ - Perform an update of the non-linear search's model-fitting results. - - Delegates to :class:`SearchUpdater` which separates each output - concern (samples, latent variables, visualization, profiling, - summary) into its own method. - """ - return self._updater.update( - model=model, - analysis=analysis, - during_analysis=during_analysis, - fitness=fitness, - search_internal=search_internal, - ) - - def perform_visualization( - self, - model: AbstractPriorModel, - analysis: Analysis, - during_analysis: bool, - samples_summary: Optional[SamplesSummary] = None, - instance: Optional[ModelInstance] = None, - paths_override: Optional[AbstractPaths] = None, - search_internal=None, - ): - """ - Perform visualization of the non-linear search's model-fitting results. - - Delegates to :class:`SearchUpdater.visualize`. - """ - self._updater.visualize( - model=model, - analysis=analysis, - during_analysis=during_analysis, - samples_summary=samples_summary, - instance=instance, - paths_override=paths_override, - search_internal=search_internal, - ) - - @property - def should_plot_start_point(self) -> bool: - return conf.instance["output"]["start_point"] - - def plot_start_point( - self, - parameter_vector: List[float], - model: AbstractPriorModel, - analysis: Analysis, - ): - """ - Visualize the starting point of the non-linear search, using an instance of the model at the starting point - of the maximum likelihood estimator. - - Plots are output to a folder named `image_start` in the output path, so that the starting point model - can be compared to the final model inferred by the non-linear search. - - Parameters - ---------- - model - The model used by the non-linear search - analysis - The analysis which contains the visualization methods which plot the starting point model. - - Returns - ------- - - """ - - if not self.should_plot_start_point: - return - - self.logger.info(f"Visualizing Starting Point Model in image_start folder.") - - instance = model.instance_from_vector(vector=parameter_vector) - paths = copy.copy(self.paths) - paths.image_path_suffix = "_start" - - self.perform_visualization( - model=model, - analysis=analysis, - instance=instance, - during_analysis=False, - paths_override=paths, - ) - - def samples_from(self, model: AbstractPriorModel, search_internal=None) -> Samples: - """ - Loads the samples of a non-linear search from its output files. - - The samples can be loaded from one of two files, which are attempted to be loading in the following order: - - 1) Load via the internal results of the non-linear search, which are specified to that search's outputs - (e.g. the .hdf file output by the MCMC method `emcee`). - - 2) Load via the `samples.csv` and `samples_info.json` files of the search, which are outputs that are the - same for all non-linear searches as they are homogenized by autofit. - - Parameters - ---------- - model - The model which generates instances for different points in parameter space. - """ - try: - return self.samples_via_internal_from( - model=model, search_internal=search_internal - ) - except (FileNotFoundError, NotImplementedError, AttributeError): - return self.paths.samples - - def samples_via_internal_from( - self, model: AbstractPriorModel, search_internal=None - ): - raise NotImplementedError - - @check_cores - def make_pool(self): - """Make the pool instance used to parallelize a `NonLinearSearch` alongside a set of unique ids for every - process in the pool. If the specified number of cores is 1, a pool instance is not made and None is returned. - - The pool cannot be set as an attribute of the class itself because this prevents pickling, thus it is generated - via this function before calling the non-linear search. - - The pool instance is also set up with a list of unique pool ids, which are used during model-fitting to - identify a 'master core' (the one whose id value is lowest) which handles model result output, visualization, - etc.""" - self.logger.info("...using pool") - return mp.Pool(processes=self.number_of_cores) - - @check_cores - def make_sneaky_pool(self, fitness: Fitness) -> Optional[SneakyPool]: - """ - Create a pool for multiprocessing that uses slight-of-hand - to avoid copying the fitness function between processes - multiple times. - - Parameters - ---------- - fitness - An instance of a fitness class used to evaluate the - likelihood that a particular model is correct - - Returns - ------- - An implementation of a multiprocessing pool - """ - - self.logger.warning( - "...using SneakyPool. This copies the likelihood function " - "to each process on instantiation to avoid copying multiple " - "times." - ) - return SneakyPool( - processes=self.number_of_cores, paths=self.paths, fitness=fitness - ) - - def make_sneakier_pool(self, fitness_function: Fitness, **kwargs) -> SneakierPool: - - self.logger.info(f"number of cores == {self.number_of_cores}") - - if self.number_of_cores > 1: - self.logger.info("Creating SneakierPool...") - else: - self.logger.info("Creating multiprocessing Pool of size 1...") - - pool = SneakierPool( - processes=self.number_of_cores, fitness=fitness_function, **kwargs - ) - - return pool - - def __eq__(self, other): - return isinstance(other, NonLinearSearch) and self.__dict__ == other.__dict__ - - def plot_results(self, samples): - raise NotImplementedError +from __future__ import annotations +import copy +import gc +import logging +import multiprocessing as mp +import numpy as np +import os +import time +import warnings +from abc import ABC, abstractmethod +from collections import Counter +from functools import wraps +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Union, Tuple, List, Dict + +import psutil + +if TYPE_CHECKING: + from autofit.non_linear.result import Result + +from autonerves import conf + +from autonerves.output import should_output + +from autofit import exc +from autofit.database.sqlalchemy_ import sa +from autofit.graphical import ( + MeanField, + AnalysisFactor, + _HierarchicalFactor, + FactorApproximation, +) +from autofit.graphical.utils import Status +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.mapper.model import ModelInstance +from autofit.non_linear.initializer import Initializer +from autofit.non_linear.fitness import Fitness +from autofit.non_linear.parallel import SneakyPool, SneakierPool +from autofit.non_linear.paths.abstract import AbstractPaths +from autofit.non_linear.paths.database import DatabasePaths +from autofit.non_linear.paths.directory import DirectoryPaths +from autofit.non_linear.paths.sub_directory_paths import SubDirectoryPaths +from autofit.non_linear.samples.samples import Samples +from autofit.non_linear.samples.summary import SamplesSummary +from autofit.non_linear.timer import Timer +from autofit.non_linear.analysis import Analysis +from autofit.non_linear.paths.null import NullPaths +from autofit.graphical.declarative.abstract import PriorFactor +from autofit.graphical.expectation_propagation import AbstractFactorOptimiser + +from autofit.non_linear.fitness import get_timeout_seconds +from autofit.non_linear.test_mode import ( + test_mode_level, + test_mode_samples, + skip_fit_output, +) + +logger = logging.getLogger(__name__) + + +def check_cores(func): + """ + Checks how many cores the search has been configured to + use and then returns None instead of calling the pool + creation function in the case that only one core has + been set. + + Parameters + ---------- + func + A function that creates a pool + + Returns + ------- + None or a pool + """ + + @wraps(func) + def wrapper(self, *args, **kwargs): + if self.number_of_cores == 1: + return None + return func(self, *args, **kwargs) + + return wrapper + + +def configure_handler(func): + """ + Add a file handler for logging during the course of the search. + + Optionally outputs 'search.log' to the search's output directory. Can be + turned on or off in the output.yaml file. + + Parameters + ---------- + func + Some function for which logging should be output to file + + Returns + ------- + A decorated version of the function + """ + root_logger = logging.getLogger() + + def decorated(self, *args, **kwargs): + if not should_output("search_log"): + return func(self, *args, **kwargs) + if self.disable_output: + return func(self, *args, **kwargs) + try: + os.makedirs( + self.paths.output_path, + exist_ok=True, + ) + handler = logging.FileHandler(self.paths.output_path / "search.log") + root_logger.addHandler(handler) + except AttributeError: + return func(self, *args, **kwargs) + + try: + return func(self, *args, **kwargs) + finally: + root_logger.removeHandler(handler) + + return decorated + + +class NonLinearSearch(AbstractFactorOptimiser, ABC): + def __init__( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + unique_tag: Optional[str] = None, + initializer: Initializer = None, + iterations_per_quick_update: Optional[int] = None, + iterations_per_full_update: int = None, + live_visual_update: Optional[bool] = None, + number_of_cores: int = 1, + silence: bool = False, + session: Optional[sa.orm.Session] = None, + paths: Optional[AbstractPaths] = None, + **kwargs, + ): + """ + Abstract base class for non-linear searches. + + This class sets up the file structure for the non-linear search, which are standardized across all non-linear + searches. + + Parameters + ---------- + name + The name of the search, controlling the last folder results are output. + path_prefix + The path of folders prefixing the name folder where results are output. + unique_tag + The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database + and also acts as the folder after the path prefix and before the search name. + initializer + Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). + silence + If True, the default print output of the non-linear search is silenced. + session + An SQLAlchemy session instance so the results of the model-fit are written to an SQLite database. + """ + super().__init__() + + if name is None and path_prefix is None: + self.disable_output = True + else: + self.disable_output = False + + from autofit.non_linear.paths.database import DatabasePaths + + if name: + path_prefix = Path(path_prefix or "") + + self.path_prefix = path_prefix + + self.path_prefix_no_unique_tag = path_prefix + + self._logger = None + + self.unique_tag = unique_tag + + if paths: + self.paths = paths + elif session is not None: + logger.debug("Session found. Using database.") + self.paths = DatabasePaths( + name=name, + path_prefix=path_prefix, + session=session, + save_all_samples=kwargs.get("save_all_samples", False), + unique_tag=unique_tag, + ) + elif name is not None or path_prefix: + logger.debug("Session not found. Using directory output.") + self.paths = DirectoryPaths( + name=name, path_prefix=path_prefix, unique_tag=unique_tag + ) + else: + self.paths = NullPaths() + + self.force_pickle_overwrite = conf.instance["general"]["output"][ + "force_pickle_overwrite" + ] + + self.force_visualize_overwrite = conf.instance["general"]["output"][ + "force_visualize_overwrite" + ] + + if initializer is not None: + self.initializer = initializer + + self.iterations_per_quick_update = float((iterations_per_quick_update or + conf.instance["general"]["updates"]["iterations_per_quick_update"])) + + self.iterations_per_full_update = float((iterations_per_full_update or + conf.instance["general"]["updates"]["iterations_per_full_update"])) + + self.quick_update_background = bool( + conf.instance["general"]["updates"].get( + "quick_update_background", False, + ) + ) + + self.live_visual_update = bool( + live_visual_update + if live_visual_update is not None + else conf.instance["general"]["updates"].get( + "live_visual_update", False, + ) + ) + + if conf.instance["general"]["hpc"]["hpc_mode"]: + self.iterations_per_quick_update = float(conf.instance["general"]["hpc"][ + "iterations_per_quick_update" + ]) + self.iterations_per_full_update = float(conf.instance["general"]["hpc"][ + "iterations_per_full_update" + ]) + + self.iterations = 0 + + self.silence = silence + + if conf.instance["general"]["hpc"]["hpc_mode"]: + self.silence = True + + self.kwargs = kwargs + + self.number_of_cores = number_of_cores + + if number_of_cores > 1 and any( + os.environ.get(key) != "1" + for key in ( + "OPENBLAS_NUM_THREADS", + "MKL_NUM_THREADS", + "OMP_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", + "NUMEXPR_NUM_THREADS", + ) + ): + if conf.instance["general"]["parallel"]["warn_environment_variables"]: + warnings.warn(exc.SearchWarning("")) + logger.warning( + """ + The non-linear search is using multiprocessing (number_of_cores>1). + + However, the following environment variables have not been set to 1: + + OPENBLAS_NUM_THREADS + MKL_NUM_THREADS + OMP_NUM_THREADS + VECLIB_MAXIMUM_THREADS + NUMEXPR_NUM_THREADS + + This can lead to performance issues, because both the non-linear search and libraries that may be + used in your `log_likelihood_function` evaluation (e.g. NumPy, SciPy, scikit-learn) may attempt to + parallelize over all cores available. + + This will lead to slow-down, due to overallocation of tasks over the CPUs. + + To mitigate this, set the environment variables to 1 via the following command on your + bash terminal / command line: + + export OPENBLAS_NUM_THREADS=1 + export MKL_NUM_THREADS=1 + export OMP_NUM_THREADS=1 + export VECLIB_MAXIMUM_THREADS=1 + export NUMEXPR_NUM_THREADS=1 + + This means only the non-linear search is parallelized over multiple cores. + + If you "know what you are doing" and do not want these environment variables to be set to one, you + can disable this warning by changing the following entry in the config files: + + `config -> general.yaml -> parallel: -> warn_environment_variables=False` + """ + ) + + self.optimisation_counter = Counter() + + __identifier_fields__ = tuple() + + def optimise( + self, + factor_approx: FactorApproximation, + status: Status = Status(), + ) -> Tuple[MeanField, Status]: + """ + Perform optimisation for expectation propagation. Currently only + applicable for ModelFactors created by the declarative interface. + + 1. Analysis and model classes are extracted from the factor. + 2. Priors are updated from the mean field. + 3. Analysis and model are fit as usual. + 4. A new mean field is constructed with the (posterior) 'linking' priors. + 5. Projection is performed to produce an updated EPMeanField object. + + Output directories are generated according to the factor and the number + of the search. For example a factor called "factor" would output: + + factor/optimization_0/ + factor/optimization_1/ + factor/optimization_2/ + + For the first, second and third optimizations respectively. + + Parameters + ---------- + factor_approx + A collection of messages defining the current best approximation to + some global model + status + + Returns + ------- + An updated approximation to the model having performed optimisation on + a single factor. + """ + + factor = factor_approx.factor + + _ = status + if not isinstance(factor, (AnalysisFactor, PriorFactor, _HierarchicalFactor)): + raise NotImplementedError( + f"Optimizer {self.__class__.__name__} can only be applied to" + f" AnalysisFactors, HierarchicalFactors and PriorFactors" + ) + + model = factor.prior_model.mapper_from_prior_arguments( + { + prior: prior.with_message(message) + for prior, message in factor_approx.cavity_dist.arguments.items() + } + ) + + analysis = factor.analysis + + number = self.optimisation_counter[factor.name] + + self.optimisation_counter[factor.name] += 1 + + self.paths = SubDirectoryPaths( + parent=self.paths, + analysis_name=f"{factor.name}/optimization_{number}", + is_flat=True, + ) + + result = self.fit(model=model, analysis=analysis) + + # Record the sampler's log-evidence of this tilted-distribution fit on + # the projected mean field — the per-factor Ẑₐ that README §5 documents + # `MeanField.log_norm` as carrying (#1332 F7(b)). Previously always 0, + # so `EPMeanField.log_evidence` could not be trusted for model + # comparison in sampler-driven EP fits. Searches with no evidence + # estimate (MCMC / MLE) yield None and keep the 0.0 default — evidence- + # correct model comparison requires nested-sampling factor searches. + # (Both levels guarded: e.g. StaticResult carries no samples at all.) + log_evidence = getattr( + getattr(result, "samples", None), "log_evidence", None + ) + + new_model_dist = MeanField.from_priors( + result.projected_model.priors, + log_norm=log_evidence if log_evidence is not None else 0.0, + ) + + status.result = result + + return new_model_dist, status + + @property + def name(self): + return self.paths.name + + def __getstate__(self): + """ + Remove the logger for pickling + """ + state = self.__dict__.copy() + if "_logger" in state: + del state["_logger"] + if "paths" in state: + del state["paths"] + return state + + @property + def logger(self): + if not hasattr(self, "_logger"): + self._logger = None + if self._logger is None: + logger_ = logging.getLogger(self.name) + self._logger = logger_ + return self._logger + + @property + def timer(self) -> Optional[Timer]: + """ + Returns the timer of the search, which is used to output informaiton such as how long the search took and + how much parallelization sped up the search time. + + If the search is running in `NullPaths` mode, meaning that no output is written to the hard-disk, the timer + is disabled and a `None` is returned. + + Returns + ------- + An object which times the non-linear search. + """ + try: + return Timer(self.paths.search_internal_path) + except TypeError: + pass + + @property + def paths(self) -> Optional[AbstractPaths]: + return self._paths + + @paths.setter + def paths(self, paths: Optional[AbstractPaths]): + if paths is not None: + paths.search = self + self._paths = paths + + def copy_with_paths(self, paths): + self.logger.debug(f"Creating a copy of {self._paths.name}") + search_instance = copy.copy(self) + search_instance.paths = paths + search_instance._logger = None + + return search_instance + + def fit( + self, + model: AbstractPriorModel, + analysis: Analysis, + info: Optional[Dict] = None, + ) -> Union[Result, List[Result]]: + """ + Fit a model, M with some function f that takes instances of the + class represented by model M and gives a score for their fitness. + + A model which represents possible instances with some dimensionality is fit. + + The analysis provides two functions. One visualises an instance of a model and the + other scores an instance based on how well it fits some data. The search + produces instances of the model by picking points in an N dimensional space. + + Parameters + ---------- + analysis + An object that encapsulates the data and a log likelihood function which fits the model to the data + via the non-linear search. + model + The model that is fitted to the data, which is used by the non-linear search to create instances of + the model that are fitted to the data via the log likelihood function. + info + Optional dictionary containing information about the fit that can be saved in the `files` folder + (e.g. as `files/info.json`) and can be loaded via the database. + + Returns + ------- + An object encapsulating how well the model fit the data, the best fit instance + and an updated model with free parameters updated to represent beliefs + produced by this fit. + + Raises + ------ + AssertionError + If the model has 0 dimensions. + """ + self.check_model(model=model) + + if getattr(analysis, "_use_jax", False): + try: + import jax + devices = jax.devices() + device = devices[0] + backend = device.platform.upper() + device_name = getattr(device, "device_kind", backend) + logger.info( + f"Starting non-linear search with JAX ({backend}: {device_name})." + ) + except Exception: + logger.info("Starting non-linear search with JAX.") + else: + logger.info(f"Starting non-linear search with {self.number_of_cores} cores.") + self._log_process_state() + + model = analysis.modify_model(model) + self.paths.model = model + self.paths.unique_tag = self.unique_tag + + self.paths.restore() + + model.freeze() + analysis = analysis.modify_before_fit(paths=self.paths, model=model) + model.unfreeze() + + if not skip_fit_output(): + self.pre_fit_output( + analysis=analysis, + model=model, + info=info, + ) + else: + # Skip mode still needs the metadata + identifier files written + # so downstream aggregator scraping can discover the search + # directory. `save_all` is lightweight (a handful of JSON dumps) + # and skips the expensive `analysis.save_attributes` / + # `visualize_before_fit` calls that `pre_fit_output` would add. + if hasattr(self.paths, "save_all"): + self.paths.save_all( + info=info, + ) + + if not self.paths.is_complete: + result = self.start_resume_fit( + analysis=analysis, + model=model, + ) + else: + result = self.result_via_completed_fit( + analysis=analysis, + model=model, + ) + + if not skip_fit_output(): + analysis = analysis.modify_after_fit( + paths=self.paths, model=model, result=result + ) + + self.post_fit_output( + search_internal=result.search_internal, + ) + + gc.collect() + + self.logger.info("Search complete, returning result") + + return result + + @staticmethod + def _log_process_state(): + total_files = 0 + + for process in psutil.process_iter(attrs=["pid"]): + try: + proc_info = process.as_dict(attrs=["pid"]) + logger.debug( + f"Process ID: {proc_info['pid']} has the following open files:" + ) + + open_files = process.open_files() + for file in open_files: + logger.debug(file) + total_files += 1 + + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + if conf.instance["logging"]["total_files_open"]: + logger.info(f"Total Files Open: {total_files}") + + def pre_fit_output( + self, analysis: Analysis, model: AbstractPriorModel, info: Optional[Dict] = None + ): + """ + Outputs attributes of fit before the non-linear search begins. + + The following attributes of a fit may be output before the search begins: + + - The model composition, which is output as a .json file (`files/model.json`). + + - The non-linear search settings, which are output as a .json file (`files/search.json`). + + - Custom attributes of the analysis defined via the `save_attributes` method of the analysis class, for + example the data (e.g. `files/data.json`). + + - Custom Visualization associated with the analysis, defined via the `visualize_before_fit` + and `visualize_before_fit_combined` methods. This is typically quantities that do not change during the + model-fit (e.g. the data). + + Parameters + ---------- + analysis + An object that encapsulates the data and a log likelihood function which fits the model to the data + via the non-linear search. + model + The model that is fitted to the data, which is used by the non-linear search to create instances of + the model that are fitted to the data via the log likelihood function. + info + Optional dictionary containing information about the fit that can be saved in the `files` folder + (e.g. as `files/info.json`) and can be loaded via the database. + """ + + if not self.disable_output: + self.logger.info(f"The output path of this fit is {self.paths.output_path}") + else: + self.logger.info( + "Output to hard-disk disabled, input a search name to enable." + ) + + if not self.paths.is_complete or self.force_pickle_overwrite: + if not self.disable_output: + self.logger.info( + f"Outputting pre-fit files (e.g. model.info, visualization)." + ) + + self.paths.save_all( + info=info, + ) + analysis.save_attributes(paths=self.paths) + + if analysis.should_visualize(paths=self.paths): + analysis.visualize_before_fit( + paths=self.paths, + model=model, + ) + analysis.visualize_before_fit_combined( + paths=self.paths, + model=model, + ) + + timeout_seconds = get_timeout_seconds() + + if timeout_seconds is not None: + logger.info( + f"\n\n ***Log Likelihood Function timeout is " + f"turned on and set to {timeout_seconds} seconds.***\n" + ) + + @configure_handler + def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Result: + """ + Start a non-linear search from scratch, or resumes one which was previously terminated mid-way through. + + If the search is resumed, the model-fit will begin by loading the samples from the previous search and + from where it left off. + + After the search is completed, a `.completed` file is output so that if the search is resumed in the future + it is not repeated and results are loaded via the `update_completed_fit` method. + + Results are also output to hard-disk in the `files` folder via the `save_results` method of the analysis class. + + Parameters + ---------- + analysis + An object that encapsulates the data and a log likelihood function which fits the model to the data + via the non-linear search. + model + The model that is fitted to the data, which is used by the non-linear search to create instances of + the model that are fitted to the data via the log likelihood function. + + Returns + ------- + The result of the non-linear search, which includes the best-fit model instance and best-fit log likelihood + and errors on the model parameters. + """ + if not isinstance(self.paths, DatabasePaths) and not isinstance( + self.paths, NullPaths + ): + self.timer.start() + + mode = test_mode_level() + if mode >= 2: + return self._fit_bypass_test_mode( + model=model, + analysis=analysis, + call_likelihood=(mode == 2), + ) + + model.freeze() + search_internal, fitness = self._fit( + model=model, + analysis=analysis, + ) + + if hasattr(fitness, "shutdown_quick_update"): + fitness.shutdown_quick_update() + + samples = self.perform_update( + model=model, + analysis=analysis, + search_internal=search_internal, + fitness=fitness, + during_analysis=False, + ) + + result = analysis.make_result( + samples_summary=samples.summary(), + paths=self.paths, + samples=samples, + search_internal=search_internal, + ) + + analysis.save_results(paths=self.paths, result=result) + analysis.save_results_combined(paths=self.paths, result=result) + + model.unfreeze() + + self.paths.completed() + + return result + + def result_via_completed_fit( + self, + analysis: Analysis, + model: AbstractPriorModel, + ) -> Result: + """ + Returns the result of the non-linear search of a completed model-fit. + + The result contains the non-linear search samples summary, which contains the maximum log likelihood instance + that is used for visualization and prior passing via the search chaining API. + + This funciton may also load the full samples of the completed fit, for example if visualization of the + seatch chains (e.g. a corner plot) is performed. This task is optional and be slow due to loading times. + + Optional tasks can be performed to update the results of the model-fit on hard-disk depending on the following + entries of the `general.yaml` config file's `output` section: + + ` `force_visualize_overwrite=True`: the visualization of the model-fit is performed again (e.g. to + add new visualizations or replot figures with a different source code). + + - `force_pickle_overwrite=True`: the output files of the model-fit are recreated (e.g. to add a new attribute + that was previously not output). + + Parameters + ---------- + analysis + An object that encapsulates the data and a log likelihood function which fits the model to the data + via the non-linear search. + model + The model that is fitted to the data, which is used by the non-linear search to create instances of + the model that are fitted to the data via the log likelihood function. + + Returns + ------- + The result of the non-linear search, which includes the best-fit model instance and best-fit log likelihood + and errors on the model parameters. + """ + + model.freeze() + samples_summary = self.paths.load_samples_summary() + try: + samples = self.paths.samples + except FileNotFoundError: + samples = None + + result = analysis.make_result( + samples_summary=samples_summary, + samples=samples, + paths=self.paths, + ) + + self.logger.info(f"Fit Already Completed: skipping non-linear search.") + + if self.force_visualize_overwrite: + self.perform_visualization( + model=model, + analysis=analysis, + samples_summary=samples_summary, + during_analysis=False, + ) + + if self.force_pickle_overwrite: + self.logger.info("Forcing pickle overwrite") + + analysis.save_results(paths=self.paths, result=result) + analysis.save_results_combined(paths=self.paths, result=result) + + model.unfreeze() + + return result + + def post_fit_output(self, search_internal): + """ + Cleans up the output folderds after a completed non-linear search. + + The main task this performs is removing the folder containing the results of a non-linear search such that only + its corresponding `.zip` file is left. This is use for supercomputers, where users often have a file limit on + the number of files they can store in their home directory, so storing them all in just a .zip file is + advantageous. + + This only occurs if `remove_files=False` in the `general.yaml` config file's `output` section. + + Parameters + ---------- + search_internal + The internal search. + """ + if not conf.instance["output"]["search_internal"]: + self.logger.info("Removing search internal folder.") + self.paths.remove_search_internal() + elif search_internal is not None: + self.output_search_internal(search_internal=search_internal) + + if not self.disable_output: + self.logger.info("Removing all files except for .zip file") + + self.paths.zip_remove() + + def _fit_bypass_test_mode( + self, + model: AbstractPriorModel, + analysis: Analysis, + call_likelihood: bool = True, + ): + """ + Bypass the sampler entirely in test mode (levels 2 and 3). + + Generates fake samples and writes all expected output files so that + downstream code sees a complete result folder. + + Parameters + ---------- + model + The model being fitted. + analysis + The analysis object with the log likelihood function. + call_likelihood + If True (mode 2), call the likelihood function once to verify it + works. If False (mode 3), skip the likelihood call entirely. + """ + from autofit.non_linear.samples.pdf import SamplesPDF + from autofit.non_linear.samples.sample import Sample + + mode = test_mode_level() + if mode == 2: + logger.warning( + "TEST MODE 2 (bypass + likelihood): Skipping sampler, " + "calling likelihood function once to verify it works." + ) + else: + logger.warning( + "TEST MODE 3 (full bypass): Skipping sampler and likelihood " + "entirely for maximum speed. No likelihood verification." + ) + + model.freeze() + + unit_vector = [0.5] * model.prior_count + parameter_vector = [ + float(v) for v in model.vector_from_unit_vector( + unit_vector=unit_vector, + ) + ] + + log_likelihood = -1.0e99 + if call_likelihood: + instance = model.instance_from_vector(vector=parameter_vector) + try: + log_likelihood = float( + analysis.log_likelihood_function(instance) + ) + except exc.FitException as e: + # A `FitException` means this particular instance is pathological + # (e.g. a non-positive-definite inversion, or a degenerate mesh + # that yields NaN vertices). In a real search the sampler absorbs + # this by resampling; test mode has no sampler, so a single + # unlucky verification eval must not hard-fail the run. Keep the + # `-1.0e99` sentinel — the same effect a resample-to-reject has — + # and log the cause so a genuinely broken likelihood stays visible. + # Only `FitException` is caught: real code errors still propagate. + logger.warning( + "TEST MODE 2: likelihood verification raised FitException " + f"({e.__cause__ or e!r}); treating as a resample-rejected " + "instance and continuing with the sentinel log likelihood." + ) + + sample_list = self._build_fake_samples( + model=model, + parameter_vector=parameter_vector, + log_likelihood=log_likelihood, + ) + + # Stub log_evidence in samples_info so downstream arithmetic + # (grid search log_evidences, subhalo Bayesian model comparison, + # scrape aggregator assertions) doesn't crash on None. SamplesPDF + # reads log_evidence from samples_info. + samples_info = { + "total_iterations": 1, + "time": 0.0, + "log_evidence": log_likelihood, + } + samples_info.update(self._test_mode_samples_info()) + samples = SamplesPDF( + model=model, + sample_list=sample_list, + samples_info=samples_info, + ) + + samples_summary = samples.summary() + + # Persist samples + summary to disk so downstream code that reads + # from the output folder (database scrape, paths.load_samples_summary) + # sees a complete result. Matches the docstring's promise. NullPaths + # and DatabasePaths both handle these calls safely. + self.paths.save_samples_summary(samples_summary=samples_summary) + self.paths.save_samples(samples=samples) + + result = analysis.make_result( + samples_summary=samples_summary, + paths=self.paths, + samples=samples, + search_internal=None, + ) + + model.unfreeze() + + # Mark the fit complete, exactly as start_resume_fit does — a bypassed + # fit must be resumable (paths.is_complete -> result_via_completed_fit + # on the next run), or every rerun re-bypasses the whole pipeline. + self.paths.completed() + + return result + + def _test_mode_samples_info(self) -> dict: + """ + Sampler-specific keys to merge into ``samples_info`` when the + sampler is bypassed via ``PYAUTO_TEST_MODE=2`` or ``=3``. + + Override in subclasses to add the diagnostic keys that the real + run would populate (e.g. NUTS ESS, MCMC autocorrelations) so that + tutorial scripts and downstream code can access those keys + without ``KeyError``. Use NaN/0 placeholders — the bypass did not + actually sample. + """ + return {} + + @staticmethod + def _build_fake_samples(model, parameter_vector, log_likelihood): + """ + Build a list of fake Sample objects for test mode bypass. + + Creates a deterministic sample set: the "best" at the prior median + and additional slightly perturbed parameters with worse likelihoods. + The default of four samples keeps bypass mode cheap while allowing + downstream structural checks to exercise multi-batch sample handling. + + ``PYAUTO_TEST_MODE_SAMPLES=N`` raises the sample count so the + bypass run's ``samples.csv`` row count and byte size match a + production sampler stage (N ~ 10k-100k), keeping resume/load + timings measured against the output honest. The N > 4 samples are + synthesized vectorized (numpy) then materialised through the same + ``Sample.from_lists`` path a real sampler run uses, so structure + and cost are representative by construction. The best (first) + sample is the unperturbed prior median in both branches. + """ + from autofit.non_linear.samples.sample import Sample + + total_samples = test_mode_samples() + + if total_samples == 4: + parameter_lists = [parameter_vector] + for scale in (1.001, 0.999, 1.002): + parameter_lists.append( + [p * scale if p != 0.0 else scale - 1.0 for p in parameter_vector] + ) + + return Sample.from_lists( + model=model, + parameter_lists=parameter_lists, + log_likelihood_list=[ + log_likelihood - offset for offset in range(len(parameter_lists)) + ], + log_prior_list=[0.0] * len(parameter_lists), + weight_list=[1.0, 0.5, 0.25, 0.125], + ) + + rng = np.random.default_rng(0) + base = np.asarray(parameter_vector, dtype=float) + scatter = 1.0e-3 * rng.standard_normal((total_samples, base.shape[0])) + parameters = np.where(base == 0.0, scatter, base * (1.0 + scatter)) + parameters[0] = base + + # Weights decay over ~N/10 samples so the effective sample size stays + # a healthy fraction of N and the smallest weight, ~(10/N)e^-10, sits + # above the output.yaml samples_weight_threshold of 1e-10 for N <= 1e5. + weights = np.exp( + -np.arange(total_samples, dtype=float) / (total_samples / 10.0) + ) + + return Sample.from_lists( + model=model, + parameter_lists=parameters.tolist(), + log_likelihood_list=( + log_likelihood - np.arange(total_samples, dtype=float) + ).tolist(), + log_prior_list=[0.0] * total_samples, + weight_list=(weights / weights.sum()).tolist(), + ) + + @abstractmethod + def _fit(self, model: AbstractPriorModel, analysis: Analysis): + pass + + def check_model(self, model: AbstractPriorModel): + if model is not None and model.prior_count == 0: + raise AssertionError("Model has no priors! Cannot fit a 0 dimension model.") + + def apply_test_mode(self): + """ + Override in subclasses to reduce sampler iterations for test mode. + + Called during __init__ when test mode is active (level 1). + Subclasses should directly mutate instance attributes to minimize + the number of iterations the sampler performs. + """ + pass + + def output_search_internal(self, search_internal): + self.paths.save_search_internal( + obj=search_internal, + ) + + @property + def _updater(self): + # The cached ``SearchUpdater`` must be invalidated whenever + # ``self.paths`` is reassigned to a new object — otherwise the + # updater holds a stale reference to the old paths and writes + # output (samples, visualizations, profiles) under the wrong + # directory. This happens routinely when a single search + # instance is reused across factor optimisations in the EP + # loop: ``AbstractSearch.optimise(factor_approx)`` mutates + # ``self.paths = SubDirectoryPaths(...)`` per factor and per EP + # iteration, but the updater would otherwise stay pinned to + # whichever paths were live the first time ``_updater`` was + # accessed. Identity comparison (``is not``) is the right test: + # the search instance receives a freshly-constructed + # ``SubDirectoryPaths`` each time, never an in-place mutation. + cached = getattr(self, "_search_updater", None) + if cached is None or cached._paths is not self.paths: + from autofit.non_linear.search.updater import SearchUpdater + + self._search_updater = SearchUpdater( + paths=self.paths, + timer=self.timer, + search_logger=self.logger, + plot_results_func=self.plot_results, + samples_from_func=self.samples_from, + disable_output=self.disable_output, + iterations_per_full_update=self.iterations_per_full_update, + ) + return self._search_updater + + def perform_update( + self, + model: AbstractPriorModel, + analysis: Analysis, + during_analysis: bool, + fitness: Optional[Fitness] = None, + search_internal=None, + ) -> Samples: + """ + Perform an update of the non-linear search's model-fitting results. + + Delegates to :class:`SearchUpdater` which separates each output + concern (samples, latent variables, visualization, profiling, + summary) into its own method. + """ + return self._updater.update( + model=model, + analysis=analysis, + during_analysis=during_analysis, + fitness=fitness, + search_internal=search_internal, + ) + + def perform_visualization( + self, + model: AbstractPriorModel, + analysis: Analysis, + during_analysis: bool, + samples_summary: Optional[SamplesSummary] = None, + instance: Optional[ModelInstance] = None, + paths_override: Optional[AbstractPaths] = None, + search_internal=None, + ): + """ + Perform visualization of the non-linear search's model-fitting results. + + Delegates to :class:`SearchUpdater.visualize`. + """ + self._updater.visualize( + model=model, + analysis=analysis, + during_analysis=during_analysis, + samples_summary=samples_summary, + instance=instance, + paths_override=paths_override, + search_internal=search_internal, + ) + + @property + def should_plot_start_point(self) -> bool: + return conf.instance["output"]["start_point"] + + def plot_start_point( + self, + parameter_vector: List[float], + model: AbstractPriorModel, + analysis: Analysis, + ): + """ + Visualize the starting point of the non-linear search, using an instance of the model at the starting point + of the maximum likelihood estimator. + + Plots are output to a folder named `image_start` in the output path, so that the starting point model + can be compared to the final model inferred by the non-linear search. + + Parameters + ---------- + model + The model used by the non-linear search + analysis + The analysis which contains the visualization methods which plot the starting point model. + + Returns + ------- + + """ + + if not self.should_plot_start_point: + return + + self.logger.info(f"Visualizing Starting Point Model in image_start folder.") + + instance = model.instance_from_vector(vector=parameter_vector) + paths = copy.copy(self.paths) + paths.image_path_suffix = "_start" + + self.perform_visualization( + model=model, + analysis=analysis, + instance=instance, + during_analysis=False, + paths_override=paths, + ) + + def samples_from(self, model: AbstractPriorModel, search_internal=None) -> Samples: + """ + Loads the samples of a non-linear search from its output files. + + The samples can be loaded from one of two files, which are attempted to be loading in the following order: + + 1) Load via the internal results of the non-linear search, which are specified to that search's outputs + (e.g. the .hdf file output by the MCMC method `emcee`). + + 2) Load via the `samples.csv` and `samples_info.json` files of the search, which are outputs that are the + same for all non-linear searches as they are homogenized by autofit. + + Parameters + ---------- + model + The model which generates instances for different points in parameter space. + """ + try: + return self.samples_via_internal_from( + model=model, search_internal=search_internal + ) + except (FileNotFoundError, NotImplementedError, AttributeError): + return self.paths.samples + + def samples_via_internal_from( + self, model: AbstractPriorModel, search_internal=None + ): + raise NotImplementedError + + @check_cores + def make_pool(self): + """Make the pool instance used to parallelize a `NonLinearSearch` alongside a set of unique ids for every + process in the pool. If the specified number of cores is 1, a pool instance is not made and None is returned. + + The pool cannot be set as an attribute of the class itself because this prevents pickling, thus it is generated + via this function before calling the non-linear search. + + The pool instance is also set up with a list of unique pool ids, which are used during model-fitting to + identify a 'master core' (the one whose id value is lowest) which handles model result output, visualization, + etc.""" + self.logger.info("...using pool") + return mp.Pool(processes=self.number_of_cores) + + @check_cores + def make_sneaky_pool(self, fitness: Fitness) -> Optional[SneakyPool]: + """ + Create a pool for multiprocessing that uses slight-of-hand + to avoid copying the fitness function between processes + multiple times. + + Parameters + ---------- + fitness + An instance of a fitness class used to evaluate the + likelihood that a particular model is correct + + Returns + ------- + An implementation of a multiprocessing pool + """ + + self.logger.warning( + "...using SneakyPool. This copies the likelihood function " + "to each process on instantiation to avoid copying multiple " + "times." + ) + return SneakyPool( + processes=self.number_of_cores, paths=self.paths, fitness=fitness + ) + + def make_sneakier_pool(self, fitness_function: Fitness, **kwargs) -> SneakierPool: + + self.logger.info(f"number of cores == {self.number_of_cores}") + + if self.number_of_cores > 1: + self.logger.info("Creating SneakierPool...") + else: + self.logger.info("Creating multiprocessing Pool of size 1...") + + pool = SneakierPool( + processes=self.number_of_cores, fitness=fitness_function, **kwargs + ) + + return pool + + def __eq__(self, other): + return isinstance(other, NonLinearSearch) and self.__dict__ == other.__dict__ + + def plot_results(self, samples): + raise NotImplementedError diff --git a/autofit/non_linear/search/mcmc/auto_correlations.py b/autofit/non_linear/search/mcmc/auto_correlations.py index 7c17fdde2..1de28b89c 100644 --- a/autofit/non_linear/search/mcmc/auto_correlations.py +++ b/autofit/non_linear/search/mcmc/auto_correlations.py @@ -1,115 +1,115 @@ -import numpy as np -import os - -from typing import Optional - -from autofit.non_linear.test_mode import is_test_mode - -class AutoCorrelationsSettings: - - def __init__( - self, - check_for_convergence: bool = True, - check_size: int = 100, - required_length: int = 50, - change_threshold: float = 0.01, - ): - """ - Class for performing and customizing AutoCorrelation calculations, which are used: - - - By the `Samples` object during a model-fit to determine is an ensemble MCMC sampler should terminate. - - After a model-fit is finished to investigate whether the resutls converged. - - Parameters - ---------- - check_for_convergence - Whether the auto-correlation lengths of the Emcee samples are checked to determine the stopping criteria. - If `True`, this option may terminate the Emcee run before the input number of steps, nsteps, has - been performed. If `False` nstep samples will be taken. - check_size - The length of the samples used to check the auto-correlation lengths (from the latest sample backwards). - For convergence, the auto-correlations must not change over a certain range of samples. A longer check-size - thus requires more samples meet the auto-correlation threshold, taking longer to terminate sampling. - However, shorter chains risk stopping sampling early due to noise. - required_length - The length an auto_correlation chain must be for it to be used to evaluate whether its change threshold is - sufficiently small to terminate sampling early. - change_threshold - The threshold value by which if the change in auto_correlations is below sampling will be terminated early. - """ - self.check_for_convergence = check_for_convergence - self.check_size = check_size - self.required_length = required_length - self.change_threshold = change_threshold - - if is_test_mode(): - self.check_size = 1 - - -class AutoCorrelations(AutoCorrelationsSettings): - - def __init__( - self, - check_size, - required_length, - change_threshold, - times, - previous_times, - - ): - """ - Class for performing and customizing AutoCorrelation calculations, which are used: - - - By the `Samples` object during a model-fit to determine is an ensemble MCMC sampler should terminate. - - After a model-fit is finished to investigate whether the resutls converged. - - Parameters - ---------- - check_for_convergence - Whether the auto-correlation lengths of the Emcee samples are checked to determine the stopping criteria. - If `True`, this option may terminate the Emcee run before the input number of steps, nsteps, has - been performed. If `False` nstep samples will be taken. - check_size - The length of the samples used to check the auto-correlation lengths (from the latest sample backwards). - For convergence, the auto-correlations must not change over a certain range of samples. A longer check-size - thus requires more samples meet the auto-correlation threshold, taking longer to terminate sampling. - However, shorter chains risk stopping sampling early due to noise. - required_length - The length an auto_correlation chain must be for it to be used to evaluate whether its change threshold is - sufficiently small to terminate sampling early. - change_threshold - The threshold value by which if the change in auto_correlations is below sampling will be terminated early. - """ - - super().__init__( - check_size=check_size, - required_length=required_length, - change_threshold=change_threshold - ) - - self.times = times - self.previous_times = previous_times - - @property - def relative_times(self) -> [float]: - return np.abs(self.previous_times - self.times) / self.times - - def check_if_converged(self, total_samples): - """ - Whether the emcee samples have converged on a solution or if they are still in a burn-in period, based on the - auto correlation times of parameters. - """ - - if self.times is None or self.previous_times is None: - return False - - converged = np.all( - self.times * self.required_length - < total_samples - ) - if converged: - try: - converged &= np.all(self.relative_times < self.change_threshold) - except IndexError: - return False - return converged +import numpy as np +import os + +from typing import Optional + +from autofit.non_linear.test_mode import is_test_mode + +class AutoCorrelationsSettings: + + def __init__( + self, + check_for_convergence: bool = True, + check_size: int = 100, + required_length: int = 50, + change_threshold: float = 0.01, + ): + """ + Class for performing and customizing AutoCorrelation calculations, which are used: + + - By the `Samples` object during a model-fit to determine is an ensemble MCMC sampler should terminate. + - After a model-fit is finished to investigate whether the resutls converged. + + Parameters + ---------- + check_for_convergence + Whether the auto-correlation lengths of the Emcee samples are checked to determine the stopping criteria. + If `True`, this option may terminate the Emcee run before the input number of steps, nsteps, has + been performed. If `False` nstep samples will be taken. + check_size + The length of the samples used to check the auto-correlation lengths (from the latest sample backwards). + For convergence, the auto-correlations must not change over a certain range of samples. A longer check-size + thus requires more samples meet the auto-correlation threshold, taking longer to terminate sampling. + However, shorter chains risk stopping sampling early due to noise. + required_length + The length an auto_correlation chain must be for it to be used to evaluate whether its change threshold is + sufficiently small to terminate sampling early. + change_threshold + The threshold value by which if the change in auto_correlations is below sampling will be terminated early. + """ + self.check_for_convergence = check_for_convergence + self.check_size = check_size + self.required_length = required_length + self.change_threshold = change_threshold + + if is_test_mode(): + self.check_size = 1 + + +class AutoCorrelations(AutoCorrelationsSettings): + + def __init__( + self, + check_size, + required_length, + change_threshold, + times, + previous_times, + + ): + """ + Class for performing and customizing AutoCorrelation calculations, which are used: + + - By the `Samples` object during a model-fit to determine is an ensemble MCMC sampler should terminate. + - After a model-fit is finished to investigate whether the resutls converged. + + Parameters + ---------- + check_for_convergence + Whether the auto-correlation lengths of the Emcee samples are checked to determine the stopping criteria. + If `True`, this option may terminate the Emcee run before the input number of steps, nsteps, has + been performed. If `False` nstep samples will be taken. + check_size + The length of the samples used to check the auto-correlation lengths (from the latest sample backwards). + For convergence, the auto-correlations must not change over a certain range of samples. A longer check-size + thus requires more samples meet the auto-correlation threshold, taking longer to terminate sampling. + However, shorter chains risk stopping sampling early due to noise. + required_length + The length an auto_correlation chain must be for it to be used to evaluate whether its change threshold is + sufficiently small to terminate sampling early. + change_threshold + The threshold value by which if the change in auto_correlations is below sampling will be terminated early. + """ + + super().__init__( + check_size=check_size, + required_length=required_length, + change_threshold=change_threshold + ) + + self.times = times + self.previous_times = previous_times + + @property + def relative_times(self) -> [float]: + return np.abs(self.previous_times - self.times) / self.times + + def check_if_converged(self, total_samples): + """ + Whether the emcee samples have converged on a solution or if they are still in a burn-in period, based on the + auto correlation times of parameters. + """ + + if self.times is None or self.previous_times is None: + return False + + converged = np.all( + self.times * self.required_length + < total_samples + ) + if converged: + try: + converged &= np.all(self.relative_times < self.change_threshold) + except IndexError: + return False + return converged diff --git a/autofit/non_linear/search/mcmc/emcee/search.py b/autofit/non_linear/search/mcmc/emcee/search.py index 43a455c63..a1c3d7412 100644 --- a/autofit/non_linear/search/mcmc/emcee/search.py +++ b/autofit/non_linear/search/mcmc/emcee/search.py @@ -1,380 +1,380 @@ -import logging -import os -from pathlib import Path -from typing import Dict, Optional - -import numpy as np - -from autonerves import conf - -from autofit.database.sqlalchemy_ import sa -from autofit.mapper.model_mapper import ModelMapper -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.fitness import Fitness -from autofit.non_linear.initializer import Initializer -from autofit.non_linear.search.mcmc.abstract_mcmc import AbstractMCMC -from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings -from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelations -from autofit.non_linear.test_mode import is_test_mode -from autofit.non_linear.samples.sample import Sample -from autofit.non_linear.samples.mcmc import SamplesMCMC - -logger = logging.getLogger(__name__) - - -class Emcee(AbstractMCMC): - __identifier_fields__ = ("nwalkers",) - - def __init__( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - unique_tag: Optional[str] = None, - nwalkers: int = 50, - nsteps: int = 2000, - initializer: Optional[Initializer] = None, - auto_correlation_settings=AutoCorrelationsSettings(), - iterations_per_quick_update: int = None, - iterations_per_full_update: int = None, - number_of_cores: int = 1, - silence: bool = False, - session: Optional[sa.orm.Session] = None, - **kwargs, - ): - """ - An Emcee non-linear search. - - For a full description of Emcee, checkout its Github and readthedocs webpages: - - https://github.com/dfm/emcee - - https://emcee.readthedocs.io/en/stable/ - - If you use `Emcee` as part of a published work, please cite the package following the instructions under the - *Attribution* section of the GitHub page. - - Parameters - ---------- - name - The name of the search, controlling the last folder results are output. - path_prefix - The path of folders prefixing the name folder where results are output. - unique_tag - The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database - and also acts as the folder after the path prefix and before the search name. - nwalkers - The number of walkers in the ensemble used to sample parameter space. - nsteps - The number of steps that must be taken by every walker. - initializer - Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). - auto_correlation_settings - Customizes and performs auto correlation calculations performed during and after the search. - number_of_cores - The number of cores sampling is performed using a Python multiprocessing Pool instance. - silence - If True, the default print output of the non-linear search is silenced. - session - An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. - """ - - super().__init__( - name=name, - path_prefix=path_prefix, - unique_tag=unique_tag, - initializer=initializer, - auto_correlation_settings=auto_correlation_settings, - iterations_per_quick_update=iterations_per_quick_update, - iterations_per_full_update=iterations_per_full_update, - number_of_cores=number_of_cores, - silence=silence, - session=session, - **kwargs, - ) - - self.nwalkers = nwalkers - self.nsteps = nsteps - - if is_test_mode(): - self.apply_test_mode() - - self.logger.debug("Creating Emcee Search") - - conf.instance["output"]["search_internal"] = True - - def apply_test_mode(self): - logger.warning( - "TEST MODE 1 (reduced iterations): Sampler will run with " - "minimal iterations for faster completion." - ) - self.nwalkers = 20 - self.nsteps = 10 - - def _fit(self, model: AbstractPriorModel, analysis): - """ - Fit a model using Emcee and the Analysis class which contains the data and returns the log likelihood from - instances of the model, which the `NonLinearSearch` seeks to maximize. - - Parameters - ---------- - model : ModelMapper - The model which generates instances for different points in parameter space. - analysis : Analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the `NonLinearSearch` maximizes. - - Returns - ------- - A result object comprising the Samples object that inclues the maximum log likelihood instance and full - chains used by the fit. - """ - import emcee - - fitness = Fitness( - model=model, - analysis=analysis, - paths=self.paths, - fom_is_log_likelihood=False, - resample_figure_of_merit=-np.inf, - ) - - pool = self.make_sneaky_pool(fitness) - - try: - backend = emcee.backends.HDFBackend(filename=self.backend_filename) - except TypeError: - backend = None - - search_internal = emcee.EnsembleSampler( - nwalkers=self.nwalkers, - ndim=model.prior_count, - log_prob_fn=fitness.call_wrap, - backend=backend, - pool=pool, - ) - - try: - state = search_internal.get_last_sample() - samples = self.samples_from(model=model, search_internal=search_internal) - - total_iterations = search_internal.iteration - - if samples.converged: - iterations_remaining = 0 - else: - iterations_remaining = self.nsteps - total_iterations - - self.logger.info( - "Resuming Emcee non-linear search (previous samples found)." - ) - - except AttributeError: - ( - unit_parameter_lists, - parameter_lists, - log_posterior_list, - ) = self.initializer.samples_from_model( - total_points=search_internal.nwalkers, - model=model, - fitness=fitness, - paths=self.paths, - n_cores=self.number_of_cores, - ) - - self.plot_start_point( - parameter_vector=parameter_lists[0], - model=model, - analysis=analysis, - ) - - state = np.zeros(shape=(search_internal.nwalkers, model.prior_count)) - - self.logger.info( - "Starting new Emcee non-linear search (no previous samples found)." - ) - - for index, parameters in enumerate(parameter_lists): - state[index, :] = np.asarray(parameters) - - total_iterations = 0 - iterations_remaining = self.nsteps - - while iterations_remaining > 0: - if self.iterations_per_full_update > iterations_remaining: - iterations = iterations_remaining - else: - iterations = self.iterations_per_full_update - - for sample in search_internal.sample( - initial_state=state, - iterations=iterations, - progress=True, - skip_initial_state_check=True, - store=True, - ): - pass - - state = search_internal.get_last_sample() - - total_iterations += iterations - iterations_remaining = self.nsteps - total_iterations - - samples = self.samples_from(model=model, search_internal=search_internal) - - if self.auto_correlation_settings.check_for_convergence: - if ( - search_internal.iteration - > self.auto_correlation_settings.check_size - ): - if samples.converged: - iterations_remaining = 0 - - if iterations_remaining > 0: - self.perform_update( - model=model, - analysis=analysis, - search_internal=search_internal, - fitness=fitness, - during_analysis=True, - ) - - return search_internal, fitness - - def output_search_internal(self, search_internal): - """ - Output the sampler results to hard-disk in their internal format. - - Emcee uses a backend to store and load results, therefore the outputting of the search internal to a - dill file is disabled. - - Parameters - ---------- - sampler - The nautilus sampler object containing the results of the model-fit. - """ - pass - - def samples_info_from(self, search_internal=None): - search_internal = search_internal or self.backend - - auto_correlations = self.auto_correlations_from(search_internal=search_internal) - - return { - "check_size": auto_correlations.check_size, - "required_length": auto_correlations.required_length, - "change_threshold": auto_correlations.change_threshold, - "total_walkers": len(search_internal.get_chain()[0, :, 0]), - "total_steps": len(search_internal.get_log_prob()), - "time": self.timer.time if self.timer else None, - } - - def samples_via_internal_from(self, model, search_internal=None): - """ - Returns a `Samples` object from the emcee internal results. - - The samples contain all information on the parameter space sampling (e.g. the parameters, - log likelihoods, etc.). - - The internal search results are converted from the native format used by the search to lists of values - (e.g. `parameter_lists`, `log_likelihood_list`). - - Parameters - ---------- - model - Maps input vectors of unit parameter values to physical values and model instances via priors. - """ - - search_internal = search_internal or self.backend - - if is_test_mode(): - samples_after_burn_in = search_internal.get_chain( - discard=5, thin=5, flat=True - ) - - else: - auto_correlations = self.auto_correlations_from( - search_internal=search_internal - ) - - discard = int(3.0 * np.max(auto_correlations.times)) - thin = int(np.max(auto_correlations.times) / 2.0) - samples_after_burn_in = search_internal.get_chain( - discard=discard, thin=thin, flat=True - ) - - parameter_lists = samples_after_burn_in.tolist() - - log_prior_list = model.log_prior_list_from(parameter_lists=parameter_lists) - - total_samples = len(parameter_lists) - - log_posterior_list = search_internal.get_log_prob(flat=True)[ - -total_samples - 1 : -1 - ].tolist() - - log_likelihood_list = [ - log_posterior - log_prior - for log_posterior, log_prior in zip(log_posterior_list, log_prior_list) - ] - - weight_list = len(log_likelihood_list) * [1.0] - - sample_list = Sample.from_lists( - model=model, - parameter_lists=parameter_lists, - log_likelihood_list=log_likelihood_list, - log_prior_list=log_prior_list, - weight_list=weight_list, - ) - - return SamplesMCMC( - model=model, - sample_list=sample_list, - samples_info=self.samples_info_from(search_internal=search_internal), - auto_correlation_settings=self.auto_correlation_settings, - auto_correlations=self.auto_correlations_from( - search_internal=search_internal - ), - ) - - def auto_correlations_from(self, search_internal=None): - import emcee - - search_internal = search_internal or self.backend - - times = search_internal.get_autocorr_time(tol=0) - - previous_auto_correlation_times = emcee.autocorr.integrated_time( - x=search_internal.get_chain()[ - : -self.auto_correlation_settings.check_size, :, : - ], - tol=0, - ) - - return AutoCorrelations( - check_size=self.auto_correlation_settings.check_size, - required_length=self.auto_correlation_settings.required_length, - change_threshold=self.auto_correlation_settings.change_threshold, - times=times, - previous_times=previous_auto_correlation_times, - ) - - @property - def backend_filename(self): - return self.paths.search_internal_path / "search_internal.hdf" - - @property - def backend(self) -> "emcee.backends.HDFBackend": - """ - The `Emcee` hdf5 backend, which provides access to all samples, likelihoods, etc. of the non-linear search. - - The sampler is described in the "Results" section at https://dynesty.readthedocs.io/en/latest/quickstart.html - """ - import emcee - - if Path(self.backend_filename).is_file(): - return emcee.backends.HDFBackend(filename=str(self.backend_filename)) - else: - raise FileNotFoundError( - f"The file search_internal.hdf does not exist at the path {self.paths.search_internal_path}" - ) +import logging +import os +from pathlib import Path +from typing import Dict, Optional + +import numpy as np + +from autonerves import conf + +from autofit.database.sqlalchemy_ import sa +from autofit.mapper.model_mapper import ModelMapper +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.fitness import Fitness +from autofit.non_linear.initializer import Initializer +from autofit.non_linear.search.mcmc.abstract_mcmc import AbstractMCMC +from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings +from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelations +from autofit.non_linear.test_mode import is_test_mode +from autofit.non_linear.samples.sample import Sample +from autofit.non_linear.samples.mcmc import SamplesMCMC + +logger = logging.getLogger(__name__) + + +class Emcee(AbstractMCMC): + __identifier_fields__ = ("nwalkers",) + + def __init__( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + unique_tag: Optional[str] = None, + nwalkers: int = 50, + nsteps: int = 2000, + initializer: Optional[Initializer] = None, + auto_correlation_settings=AutoCorrelationsSettings(), + iterations_per_quick_update: int = None, + iterations_per_full_update: int = None, + number_of_cores: int = 1, + silence: bool = False, + session: Optional[sa.orm.Session] = None, + **kwargs, + ): + """ + An Emcee non-linear search. + + For a full description of Emcee, checkout its Github and readthedocs webpages: + + https://github.com/dfm/emcee + + https://emcee.readthedocs.io/en/stable/ + + If you use `Emcee` as part of a published work, please cite the package following the instructions under the + *Attribution* section of the GitHub page. + + Parameters + ---------- + name + The name of the search, controlling the last folder results are output. + path_prefix + The path of folders prefixing the name folder where results are output. + unique_tag + The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database + and also acts as the folder after the path prefix and before the search name. + nwalkers + The number of walkers in the ensemble used to sample parameter space. + nsteps + The number of steps that must be taken by every walker. + initializer + Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). + auto_correlation_settings + Customizes and performs auto correlation calculations performed during and after the search. + number_of_cores + The number of cores sampling is performed using a Python multiprocessing Pool instance. + silence + If True, the default print output of the non-linear search is silenced. + session + An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. + """ + + super().__init__( + name=name, + path_prefix=path_prefix, + unique_tag=unique_tag, + initializer=initializer, + auto_correlation_settings=auto_correlation_settings, + iterations_per_quick_update=iterations_per_quick_update, + iterations_per_full_update=iterations_per_full_update, + number_of_cores=number_of_cores, + silence=silence, + session=session, + **kwargs, + ) + + self.nwalkers = nwalkers + self.nsteps = nsteps + + if is_test_mode(): + self.apply_test_mode() + + self.logger.debug("Creating Emcee Search") + + conf.instance["output"]["search_internal"] = True + + def apply_test_mode(self): + logger.warning( + "TEST MODE 1 (reduced iterations): Sampler will run with " + "minimal iterations for faster completion." + ) + self.nwalkers = 20 + self.nsteps = 10 + + def _fit(self, model: AbstractPriorModel, analysis): + """ + Fit a model using Emcee and the Analysis class which contains the data and returns the log likelihood from + instances of the model, which the `NonLinearSearch` seeks to maximize. + + Parameters + ---------- + model : ModelMapper + The model which generates instances for different points in parameter space. + analysis : Analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the `NonLinearSearch` maximizes. + + Returns + ------- + A result object comprising the Samples object that inclues the maximum log likelihood instance and full + chains used by the fit. + """ + import emcee + + fitness = Fitness( + model=model, + analysis=analysis, + paths=self.paths, + fom_is_log_likelihood=False, + resample_figure_of_merit=-np.inf, + ) + + pool = self.make_sneaky_pool(fitness) + + try: + backend = emcee.backends.HDFBackend(filename=self.backend_filename) + except TypeError: + backend = None + + search_internal = emcee.EnsembleSampler( + nwalkers=self.nwalkers, + ndim=model.prior_count, + log_prob_fn=fitness.call_wrap, + backend=backend, + pool=pool, + ) + + try: + state = search_internal.get_last_sample() + samples = self.samples_from(model=model, search_internal=search_internal) + + total_iterations = search_internal.iteration + + if samples.converged: + iterations_remaining = 0 + else: + iterations_remaining = self.nsteps - total_iterations + + self.logger.info( + "Resuming Emcee non-linear search (previous samples found)." + ) + + except AttributeError: + ( + unit_parameter_lists, + parameter_lists, + log_posterior_list, + ) = self.initializer.samples_from_model( + total_points=search_internal.nwalkers, + model=model, + fitness=fitness, + paths=self.paths, + n_cores=self.number_of_cores, + ) + + self.plot_start_point( + parameter_vector=parameter_lists[0], + model=model, + analysis=analysis, + ) + + state = np.zeros(shape=(search_internal.nwalkers, model.prior_count)) + + self.logger.info( + "Starting new Emcee non-linear search (no previous samples found)." + ) + + for index, parameters in enumerate(parameter_lists): + state[index, :] = np.asarray(parameters) + + total_iterations = 0 + iterations_remaining = self.nsteps + + while iterations_remaining > 0: + if self.iterations_per_full_update > iterations_remaining: + iterations = iterations_remaining + else: + iterations = self.iterations_per_full_update + + for sample in search_internal.sample( + initial_state=state, + iterations=iterations, + progress=True, + skip_initial_state_check=True, + store=True, + ): + pass + + state = search_internal.get_last_sample() + + total_iterations += iterations + iterations_remaining = self.nsteps - total_iterations + + samples = self.samples_from(model=model, search_internal=search_internal) + + if self.auto_correlation_settings.check_for_convergence: + if ( + search_internal.iteration + > self.auto_correlation_settings.check_size + ): + if samples.converged: + iterations_remaining = 0 + + if iterations_remaining > 0: + self.perform_update( + model=model, + analysis=analysis, + search_internal=search_internal, + fitness=fitness, + during_analysis=True, + ) + + return search_internal, fitness + + def output_search_internal(self, search_internal): + """ + Output the sampler results to hard-disk in their internal format. + + Emcee uses a backend to store and load results, therefore the outputting of the search internal to a + dill file is disabled. + + Parameters + ---------- + sampler + The nautilus sampler object containing the results of the model-fit. + """ + pass + + def samples_info_from(self, search_internal=None): + search_internal = search_internal or self.backend + + auto_correlations = self.auto_correlations_from(search_internal=search_internal) + + return { + "check_size": auto_correlations.check_size, + "required_length": auto_correlations.required_length, + "change_threshold": auto_correlations.change_threshold, + "total_walkers": len(search_internal.get_chain()[0, :, 0]), + "total_steps": len(search_internal.get_log_prob()), + "time": self.timer.time if self.timer else None, + } + + def samples_via_internal_from(self, model, search_internal=None): + """ + Returns a `Samples` object from the emcee internal results. + + The samples contain all information on the parameter space sampling (e.g. the parameters, + log likelihoods, etc.). + + The internal search results are converted from the native format used by the search to lists of values + (e.g. `parameter_lists`, `log_likelihood_list`). + + Parameters + ---------- + model + Maps input vectors of unit parameter values to physical values and model instances via priors. + """ + + search_internal = search_internal or self.backend + + if is_test_mode(): + samples_after_burn_in = search_internal.get_chain( + discard=5, thin=5, flat=True + ) + + else: + auto_correlations = self.auto_correlations_from( + search_internal=search_internal + ) + + discard = int(3.0 * np.max(auto_correlations.times)) + thin = int(np.max(auto_correlations.times) / 2.0) + samples_after_burn_in = search_internal.get_chain( + discard=discard, thin=thin, flat=True + ) + + parameter_lists = samples_after_burn_in.tolist() + + log_prior_list = model.log_prior_list_from(parameter_lists=parameter_lists) + + total_samples = len(parameter_lists) + + log_posterior_list = search_internal.get_log_prob(flat=True)[ + -total_samples - 1 : -1 + ].tolist() + + log_likelihood_list = [ + log_posterior - log_prior + for log_posterior, log_prior in zip(log_posterior_list, log_prior_list) + ] + + weight_list = len(log_likelihood_list) * [1.0] + + sample_list = Sample.from_lists( + model=model, + parameter_lists=parameter_lists, + log_likelihood_list=log_likelihood_list, + log_prior_list=log_prior_list, + weight_list=weight_list, + ) + + return SamplesMCMC( + model=model, + sample_list=sample_list, + samples_info=self.samples_info_from(search_internal=search_internal), + auto_correlation_settings=self.auto_correlation_settings, + auto_correlations=self.auto_correlations_from( + search_internal=search_internal + ), + ) + + def auto_correlations_from(self, search_internal=None): + import emcee + + search_internal = search_internal or self.backend + + times = search_internal.get_autocorr_time(tol=0) + + previous_auto_correlation_times = emcee.autocorr.integrated_time( + x=search_internal.get_chain()[ + : -self.auto_correlation_settings.check_size, :, : + ], + tol=0, + ) + + return AutoCorrelations( + check_size=self.auto_correlation_settings.check_size, + required_length=self.auto_correlation_settings.required_length, + change_threshold=self.auto_correlation_settings.change_threshold, + times=times, + previous_times=previous_auto_correlation_times, + ) + + @property + def backend_filename(self): + return self.paths.search_internal_path / "search_internal.hdf" + + @property + def backend(self) -> "emcee.backends.HDFBackend": + """ + The `Emcee` hdf5 backend, which provides access to all samples, likelihoods, etc. of the non-linear search. + + The sampler is described in the "Results" section at https://dynesty.readthedocs.io/en/latest/quickstart.html + """ + import emcee + + if Path(self.backend_filename).is_file(): + return emcee.backends.HDFBackend(filename=str(self.backend_filename)) + else: + raise FileNotFoundError( + f"The file search_internal.hdf does not exist at the path {self.paths.search_internal_path}" + ) diff --git a/autofit/non_linear/search/mcmc/zeus/search.py b/autofit/non_linear/search/mcmc/zeus/search.py index 27667b889..52133bb70 100644 --- a/autofit/non_linear/search/mcmc/zeus/search.py +++ b/autofit/non_linear/search/mcmc/zeus/search.py @@ -1,415 +1,415 @@ -import logging -from typing import Dict, Optional - -import numpy as np -import os - -from autofit.database.sqlalchemy_ import sa -from autofit.mapper.model_mapper import ModelMapper -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.fitness import Fitness -from autofit.non_linear.initializer import Initializer -from autofit.non_linear.search.mcmc.abstract_mcmc import AbstractMCMC -from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings -from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelations -from autofit.non_linear.samples.sample import Sample -from autofit.non_linear.test_mode import is_test_mode -from autofit.non_linear.samples.mcmc import SamplesMCMC - -logger = logging.getLogger(__name__) - - -class Zeus(AbstractMCMC): - __identifier_fields__ = ( - "nwalkers", - "tune", - "tolerance", - "patience", - "mu", - "light_mode", - ) - - def __init__( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - unique_tag: Optional[str] = None, - nwalkers: int = 50, - nsteps: int = 2000, - tune: bool = True, - tolerance: float = 0.05, - patience: int = 5, - mu: float = 1.0, - light_mode: bool = False, - maxsteps: int = 10000, - maxiter: int = 10000, - vectorize: bool = False, - shuffle_ensemble: bool = True, - check_walkers: bool = True, - maxcall: Optional[int] = None, - initializer: Optional[Initializer] = None, - auto_correlation_settings=AutoCorrelationsSettings(), - iterations_per_quick_update: int = None, - iterations_per_full_update: int = None, - number_of_cores: int = 1, - silence: bool = False, - session: Optional[sa.orm.Session] = None, - **kwargs - ): - """ - A Zeus non-linear search. - - For a full description of Zeus, checkout its Github and readthedocs webpages: - - https://github.com/minaskar/zeus - - https://zeus-mcmc.readthedocs.io/en/latest/ - - If you use `Zeus` as part of a published work, please cite the package following the instructions under the - *Attribution* section of the GitHub page. - - Parameters - ---------- - name - The name of the search, controlling the last folder results are output. - path_prefix - The path of folders prefixing the name folder where results are output. - unique_tag - The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database - and also acts as the folder after the path prefix and before the search name. - nwalkers - The number of walkers in the ensemble used to sample parameter space. - nsteps - The number of steps that must be taken by every walker. - initializer - Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). - auto_correlation_settings - Customizes and performs auto correlation calculations performed during and after the search. - number_of_cores - The number of cores Zeus sampling is performed using a Python multiprocessing Pool instance. - silence - If True, the default print output of the non-linear search is silenced. - session - An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. - """ - - super().__init__( - name=name, - path_prefix=path_prefix, - unique_tag=unique_tag, - initializer=initializer, - auto_correlation_settings=auto_correlation_settings, - iterations_per_quick_update=iterations_per_quick_update, - iterations_per_full_update=iterations_per_full_update, - number_of_cores=number_of_cores, - silence=silence, - session=session, - **kwargs - ) - - self.nwalkers = nwalkers - self.nsteps = nsteps - self.tune = tune - self.tolerance = tolerance - self.patience = patience - self.mu = mu - self.light_mode = light_mode - self.maxsteps = maxsteps - self.maxiter = maxiter - self.vectorize = vectorize - self.shuffle_ensemble = shuffle_ensemble - self.check_walkers = check_walkers - self.maxcall = maxcall - - if is_test_mode(): - self.apply_test_mode() - - self.logger.debug("Creating Zeus Search") - - def apply_test_mode(self): - logger.warning( - "TEST MODE 1 (reduced iterations): Sampler will run with " - "minimal iterations for faster completion." - ) - self.nwalkers = 20 - self.nsteps = 10 - - def _fit(self, model: AbstractPriorModel, analysis): - """ - Fit a model using Zeus and the Analysis class which contains the data and returns the log likelihood from - instances of the model, which the `NonLinearSearch` seeks to maximize. - - Parameters - ---------- - model : ModelMapper - The model which generates instances for different points in parameter space. - analysis : Analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the `NonLinearSearch` maximizes. - - Returns - ------- - A result object comprising the Samples object that inclues the maximum log likelihood instance and full - chains used by the fit. - """ - - try: - import zeus - except ModuleNotFoundError: - raise ModuleNotFoundError( - "\n--------------------\n" - "You are attempting to perform a model-fit using Zeus. \n\n" - "However, the optional library Zeus (https://zeus-mcmc.readthedocs.io/en/latest/) is " - "not installed.\n\n" - "Install it via the command `pip install zeus-mcmc==2.5.4`.\n\n" - "----------------------" - ) - - pool = self.make_pool() - - fitness = Fitness( - model=model, - analysis=analysis, - paths=self.paths, - fom_is_log_likelihood=False, - resample_figure_of_merit=-np.inf, - ) - - try: - search_internal = self.paths.load_search_internal() - - state = search_internal.get_last_sample() - log_posterior_list = search_internal.get_last_log_prob() - - samples = self.samples_from(model=model, search_internal=search_internal) - - total_iterations = search_internal.iteration - - if samples.converged: - iterations_remaining = 0 - else: - iterations_remaining = self.nsteps - total_iterations - - self.logger.info( - "Resuming Zeus non-linear search (previous samples found)." - ) - - except (FileNotFoundError, AttributeError): - search_internal = zeus.EnsembleSampler( - nwalkers=self.nwalkers, - ndim=model.prior_count, - logprob_fn=fitness.call_wrap, - pool=pool, - ) - - search_internal.ncall_total = 0 - - ( - unit_parameter_lists, - parameter_lists, - log_posterior_list, - ) = self.initializer.samples_from_model( - total_points=search_internal.nwalkers, - model=model, - fitness=fitness, - test_mode_samples=False, - paths=self.paths, - n_cores=self.number_of_cores, - ) - - self.plot_start_point( - parameter_vector=parameter_lists[0], - model=model, - analysis=analysis, - ) - - state = np.zeros(shape=(search_internal.nwalkers, model.prior_count)) - - self.logger.info( - "Starting new Zeus non-linear search (no previous samples found)." - ) - - for index, parameters in enumerate(parameter_lists): - state[index, :] = np.asarray(parameters) - - total_iterations = 0 - iterations_remaining = self.nsteps - - while iterations_remaining > 0: - if self.iterations_per_full_update > iterations_remaining: - iterations = iterations_remaining - else: - iterations = self.iterations_per_full_update - - for sample in search_internal.sample( - start=state, - log_prob0=log_posterior_list, - iterations=iterations, - progress=True, - ): - pass - - search_internal.ncall_total += search_internal.ncall - - self.paths.save_search_internal( - obj=search_internal, - ) - - state = search_internal.get_last_sample() - log_posterior_list = search_internal.get_last_log_prob() - - total_iterations += iterations - iterations_remaining = self.nsteps - total_iterations - - samples = self.samples_from(model=model, search_internal=search_internal) - - if self.auto_correlation_settings.check_for_convergence: - if ( - search_internal.iteration - > self.auto_correlation_settings.check_size - ): - if samples.converged: - iterations_remaining = 0 - - auto_correlation_time = zeus.AutoCorrTime( - samples=search_internal.get_chain() - ) - - discard = int(3.0 * np.max(auto_correlation_time)) - thin = int(np.max(auto_correlation_time) / 2.0) - chain = search_internal.get_chain(discard=discard, thin=thin, flat=True) - - if self.maxcall is not None: - if search_internal.ncall_total > self.maxcall: - iterations_remaining = 0 - - if iterations_remaining > 0: - self.perform_update( - model=model, - analysis=analysis, - search_internal=search_internal, - fitness=fitness, - during_analysis=True, - ) - - return search_internal, fitness - - def samples_info_from(self, search_internal=None): - search_internal = search_internal or self.paths.load_search_internal() - - auto_correlations = self.auto_correlations_from(search_internal=search_internal) - - return { - "check_size": auto_correlations.check_size, - "required_length": auto_correlations.required_length, - "change_threshold": auto_correlations.change_threshold, - "total_walkers": len(search_internal.get_chain()[0, :, 0]), - "total_steps": int(search_internal.ncall_total), - "time": self.timer.time if self.timer else None, - } - - def samples_via_internal_from(self, model, search_internal=None): - """ - Returns a `Samples` object from the zeus internal results. - - The samples contain all information on the parameter space sampling (e.g. the parameters, - log likelihoods, etc.). - - The internal search results are converted from the native format used by the search to lists of values - (e.g. `parameter_lists`, `log_likelihood_list`). - - Parameters - ---------- - model - Maps input vectors of unit parameter values to physical values and model instances via priors. - """ - - search_internal = search_internal or self.paths.load_search_internal() - - if is_test_mode(): - - samples_after_burn_in = search_internal.get_chain( - discard=5, thin=5, flat=True - ) - - else: - auto_correlations = self.auto_correlations_from( - search_internal=search_internal - ) - - discard = int(3.0 * np.max(auto_correlations.times)) - thin = int(np.max(auto_correlations.times) / 2.0) - samples_after_burn_in = search_internal.get_chain( - discard=discard, thin=thin, flat=True - ) - - if len(samples_after_burn_in) == 0: - - logging.info( - """ - After thinnng the Zeus samples in order to remove burn-in, no samples were left. - - To create a samples object containing samples, so that the code can continue and results - can be inspected, the full list of samples before removing burn-in has been used. This may - indicate that the sampler has not converged and therefore your results may not be reliable. - - To fix this, run Zeus with more steps to ensure convergence is achieved or change the auto - correlation settings to be less aggressive in thinning samples. - """ - ) - - samples_after_burn_in = search_internal.get_chain(flat=True) - - parameter_lists = samples_after_burn_in.tolist() - log_posterior_list = search_internal.get_log_prob(flat=True).tolist() - log_prior_list = model.log_prior_list_from(parameter_lists=parameter_lists) - - log_likelihood_list = [ - log_posterior - log_prior - for log_posterior, log_prior in zip(log_posterior_list, log_prior_list) - ] - - weight_list = len(log_likelihood_list) * [1.0] - - sample_list = Sample.from_lists( - model=model, - parameter_lists=parameter_lists, - log_likelihood_list=log_likelihood_list, - log_prior_list=log_prior_list, - weight_list=weight_list, - ) - - return SamplesMCMC( - model=model, - sample_list=sample_list, - samples_info=self.samples_info_from(search_internal=search_internal), - auto_correlation_settings=self.auto_correlation_settings, - auto_correlations=self.auto_correlations_from( - search_internal=search_internal - ), - ) - - def auto_correlations_from(self, search_internal=None): - import zeus - - search_internal = search_internal or self.paths.load_search_internal() - - times = zeus.AutoCorrTime(samples=search_internal.get_chain()) - try: - previous_auto_correlation_times = zeus.AutoCorrTime( - samples=search_internal.get_chain()[ - : -self.auto_correlation_settings.check_size, :, : - ], - ) - except IndexError: - self.logger.debug("Unable to compute previous auto correlation times.") - previous_auto_correlation_times = None - - return AutoCorrelations( - check_size=self.auto_correlation_settings.check_size, - required_length=self.auto_correlation_settings.required_length, - change_threshold=self.auto_correlation_settings.change_threshold, - times=times, - previous_times=previous_auto_correlation_times, - ) - +import logging +from typing import Dict, Optional + +import numpy as np +import os + +from autofit.database.sqlalchemy_ import sa +from autofit.mapper.model_mapper import ModelMapper +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.fitness import Fitness +from autofit.non_linear.initializer import Initializer +from autofit.non_linear.search.mcmc.abstract_mcmc import AbstractMCMC +from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings +from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelations +from autofit.non_linear.samples.sample import Sample +from autofit.non_linear.test_mode import is_test_mode +from autofit.non_linear.samples.mcmc import SamplesMCMC + +logger = logging.getLogger(__name__) + + +class Zeus(AbstractMCMC): + __identifier_fields__ = ( + "nwalkers", + "tune", + "tolerance", + "patience", + "mu", + "light_mode", + ) + + def __init__( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + unique_tag: Optional[str] = None, + nwalkers: int = 50, + nsteps: int = 2000, + tune: bool = True, + tolerance: float = 0.05, + patience: int = 5, + mu: float = 1.0, + light_mode: bool = False, + maxsteps: int = 10000, + maxiter: int = 10000, + vectorize: bool = False, + shuffle_ensemble: bool = True, + check_walkers: bool = True, + maxcall: Optional[int] = None, + initializer: Optional[Initializer] = None, + auto_correlation_settings=AutoCorrelationsSettings(), + iterations_per_quick_update: int = None, + iterations_per_full_update: int = None, + number_of_cores: int = 1, + silence: bool = False, + session: Optional[sa.orm.Session] = None, + **kwargs + ): + """ + A Zeus non-linear search. + + For a full description of Zeus, checkout its Github and readthedocs webpages: + + https://github.com/minaskar/zeus + + https://zeus-mcmc.readthedocs.io/en/latest/ + + If you use `Zeus` as part of a published work, please cite the package following the instructions under the + *Attribution* section of the GitHub page. + + Parameters + ---------- + name + The name of the search, controlling the last folder results are output. + path_prefix + The path of folders prefixing the name folder where results are output. + unique_tag + The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database + and also acts as the folder after the path prefix and before the search name. + nwalkers + The number of walkers in the ensemble used to sample parameter space. + nsteps + The number of steps that must be taken by every walker. + initializer + Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). + auto_correlation_settings + Customizes and performs auto correlation calculations performed during and after the search. + number_of_cores + The number of cores Zeus sampling is performed using a Python multiprocessing Pool instance. + silence + If True, the default print output of the non-linear search is silenced. + session + An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. + """ + + super().__init__( + name=name, + path_prefix=path_prefix, + unique_tag=unique_tag, + initializer=initializer, + auto_correlation_settings=auto_correlation_settings, + iterations_per_quick_update=iterations_per_quick_update, + iterations_per_full_update=iterations_per_full_update, + number_of_cores=number_of_cores, + silence=silence, + session=session, + **kwargs + ) + + self.nwalkers = nwalkers + self.nsteps = nsteps + self.tune = tune + self.tolerance = tolerance + self.patience = patience + self.mu = mu + self.light_mode = light_mode + self.maxsteps = maxsteps + self.maxiter = maxiter + self.vectorize = vectorize + self.shuffle_ensemble = shuffle_ensemble + self.check_walkers = check_walkers + self.maxcall = maxcall + + if is_test_mode(): + self.apply_test_mode() + + self.logger.debug("Creating Zeus Search") + + def apply_test_mode(self): + logger.warning( + "TEST MODE 1 (reduced iterations): Sampler will run with " + "minimal iterations for faster completion." + ) + self.nwalkers = 20 + self.nsteps = 10 + + def _fit(self, model: AbstractPriorModel, analysis): + """ + Fit a model using Zeus and the Analysis class which contains the data and returns the log likelihood from + instances of the model, which the `NonLinearSearch` seeks to maximize. + + Parameters + ---------- + model : ModelMapper + The model which generates instances for different points in parameter space. + analysis : Analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the `NonLinearSearch` maximizes. + + Returns + ------- + A result object comprising the Samples object that inclues the maximum log likelihood instance and full + chains used by the fit. + """ + + try: + import zeus + except ModuleNotFoundError: + raise ModuleNotFoundError( + "\n--------------------\n" + "You are attempting to perform a model-fit using Zeus. \n\n" + "However, the optional library Zeus (https://zeus-mcmc.readthedocs.io/en/latest/) is " + "not installed.\n\n" + "Install it via the command `pip install zeus-mcmc==2.5.4`.\n\n" + "----------------------" + ) + + pool = self.make_pool() + + fitness = Fitness( + model=model, + analysis=analysis, + paths=self.paths, + fom_is_log_likelihood=False, + resample_figure_of_merit=-np.inf, + ) + + try: + search_internal = self.paths.load_search_internal() + + state = search_internal.get_last_sample() + log_posterior_list = search_internal.get_last_log_prob() + + samples = self.samples_from(model=model, search_internal=search_internal) + + total_iterations = search_internal.iteration + + if samples.converged: + iterations_remaining = 0 + else: + iterations_remaining = self.nsteps - total_iterations + + self.logger.info( + "Resuming Zeus non-linear search (previous samples found)." + ) + + except (FileNotFoundError, AttributeError): + search_internal = zeus.EnsembleSampler( + nwalkers=self.nwalkers, + ndim=model.prior_count, + logprob_fn=fitness.call_wrap, + pool=pool, + ) + + search_internal.ncall_total = 0 + + ( + unit_parameter_lists, + parameter_lists, + log_posterior_list, + ) = self.initializer.samples_from_model( + total_points=search_internal.nwalkers, + model=model, + fitness=fitness, + test_mode_samples=False, + paths=self.paths, + n_cores=self.number_of_cores, + ) + + self.plot_start_point( + parameter_vector=parameter_lists[0], + model=model, + analysis=analysis, + ) + + state = np.zeros(shape=(search_internal.nwalkers, model.prior_count)) + + self.logger.info( + "Starting new Zeus non-linear search (no previous samples found)." + ) + + for index, parameters in enumerate(parameter_lists): + state[index, :] = np.asarray(parameters) + + total_iterations = 0 + iterations_remaining = self.nsteps + + while iterations_remaining > 0: + if self.iterations_per_full_update > iterations_remaining: + iterations = iterations_remaining + else: + iterations = self.iterations_per_full_update + + for sample in search_internal.sample( + start=state, + log_prob0=log_posterior_list, + iterations=iterations, + progress=True, + ): + pass + + search_internal.ncall_total += search_internal.ncall + + self.paths.save_search_internal( + obj=search_internal, + ) + + state = search_internal.get_last_sample() + log_posterior_list = search_internal.get_last_log_prob() + + total_iterations += iterations + iterations_remaining = self.nsteps - total_iterations + + samples = self.samples_from(model=model, search_internal=search_internal) + + if self.auto_correlation_settings.check_for_convergence: + if ( + search_internal.iteration + > self.auto_correlation_settings.check_size + ): + if samples.converged: + iterations_remaining = 0 + + auto_correlation_time = zeus.AutoCorrTime( + samples=search_internal.get_chain() + ) + + discard = int(3.0 * np.max(auto_correlation_time)) + thin = int(np.max(auto_correlation_time) / 2.0) + chain = search_internal.get_chain(discard=discard, thin=thin, flat=True) + + if self.maxcall is not None: + if search_internal.ncall_total > self.maxcall: + iterations_remaining = 0 + + if iterations_remaining > 0: + self.perform_update( + model=model, + analysis=analysis, + search_internal=search_internal, + fitness=fitness, + during_analysis=True, + ) + + return search_internal, fitness + + def samples_info_from(self, search_internal=None): + search_internal = search_internal or self.paths.load_search_internal() + + auto_correlations = self.auto_correlations_from(search_internal=search_internal) + + return { + "check_size": auto_correlations.check_size, + "required_length": auto_correlations.required_length, + "change_threshold": auto_correlations.change_threshold, + "total_walkers": len(search_internal.get_chain()[0, :, 0]), + "total_steps": int(search_internal.ncall_total), + "time": self.timer.time if self.timer else None, + } + + def samples_via_internal_from(self, model, search_internal=None): + """ + Returns a `Samples` object from the zeus internal results. + + The samples contain all information on the parameter space sampling (e.g. the parameters, + log likelihoods, etc.). + + The internal search results are converted from the native format used by the search to lists of values + (e.g. `parameter_lists`, `log_likelihood_list`). + + Parameters + ---------- + model + Maps input vectors of unit parameter values to physical values and model instances via priors. + """ + + search_internal = search_internal or self.paths.load_search_internal() + + if is_test_mode(): + + samples_after_burn_in = search_internal.get_chain( + discard=5, thin=5, flat=True + ) + + else: + auto_correlations = self.auto_correlations_from( + search_internal=search_internal + ) + + discard = int(3.0 * np.max(auto_correlations.times)) + thin = int(np.max(auto_correlations.times) / 2.0) + samples_after_burn_in = search_internal.get_chain( + discard=discard, thin=thin, flat=True + ) + + if len(samples_after_burn_in) == 0: + + logging.info( + """ + After thinnng the Zeus samples in order to remove burn-in, no samples were left. + + To create a samples object containing samples, so that the code can continue and results + can be inspected, the full list of samples before removing burn-in has been used. This may + indicate that the sampler has not converged and therefore your results may not be reliable. + + To fix this, run Zeus with more steps to ensure convergence is achieved or change the auto + correlation settings to be less aggressive in thinning samples. + """ + ) + + samples_after_burn_in = search_internal.get_chain(flat=True) + + parameter_lists = samples_after_burn_in.tolist() + log_posterior_list = search_internal.get_log_prob(flat=True).tolist() + log_prior_list = model.log_prior_list_from(parameter_lists=parameter_lists) + + log_likelihood_list = [ + log_posterior - log_prior + for log_posterior, log_prior in zip(log_posterior_list, log_prior_list) + ] + + weight_list = len(log_likelihood_list) * [1.0] + + sample_list = Sample.from_lists( + model=model, + parameter_lists=parameter_lists, + log_likelihood_list=log_likelihood_list, + log_prior_list=log_prior_list, + weight_list=weight_list, + ) + + return SamplesMCMC( + model=model, + sample_list=sample_list, + samples_info=self.samples_info_from(search_internal=search_internal), + auto_correlation_settings=self.auto_correlation_settings, + auto_correlations=self.auto_correlations_from( + search_internal=search_internal + ), + ) + + def auto_correlations_from(self, search_internal=None): + import zeus + + search_internal = search_internal or self.paths.load_search_internal() + + times = zeus.AutoCorrTime(samples=search_internal.get_chain()) + try: + previous_auto_correlation_times = zeus.AutoCorrTime( + samples=search_internal.get_chain()[ + : -self.auto_correlation_settings.check_size, :, : + ], + ) + except IndexError: + self.logger.debug("Unable to compute previous auto correlation times.") + previous_auto_correlation_times = None + + return AutoCorrelations( + check_size=self.auto_correlation_settings.check_size, + required_length=self.auto_correlation_settings.required_length, + change_threshold=self.auto_correlation_settings.change_threshold, + times=times, + previous_times=previous_auto_correlation_times, + ) + diff --git a/autofit/non_linear/search/mle/drawer/search.py b/autofit/non_linear/search/mle/drawer/search.py index 6bda0cab1..ad794d20d 100644 --- a/autofit/non_linear/search/mle/drawer/search.py +++ b/autofit/non_linear/search/mle/drawer/search.py @@ -1,179 +1,179 @@ -import numpy as np -from typing import Optional - -from autofit.database.sqlalchemy_ import sa - -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.non_linear.fitness import Fitness -from autofit.non_linear.search.mle.abstract_mle import AbstractMLE -from autofit.non_linear.initializer import AbstractInitializer -from autofit.non_linear.samples import Samples, Sample - - -class Drawer(AbstractMLE): - __identifier_fields__ = ("total_draws",) - - def __init__( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - unique_tag: Optional[str] = None, - total_draws: int = 50, - initializer: Optional[AbstractInitializer] = None, - iterations_per_full_update: int = None, - iterations_per_quick_update: int = None, - silence: bool = False, - session: Optional[sa.orm.Session] = None, - **kwargs, - ): - """ - A Drawer non-linear search, which simply draws a fixed number of samples from the model uniformly from the - priors. - - Therefore, it does not seek to determine model parameters which maximize the likelihood or map out the - posterior of the overall parameter space. - - Whilst this is not the typical use case of a non-linear search, it has certain niche applications, for example: - - - Given a model one can determine how much variation there is in the log likelihood / log posterior values. - By visualizing this as a histogram one can therefore quantify the behaviour of that - model's `log_likelihood_function`. - - - If the `log_likelihood_function` of a model is stochastic (e.g. different values of likelihood may be - computed for an identical model due to randomness in the likelihood evaluation) this search can quantify - the behaviour of that stochasticity. - - - For advanced modeling tools, for example sensitivity mapping performed via the `Sensitivity` object, - the `Drawer` search may be sufficient to perform the overall modeling task, without the need of performing - an actual parameter space search. - - The drawer search itself is performed by simply reusing the functionality of the `AbstractInitializer` object. - Whereas this is normally used to initialize a non-linear search, for the drawer it performed all log - likelihood evluations. - - Parameters - ---------- - name - The name of the search, controlling the last folder results are output. - path_prefix - The path of folders prefixing the name folder where results are output. - unique_tag - The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database - and also acts as the folder after the path prefix and before the search name. - initializer - Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). - session - An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. - """ - - # Drawer is single-core only; drop any saved number_of_cores so a - # round-tripped search.json (which records the resolved value) can be - # deserialized without colliding with the hardcoded kwarg below. - kwargs.pop("number_of_cores", None) - - super().__init__( - name=name, - path_prefix=path_prefix, - unique_tag=unique_tag, - initializer=initializer, - iterations_per_quick_update=iterations_per_quick_update, - iterations_per_full_update=iterations_per_full_update, - number_of_cores=1, - silence=silence, - session=session, - **kwargs, - ) - - self.total_draws = total_draws - - self.logger.debug("Creating Drawer Search") - - def _fit(self, model: AbstractPriorModel, analysis): - """ - Fit a model using Drawer and the Analysis class which contains the data and returns the log likelihood from - instances of the model, which the `NonLinearSearch` seeks to maximize. - - Parameters - ---------- - model : ModelMapper - The model which generates instances for different points in parameter space. - analysis : Analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the `NonLinearSearch` maximizes. - - Returns - ------- - A result object comprising the Samples object that inclues the maximum log likelihood instance and full - chains used by the fit. - """ - - fitness = Fitness( - model=model, - analysis=analysis, - paths=self.paths, - fom_is_log_likelihood=False, - resample_figure_of_merit=-np.inf, - convert_to_chi_squared=False, - ) - - total_draws = self.total_draws - - self.logger.info( - f"Performing DrawerSearch for a total of {total_draws} points." - ) - - ( - unit_parameter_lists, - parameter_lists, - log_posterior_list, - ) = self.initializer.samples_from_model( - total_points=self.total_draws, - model=model, - fitness=fitness, - paths=self.paths, - n_cores=self.number_of_cores, - ) - - search_internal = { - "parameter_lists": parameter_lists, - "log_posterior_list": log_posterior_list, - "time": self.timer.time, - } - - self.paths.save_search_internal( - obj=search_internal, - ) - - self.logger.info("Drawer complete") - - return search_internal, fitness - - def samples_via_internal_from(self, model, search_internal=None): - search_internal_dict = self.paths.load_search_internal() - - parameter_lists = search_internal_dict["parameter_lists"] - log_posterior_list = search_internal_dict["log_posterior_list"] - - log_prior_list = [ - sum(model.log_prior_list_from_vector(vector=vector)) - for vector in parameter_lists - ] - log_likelihood_list = [ - lp - prior for lp, prior in zip(log_posterior_list, log_prior_list) - ] - - weight_list = len(log_likelihood_list) * [1.0] - - sample_list = Sample.from_lists( - model=model, - parameter_lists=parameter_lists, - log_likelihood_list=log_likelihood_list, - log_prior_list=log_prior_list, - weight_list=weight_list, - ) - - return Samples( - model=model, - sample_list=sample_list, - samples_info=search_internal_dict, - ) +import numpy as np +from typing import Optional + +from autofit.database.sqlalchemy_ import sa + +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.non_linear.fitness import Fitness +from autofit.non_linear.search.mle.abstract_mle import AbstractMLE +from autofit.non_linear.initializer import AbstractInitializer +from autofit.non_linear.samples import Samples, Sample + + +class Drawer(AbstractMLE): + __identifier_fields__ = ("total_draws",) + + def __init__( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + unique_tag: Optional[str] = None, + total_draws: int = 50, + initializer: Optional[AbstractInitializer] = None, + iterations_per_full_update: int = None, + iterations_per_quick_update: int = None, + silence: bool = False, + session: Optional[sa.orm.Session] = None, + **kwargs, + ): + """ + A Drawer non-linear search, which simply draws a fixed number of samples from the model uniformly from the + priors. + + Therefore, it does not seek to determine model parameters which maximize the likelihood or map out the + posterior of the overall parameter space. + + Whilst this is not the typical use case of a non-linear search, it has certain niche applications, for example: + + - Given a model one can determine how much variation there is in the log likelihood / log posterior values. + By visualizing this as a histogram one can therefore quantify the behaviour of that + model's `log_likelihood_function`. + + - If the `log_likelihood_function` of a model is stochastic (e.g. different values of likelihood may be + computed for an identical model due to randomness in the likelihood evaluation) this search can quantify + the behaviour of that stochasticity. + + - For advanced modeling tools, for example sensitivity mapping performed via the `Sensitivity` object, + the `Drawer` search may be sufficient to perform the overall modeling task, without the need of performing + an actual parameter space search. + + The drawer search itself is performed by simply reusing the functionality of the `AbstractInitializer` object. + Whereas this is normally used to initialize a non-linear search, for the drawer it performed all log + likelihood evluations. + + Parameters + ---------- + name + The name of the search, controlling the last folder results are output. + path_prefix + The path of folders prefixing the name folder where results are output. + unique_tag + The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database + and also acts as the folder after the path prefix and before the search name. + initializer + Generates the initialize samples of non-linear parameter space (see autofit.non_linear.initializer). + session + An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. + """ + + # Drawer is single-core only; drop any saved number_of_cores so a + # round-tripped search.json (which records the resolved value) can be + # deserialized without colliding with the hardcoded kwarg below. + kwargs.pop("number_of_cores", None) + + super().__init__( + name=name, + path_prefix=path_prefix, + unique_tag=unique_tag, + initializer=initializer, + iterations_per_quick_update=iterations_per_quick_update, + iterations_per_full_update=iterations_per_full_update, + number_of_cores=1, + silence=silence, + session=session, + **kwargs, + ) + + self.total_draws = total_draws + + self.logger.debug("Creating Drawer Search") + + def _fit(self, model: AbstractPriorModel, analysis): + """ + Fit a model using Drawer and the Analysis class which contains the data and returns the log likelihood from + instances of the model, which the `NonLinearSearch` seeks to maximize. + + Parameters + ---------- + model : ModelMapper + The model which generates instances for different points in parameter space. + analysis : Analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the `NonLinearSearch` maximizes. + + Returns + ------- + A result object comprising the Samples object that inclues the maximum log likelihood instance and full + chains used by the fit. + """ + + fitness = Fitness( + model=model, + analysis=analysis, + paths=self.paths, + fom_is_log_likelihood=False, + resample_figure_of_merit=-np.inf, + convert_to_chi_squared=False, + ) + + total_draws = self.total_draws + + self.logger.info( + f"Performing DrawerSearch for a total of {total_draws} points." + ) + + ( + unit_parameter_lists, + parameter_lists, + log_posterior_list, + ) = self.initializer.samples_from_model( + total_points=self.total_draws, + model=model, + fitness=fitness, + paths=self.paths, + n_cores=self.number_of_cores, + ) + + search_internal = { + "parameter_lists": parameter_lists, + "log_posterior_list": log_posterior_list, + "time": self.timer.time, + } + + self.paths.save_search_internal( + obj=search_internal, + ) + + self.logger.info("Drawer complete") + + return search_internal, fitness + + def samples_via_internal_from(self, model, search_internal=None): + search_internal_dict = self.paths.load_search_internal() + + parameter_lists = search_internal_dict["parameter_lists"] + log_posterior_list = search_internal_dict["log_posterior_list"] + + log_prior_list = [ + sum(model.log_prior_list_from_vector(vector=vector)) + for vector in parameter_lists + ] + log_likelihood_list = [ + lp - prior for lp, prior in zip(log_posterior_list, log_prior_list) + ] + + weight_list = len(log_likelihood_list) * [1.0] + + sample_list = Sample.from_lists( + model=model, + parameter_lists=parameter_lists, + log_likelihood_list=log_likelihood_list, + log_prior_list=log_prior_list, + weight_list=weight_list, + ) + + return Samples( + model=model, + sample_list=sample_list, + samples_info=search_internal_dict, + ) diff --git a/autofit/non_linear/search/nest/abstract_nest.py b/autofit/non_linear/search/nest/abstract_nest.py index 81057743f..127c91ce2 100644 --- a/autofit/non_linear/search/nest/abstract_nest.py +++ b/autofit/non_linear/search/nest/abstract_nest.py @@ -1,77 +1,77 @@ -from abc import ABC -from typing import Optional -import warnings - -from autonerves import conf -from autofit.database.sqlalchemy_ import sa -from autofit.non_linear.search.abstract_search import NonLinearSearch -from autofit.non_linear.initializer import ( - InitializerPrior, - AbstractInitializer, - InitializerParamBounds, -) -from autofit.non_linear.plot import corner_anesthetic - - -class AbstractNest(NonLinearSearch, ABC): - def __init__( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - unique_tag: Optional[str] = None, - iterations_per_quick_update: Optional[int] = None, - iterations_per_full_update: Optional[int] = None, - number_of_cores: Optional[int] = None, - silence: bool = False, - session: Optional[sa.orm.Session] = None, - initializer: Optional[AbstractInitializer] = None, - **kwargs - ): - """ - Abstract class of a nested sampling `NonLinearSearch` (e.g. MultiNest, Dynesty). - - **PyAutoFit** allows a nested sampler to automatically terminate when the acceptance ratio falls below an input - threshold value. When this occurs, all samples are accepted using the current maximum log likelihood value, - irrespective of how well the model actually fits the data. - - This feature should be used for non-linear searches where the nested sampler gets 'stuck', for example because - the log likelihood function is stochastic or varies rapidly over small scales in parameter space. The results of - samples using this feature are not realiable (given the log likelihood is being manipulated to end the run), but - they are still valid results for linking priors to a new search and non-linear search. - - Parameters - ---------- - session - An SQLAlchemy session instance so the results of the model-fit are written to an SQLite database. - """ - if isinstance(initializer, InitializerParamBounds): - raise ValueError( - "InitializerParamBounds cannot be used for nested sampling" - ) - - super().__init__( - name=name, - path_prefix=path_prefix, - unique_tag=unique_tag, - initializer=initializer or InitializerPrior(), - iterations_per_quick_update=iterations_per_quick_update, - iterations_per_full_update=iterations_per_full_update, - number_of_cores=number_of_cores, - silence=silence, - session=session, - **kwargs - ) - - def plot_results(self, samples): - - def should_plot(name): - return conf.instance["visualize"]["plots_search"]["nest"][name] - - if should_plot("corner_anesthetic"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - corner_anesthetic( - samples=samples, - path=self.paths.image_path / "search", - format="png", +from abc import ABC +from typing import Optional +import warnings + +from autonerves import conf +from autofit.database.sqlalchemy_ import sa +from autofit.non_linear.search.abstract_search import NonLinearSearch +from autofit.non_linear.initializer import ( + InitializerPrior, + AbstractInitializer, + InitializerParamBounds, +) +from autofit.non_linear.plot import corner_anesthetic + + +class AbstractNest(NonLinearSearch, ABC): + def __init__( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + unique_tag: Optional[str] = None, + iterations_per_quick_update: Optional[int] = None, + iterations_per_full_update: Optional[int] = None, + number_of_cores: Optional[int] = None, + silence: bool = False, + session: Optional[sa.orm.Session] = None, + initializer: Optional[AbstractInitializer] = None, + **kwargs + ): + """ + Abstract class of a nested sampling `NonLinearSearch` (e.g. MultiNest, Dynesty). + + **PyAutoFit** allows a nested sampler to automatically terminate when the acceptance ratio falls below an input + threshold value. When this occurs, all samples are accepted using the current maximum log likelihood value, + irrespective of how well the model actually fits the data. + + This feature should be used for non-linear searches where the nested sampler gets 'stuck', for example because + the log likelihood function is stochastic or varies rapidly over small scales in parameter space. The results of + samples using this feature are not realiable (given the log likelihood is being manipulated to end the run), but + they are still valid results for linking priors to a new search and non-linear search. + + Parameters + ---------- + session + An SQLAlchemy session instance so the results of the model-fit are written to an SQLite database. + """ + if isinstance(initializer, InitializerParamBounds): + raise ValueError( + "InitializerParamBounds cannot be used for nested sampling" + ) + + super().__init__( + name=name, + path_prefix=path_prefix, + unique_tag=unique_tag, + initializer=initializer or InitializerPrior(), + iterations_per_quick_update=iterations_per_quick_update, + iterations_per_full_update=iterations_per_full_update, + number_of_cores=number_of_cores, + silence=silence, + session=session, + **kwargs + ) + + def plot_results(self, samples): + + def should_plot(name): + return conf.instance["visualize"]["plots_search"]["nest"][name] + + if should_plot("corner_anesthetic"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + corner_anesthetic( + samples=samples, + path=self.paths.image_path / "search", + format="png", ) \ No newline at end of file diff --git a/autofit/non_linear/search/nest/nautilus/search.py b/autofit/non_linear/search/nest/nautilus/search.py index e20e8be6b..24ca5b490 100644 --- a/autofit/non_linear/search/nest/nautilus/search.py +++ b/autofit/non_linear/search/nest/nautilus/search.py @@ -1,573 +1,573 @@ -import numpy as np -import logging -import os -import sys -from pathlib import Path -from typing import Dict, Optional, Tuple - -from autofit.database.sqlalchemy_ import sa - -from autofit.mapper.prior_model.abstract import AbstractPriorModel -from autofit.mapper.prior.vectorized import PriorVectorized -from autofit.non_linear.fitness import Fitness -from autofit.non_linear.paths.null import NullPaths -from autofit.non_linear.search.nest import abstract_nest -from autofit.non_linear.samples.sample import Sample -from autofit.non_linear.samples.nest import SamplesNest -from autofit.non_linear.test_mode import is_test_mode - - -logger = logging.getLogger(__name__) - -class Nautilus(abstract_nest.AbstractNest): - __identifier_fields__ = ( - "n_live", - "n_update", - "enlarge_per_dim", - "n_points_min", - "split_threshold", - "n_networks", - "n_like_new_bound", - "seed", - "n_shell", - "n_eff", - ) - - def __init__( - self, - name: Optional[str] = None, - path_prefix: Optional[str] = None, - unique_tag: Optional[str] = None, - n_live: int = 3000, - n_update: Optional[int] = None, - enlarge_per_dim: float = 1.1, - n_points_min: Optional[int] = None, - split_threshold: int = 100, - n_networks: int = 4, - n_batch: int = 100, - n_like_new_bound: Optional[int] = None, - vectorized: bool = False, - seed: Optional[int] = None, - f_live: float = 0.01, - n_shell: int = 1, - n_eff: int = 500, - n_like_max: float = float("inf"), - discard_exploration: bool = False, - verbose: bool = True, - iterations_per_quick_update: Optional[int] = None, - iterations_per_full_update: int = None, - number_of_cores: int = 1, - silence: bool = False, - force_x1_cpu: bool = False, - session: Optional[sa.orm.Session] = None, - use_jax_vmap: bool = True, - **kwargs, - ): - """ - A Nautilus non-linear search. - - Nautilus is an optional requirement and must be installed manually via the command `pip install nautilus-sampler`. - It is optional as it has certain dependencies which are generally straight forward to install. - - For a full description of Nautilus checkout its Github and documentation webpages: - - https://github.com/johannesulf/nautilus - https://nautilus-sampler.readthedocs.io/en/stable/index.html - - Parameters - ---------- - name - The name of the search, controlling the last folder results are output. - path_prefix - The path of folders prefixing the name folder where results are output. - unique_tag - The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database - and also acts as the folder after the path prefix and before the search name. - n_live - Number of live points used for sampling. - n_batch - Number of likelihood evaluations performed at each step. - n_like_max - Maximum number of likelihood evaluations before stopping. - f_live - Maximum fraction of evidence in the live set before terminating. - n_eff - Minimum effective sample size before stopping. - iterations_per_full_update - The number of iterations performed between update (e.g. output latest model to hard-disk, visualization). - number_of_cores - The number of cores sampling is performed using a Python multiprocessing Pool instance. - silence - If True, the default print output of the non-linear search is silenced. - force_x1_cpu - If True, force single-CPU mode even when number_of_cores > 1. - session - An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. - """ - - super().__init__( - name=name, - path_prefix=path_prefix, - unique_tag=unique_tag, - iterations_per_full_update=iterations_per_full_update, - iterations_per_quick_update=iterations_per_quick_update, - number_of_cores=number_of_cores, - silence=silence, - session=session, - **kwargs, - ) - - self.n_live = n_live - self.n_update = n_update - self.enlarge_per_dim = enlarge_per_dim - self.n_points_min = n_points_min - self.split_threshold = split_threshold - self.n_networks = n_networks - self.n_batch = n_batch - self.n_like_new_bound = n_like_new_bound - self.vectorized = vectorized - self.seed = seed - - self.f_live = f_live - self.n_shell = n_shell - self.n_eff = n_eff - self.n_like_max = n_like_max - self.discard_exploration = discard_exploration - self.verbose = verbose - - self.force_x1_cpu = force_x1_cpu - self.use_jax_vmap = use_jax_vmap - - if is_test_mode(): - self.apply_test_mode() - - self.logger.debug("Creating Nautilus Search") - - def apply_test_mode(self): - logger.warning( - "TEST MODE 1 (reduced iterations): Sampler will run with " - "minimal iterations for faster completion." - ) - self.n_like_max = 1 - - def _fit(self, model: AbstractPriorModel, analysis): - """ - Fit a model using the search and the Analysis class which contains the data and returns the log likelihood from - instances of the model, which the `NonLinearSearch` seeks to maximize. - - Parameters - ---------- - model : ModelMapper - The model which generates instances for different points in parameter space. - analysis : Analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the `NonLinearSearch` maximizes. - - Returns - ------- - A result object comprising the Samples object that includes the maximum log likelihood instance and full - set of accepted ssamples of the fit. - """ - - if not isinstance(self.paths, NullPaths): - checkpoint_exists = Path(self.checkpoint_file).exists() - else: - checkpoint_exists = False - - if checkpoint_exists: - self.logger.info( - "Resuming Nautilus non-linear search (previous samples found)." - ) - - else: - self.logger.info( - "Starting new Nautilus non-linear search (no previous samples found)." - ) - - if self.force_x1_cpu or analysis._use_jax: - - fitness = Fitness( - model=model, - analysis=analysis, - paths=self.paths, - fom_is_log_likelihood=True, - resample_figure_of_merit=-1.0e99, - iterations_per_quick_update=self.iterations_per_quick_update, - background_quick_update=self.quick_update_background, - live_visual_update=self.live_visual_update, - use_jax_vmap=self.use_jax_vmap, - batch_size=self.n_batch, - ) - - search_internal = self.fit_x1_cpu( - fitness=fitness, - model=model, - analysis=analysis, - ) - - else: - - fitness = Fitness( - model=model, - analysis=analysis, - paths=self.paths, - fom_is_log_likelihood=True, - resample_figure_of_merit=-1.0e99, - iterations_per_quick_update=self.iterations_per_quick_update, - background_quick_update=self.quick_update_background, - live_visual_update=self.live_visual_update, - ) - - search_internal = self.fit_multiprocessing( - fitness=fitness, - model=model, - analysis=analysis, - ) - - return search_internal, fitness - - @property - def sampler_cls(self): - try: - from nautilus import Sampler - - return Sampler - except ModuleNotFoundError: - raise ModuleNotFoundError( - "\n--------------------\n" - "You are attempting to perform a model-fit using Nautilus. \n\n" - "However, the optional library Nautilus (https://nautilus-sampler.readthedocs.io/en/stable/index.html) is " - "not installed.\n\n" - "Install it via the command `pip install nautilus-sampler==1.0.5`.\n\n" - "----------------------" - ) - - @property - def checkpoint_file(self): - """ - The path to the file used for checkpointing. - - If autofit is not outputting results to hard-disk (e.g. paths is `NullPaths`), this function is bypassed. - """ - try: - return self.paths.search_internal_path / "checkpoint.hdf5" - except TypeError: - pass - - def fit_x1_cpu(self, fitness, model, analysis): - """ - Perform the non-linear search, using one CPU core. - - This is used if the likelihood function calls external libraries that cannot be parallelized or use - threading in a way that conflicts with the parallelization of the non-linear search. - - Parameters - ---------- - fitness - The function which takes a model instance and returns its log likelihood via the Analysis class - model - The model which maps parameters chosen via the non-linear search (e.g. via the priors or sampling) to - instances of the model, which are passed to the fitness function. - analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the search maximizes. - """ - - if analysis._use_jax: - self.logger.info( - "Running search with JAX vectorization (parallelization handled by JAX)." - ) - else: - self.logger.info( - "Running search where parallelization is disabled." - ) - - search_internal = self.sampler_cls( - prior=PriorVectorized(model=model), - likelihood=fitness.call_wrap, - n_dim=model.prior_count, - filepath=self.checkpoint_file, - pool=None, - vectorized=fitness.use_jax_vmap, - n_live=self.n_live, - n_update=self.n_update, - enlarge_per_dim=self.enlarge_per_dim, - n_points_min=self.n_points_min, - split_threshold=self.split_threshold, - n_networks=self.n_networks, - n_batch=self.n_batch, - n_like_new_bound=self.n_like_new_bound, - seed=self.seed, - ) - - return self.call_search(search_internal=search_internal, model=model, analysis=analysis, fitness=fitness) - - def fit_multiprocessing(self, fitness, model, analysis): - """ - Perform the non-linear search, using multiple CPU cores parallelized via Python's multiprocessing module. - - This uses PyAutoFit's sneaky pool class, which allows us to use the multiprocessing module in a way that plays - nicely with the non-linear search (e.g. exception handling, keyboard interupts, etc.). - - Multiprocessing parallelization can only parallelize across multiple cores on a single device, it cannot be - distributed across multiple devices or computing nodes. For that, use the `fit_mpi` method. - - Parameters - ---------- - fitness - The function which takes a model instance and returns its log likelihood via the Analysis class - model - The model which maps parameters chosen via the non-linear search (e.g. via the priors or sampling) to - instances of the model, which are passed to the fitness function. - analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the search maximizes. - """ - search_internal = self.sampler_cls( - prior=PriorVectorized(model=model), - likelihood=fitness.call_wrap, - n_dim=model.prior_count, - filepath=self.checkpoint_file, - pool=self.number_of_cores, - n_live=self.n_live, - n_update=self.n_update, - enlarge_per_dim=self.enlarge_per_dim, - n_points_min=self.n_points_min, - split_threshold=self.split_threshold, - n_networks=self.n_networks, - n_batch=self.n_batch, - n_like_new_bound=self.n_like_new_bound, - vectorized=self.vectorized, - seed=self.seed, - ) - - search_internal = self.call_search( - search_internal=search_internal, - model=model, - analysis=analysis, - fitness=fitness - ) - - # Nautilus creates its own multiprocessing.Pool internally when pool=N. - # Close them here so their finalizers don't fire at interpreter shutdown - # (after pickle has been torn down, causing AttributeError on Pool.__del__). - for pool_attr in ("pool_l", "pool_s"): - pool = getattr(search_internal, pool_attr, None) - if pool is not None: - try: - pool.close() - pool.join() - except Exception: - pass - setattr(search_internal, pool_attr, None) - - return search_internal - - def call_search(self, search_internal, model, analysis, fitness): - """ - The x1 CPU and multiprocessing searches both call this function to perform the non-linear search. - - This function calls the search a reduced number of times, corresponding to the `iterations_per_full_update` of the - search. This allows the search to output results on-the-fly, for example writing to the hard-disk the latest - model and samples. - - It tracks how often to do this update alongside the maximum number of iterations the search will perform. - This ensures that on-the-fly output is performed at regular intervals and that the search does not perform more - iterations than the `n_like_max` input variable. - - Parameters - ---------- - search_internal - The single CPU or multiprocessing search which is run and performs nested sampling. - model - The model which maps parameters chosen via the non-linear search (e.g. via the priors or sampling) to - instances of the model, which are passed to the fitness function. - analysis - Contains the data and the log likelihood function which fits an instance of the model to the data, returning - the log likelihood the search maximizes. - """ - - finished = False - - minimum_iterations_per_full_updates = 3 * self.n_live - - if self.iterations_per_full_update < minimum_iterations_per_full_updates: - - self.iterations_per_full_update = minimum_iterations_per_full_updates - - logger.info( - f""" - The number of iterations_per_full_update is less than 3 times the number of live points, which can cause - issues where Nautilus loses sampling information due to stopping to output results. The number of - iterations per update has been increased to 3 times the number of live points, therefore a value - of {minimum_iterations_per_full_updates}. - - To remove this warning, increase the number of iterations_per_full_update to three or more times the - number of live points. - """ - ) - - while not finished: - - iterations, total_iterations = self.iterations_from( - search_internal=search_internal - ) - - search_internal.run( - f_live=self.f_live, - n_shell=self.n_shell, - n_eff=self.n_eff, - discard_exploration=self.discard_exploration, - verbose=self.verbose, - n_like_max=iterations, - ) - - iterations_after_run = self.iterations_from(search_internal=search_internal)[1] - - if ( - total_iterations == iterations_after_run - or iterations_after_run == self.n_like_max - ): - finished = True - - if not finished: - - self.perform_update( - model=model, - analysis=analysis, - during_analysis=True, - fitness=fitness, - search_internal=search_internal - ) - - return search_internal - - def iterations_from( - self, search_internal - ) -> Tuple[int, int]: - """ - Returns the next number of iterations that a dynesty call will use and the total number of iterations - that have been performed so far. - - This is used so that the `iterations_per_full_update` input leads to on-the-fly output of dynesty results. - - It also ensures dynesty does not perform more samples than the `n_like_max` input variable. - - Parameters - ---------- - search_internal - The Dynesty sampler (static or dynamic) which is run and performs nested sampling. - - Returns - ------- - The next number of iterations that a dynesty run sampling will perform and the total number of iterations - it has performed so far. - """ - - if isinstance(self.paths, NullPaths): - if self.n_like_max is not None and self.n_like_max != float("inf"): - return int(self.n_like_max), int(self.n_like_max) - return int(1e99), int(1e99) - - try: - total_iterations = len(search_internal.posterior()[1]) - except ValueError: - total_iterations = 0 - - iterations = total_iterations + self.iterations_per_full_update - - if self.n_like_max is not None and self.n_like_max != float("inf"): - if iterations > self.n_like_max: - iterations = int(self.n_like_max) - - return iterations, total_iterations - - def output_search_internal(self, search_internal): - """ - Output the sampler results to hard-disk in their internal format. - - The multiprocessing `Pool` object cannot be pickled and thus the sampler cannot be saved to hard-disk. This - function therefore extracts the necessary information from the sampler and saves it to hard-disk. - - Parameters - ---------- - sampler - The nautilus sampler object containing the results of the model-fit. - """ - - pool_l = search_internal.pool_l - pool_s = search_internal.pool_s - - search_internal.pool_l = None - search_internal.pool_s = None - - self.paths.save_search_internal( - obj=search_internal, - ) - - search_internal.pool_l = pool_l - search_internal.pool_s = pool_s - - try: - os.remove(self.checkpoint_file) - except (TypeError, FileNotFoundError): - pass - - def samples_info_from(self, search_internal=None): - return { - "log_evidence": search_internal.evidence(), - "total_samples": int(search_internal.n_like), - "total_accepted_samples": int(search_internal.n_like), - "time": self.timer.time if self.timer else None, - "number_live_points": int(search_internal.n_live), - } - - def samples_via_internal_from( - self, model: AbstractPriorModel, search_internal=None - ): - """ - Returns a `Samples` object from the nautilus internal results. - - The samples contain all information on the parameter space sampling (e.g. the parameters, - log likelihoods, etc.). - - The internal search results are converted from the native format used by the search to lists of values - (e.g. `parameter_lists`, `log_likelihood_list`). - - Parameters - ---------- - model - Maps input vectors of unit parameter values to physical values and model instances via priors. - """ - - if search_internal is None: - search_internal = self.paths.load_search_internal() - - parameters, log_weights, log_likelihoods = search_internal.posterior() - - parameter_lists = parameters.tolist() - log_likelihood_list = log_likelihoods.tolist() - weight_list = np.exp(log_weights).tolist() - - log_prior_list = [ - sum(model.log_prior_list_from_vector(vector=vector)) - for vector in parameter_lists - ] - - sample_list = Sample.from_lists( - model=model, - parameter_lists=parameter_lists, - log_likelihood_list=log_likelihood_list, - log_prior_list=log_prior_list, - weight_list=weight_list, - ) - - return SamplesNest( - model=model, - sample_list=sample_list, - samples_info=self.samples_info_from(search_internal=search_internal), - ) - - @property - def batch_size(self): +import numpy as np +import logging +import os +import sys +from pathlib import Path +from typing import Dict, Optional, Tuple + +from autofit.database.sqlalchemy_ import sa + +from autofit.mapper.prior_model.abstract import AbstractPriorModel +from autofit.mapper.prior.vectorized import PriorVectorized +from autofit.non_linear.fitness import Fitness +from autofit.non_linear.paths.null import NullPaths +from autofit.non_linear.search.nest import abstract_nest +from autofit.non_linear.samples.sample import Sample +from autofit.non_linear.samples.nest import SamplesNest +from autofit.non_linear.test_mode import is_test_mode + + +logger = logging.getLogger(__name__) + +class Nautilus(abstract_nest.AbstractNest): + __identifier_fields__ = ( + "n_live", + "n_update", + "enlarge_per_dim", + "n_points_min", + "split_threshold", + "n_networks", + "n_like_new_bound", + "seed", + "n_shell", + "n_eff", + ) + + def __init__( + self, + name: Optional[str] = None, + path_prefix: Optional[str] = None, + unique_tag: Optional[str] = None, + n_live: int = 3000, + n_update: Optional[int] = None, + enlarge_per_dim: float = 1.1, + n_points_min: Optional[int] = None, + split_threshold: int = 100, + n_networks: int = 4, + n_batch: int = 100, + n_like_new_bound: Optional[int] = None, + vectorized: bool = False, + seed: Optional[int] = None, + f_live: float = 0.01, + n_shell: int = 1, + n_eff: int = 500, + n_like_max: float = float("inf"), + discard_exploration: bool = False, + verbose: bool = True, + iterations_per_quick_update: Optional[int] = None, + iterations_per_full_update: int = None, + number_of_cores: int = 1, + silence: bool = False, + force_x1_cpu: bool = False, + session: Optional[sa.orm.Session] = None, + use_jax_vmap: bool = True, + **kwargs, + ): + """ + A Nautilus non-linear search. + + Nautilus is an optional requirement and must be installed manually via the command `pip install nautilus-sampler`. + It is optional as it has certain dependencies which are generally straight forward to install. + + For a full description of Nautilus checkout its Github and documentation webpages: + + https://github.com/johannesulf/nautilus + https://nautilus-sampler.readthedocs.io/en/stable/index.html + + Parameters + ---------- + name + The name of the search, controlling the last folder results are output. + path_prefix + The path of folders prefixing the name folder where results are output. + unique_tag + The name of a unique tag for this model-fit, which will be given a unique entry in the sqlite database + and also acts as the folder after the path prefix and before the search name. + n_live + Number of live points used for sampling. + n_batch + Number of likelihood evaluations performed at each step. + n_like_max + Maximum number of likelihood evaluations before stopping. + f_live + Maximum fraction of evidence in the live set before terminating. + n_eff + Minimum effective sample size before stopping. + iterations_per_full_update + The number of iterations performed between update (e.g. output latest model to hard-disk, visualization). + number_of_cores + The number of cores sampling is performed using a Python multiprocessing Pool instance. + silence + If True, the default print output of the non-linear search is silenced. + force_x1_cpu + If True, force single-CPU mode even when number_of_cores > 1. + session + An SQLalchemy session instance so the results of the model-fit are written to an SQLite database. + """ + + super().__init__( + name=name, + path_prefix=path_prefix, + unique_tag=unique_tag, + iterations_per_full_update=iterations_per_full_update, + iterations_per_quick_update=iterations_per_quick_update, + number_of_cores=number_of_cores, + silence=silence, + session=session, + **kwargs, + ) + + self.n_live = n_live + self.n_update = n_update + self.enlarge_per_dim = enlarge_per_dim + self.n_points_min = n_points_min + self.split_threshold = split_threshold + self.n_networks = n_networks + self.n_batch = n_batch + self.n_like_new_bound = n_like_new_bound + self.vectorized = vectorized + self.seed = seed + + self.f_live = f_live + self.n_shell = n_shell + self.n_eff = n_eff + self.n_like_max = n_like_max + self.discard_exploration = discard_exploration + self.verbose = verbose + + self.force_x1_cpu = force_x1_cpu + self.use_jax_vmap = use_jax_vmap + + if is_test_mode(): + self.apply_test_mode() + + self.logger.debug("Creating Nautilus Search") + + def apply_test_mode(self): + logger.warning( + "TEST MODE 1 (reduced iterations): Sampler will run with " + "minimal iterations for faster completion." + ) + self.n_like_max = 1 + + def _fit(self, model: AbstractPriorModel, analysis): + """ + Fit a model using the search and the Analysis class which contains the data and returns the log likelihood from + instances of the model, which the `NonLinearSearch` seeks to maximize. + + Parameters + ---------- + model : ModelMapper + The model which generates instances for different points in parameter space. + analysis : Analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the `NonLinearSearch` maximizes. + + Returns + ------- + A result object comprising the Samples object that includes the maximum log likelihood instance and full + set of accepted ssamples of the fit. + """ + + if not isinstance(self.paths, NullPaths): + checkpoint_exists = Path(self.checkpoint_file).exists() + else: + checkpoint_exists = False + + if checkpoint_exists: + self.logger.info( + "Resuming Nautilus non-linear search (previous samples found)." + ) + + else: + self.logger.info( + "Starting new Nautilus non-linear search (no previous samples found)." + ) + + if self.force_x1_cpu or analysis._use_jax: + + fitness = Fitness( + model=model, + analysis=analysis, + paths=self.paths, + fom_is_log_likelihood=True, + resample_figure_of_merit=-1.0e99, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, + use_jax_vmap=self.use_jax_vmap, + batch_size=self.n_batch, + ) + + search_internal = self.fit_x1_cpu( + fitness=fitness, + model=model, + analysis=analysis, + ) + + else: + + fitness = Fitness( + model=model, + analysis=analysis, + paths=self.paths, + fom_is_log_likelihood=True, + resample_figure_of_merit=-1.0e99, + iterations_per_quick_update=self.iterations_per_quick_update, + background_quick_update=self.quick_update_background, + live_visual_update=self.live_visual_update, + ) + + search_internal = self.fit_multiprocessing( + fitness=fitness, + model=model, + analysis=analysis, + ) + + return search_internal, fitness + + @property + def sampler_cls(self): + try: + from nautilus import Sampler + + return Sampler + except ModuleNotFoundError: + raise ModuleNotFoundError( + "\n--------------------\n" + "You are attempting to perform a model-fit using Nautilus. \n\n" + "However, the optional library Nautilus (https://nautilus-sampler.readthedocs.io/en/stable/index.html) is " + "not installed.\n\n" + "Install it via the command `pip install nautilus-sampler==1.0.5`.\n\n" + "----------------------" + ) + + @property + def checkpoint_file(self): + """ + The path to the file used for checkpointing. + + If autofit is not outputting results to hard-disk (e.g. paths is `NullPaths`), this function is bypassed. + """ + try: + return self.paths.search_internal_path / "checkpoint.hdf5" + except TypeError: + pass + + def fit_x1_cpu(self, fitness, model, analysis): + """ + Perform the non-linear search, using one CPU core. + + This is used if the likelihood function calls external libraries that cannot be parallelized or use + threading in a way that conflicts with the parallelization of the non-linear search. + + Parameters + ---------- + fitness + The function which takes a model instance and returns its log likelihood via the Analysis class + model + The model which maps parameters chosen via the non-linear search (e.g. via the priors or sampling) to + instances of the model, which are passed to the fitness function. + analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the search maximizes. + """ + + if analysis._use_jax: + self.logger.info( + "Running search with JAX vectorization (parallelization handled by JAX)." + ) + else: + self.logger.info( + "Running search where parallelization is disabled." + ) + + search_internal = self.sampler_cls( + prior=PriorVectorized(model=model), + likelihood=fitness.call_wrap, + n_dim=model.prior_count, + filepath=self.checkpoint_file, + pool=None, + vectorized=fitness.use_jax_vmap, + n_live=self.n_live, + n_update=self.n_update, + enlarge_per_dim=self.enlarge_per_dim, + n_points_min=self.n_points_min, + split_threshold=self.split_threshold, + n_networks=self.n_networks, + n_batch=self.n_batch, + n_like_new_bound=self.n_like_new_bound, + seed=self.seed, + ) + + return self.call_search(search_internal=search_internal, model=model, analysis=analysis, fitness=fitness) + + def fit_multiprocessing(self, fitness, model, analysis): + """ + Perform the non-linear search, using multiple CPU cores parallelized via Python's multiprocessing module. + + This uses PyAutoFit's sneaky pool class, which allows us to use the multiprocessing module in a way that plays + nicely with the non-linear search (e.g. exception handling, keyboard interupts, etc.). + + Multiprocessing parallelization can only parallelize across multiple cores on a single device, it cannot be + distributed across multiple devices or computing nodes. For that, use the `fit_mpi` method. + + Parameters + ---------- + fitness + The function which takes a model instance and returns its log likelihood via the Analysis class + model + The model which maps parameters chosen via the non-linear search (e.g. via the priors or sampling) to + instances of the model, which are passed to the fitness function. + analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the search maximizes. + """ + search_internal = self.sampler_cls( + prior=PriorVectorized(model=model), + likelihood=fitness.call_wrap, + n_dim=model.prior_count, + filepath=self.checkpoint_file, + pool=self.number_of_cores, + n_live=self.n_live, + n_update=self.n_update, + enlarge_per_dim=self.enlarge_per_dim, + n_points_min=self.n_points_min, + split_threshold=self.split_threshold, + n_networks=self.n_networks, + n_batch=self.n_batch, + n_like_new_bound=self.n_like_new_bound, + vectorized=self.vectorized, + seed=self.seed, + ) + + search_internal = self.call_search( + search_internal=search_internal, + model=model, + analysis=analysis, + fitness=fitness + ) + + # Nautilus creates its own multiprocessing.Pool internally when pool=N. + # Close them here so their finalizers don't fire at interpreter shutdown + # (after pickle has been torn down, causing AttributeError on Pool.__del__). + for pool_attr in ("pool_l", "pool_s"): + pool = getattr(search_internal, pool_attr, None) + if pool is not None: + try: + pool.close() + pool.join() + except Exception: + pass + setattr(search_internal, pool_attr, None) + + return search_internal + + def call_search(self, search_internal, model, analysis, fitness): + """ + The x1 CPU and multiprocessing searches both call this function to perform the non-linear search. + + This function calls the search a reduced number of times, corresponding to the `iterations_per_full_update` of the + search. This allows the search to output results on-the-fly, for example writing to the hard-disk the latest + model and samples. + + It tracks how often to do this update alongside the maximum number of iterations the search will perform. + This ensures that on-the-fly output is performed at regular intervals and that the search does not perform more + iterations than the `n_like_max` input variable. + + Parameters + ---------- + search_internal + The single CPU or multiprocessing search which is run and performs nested sampling. + model + The model which maps parameters chosen via the non-linear search (e.g. via the priors or sampling) to + instances of the model, which are passed to the fitness function. + analysis + Contains the data and the log likelihood function which fits an instance of the model to the data, returning + the log likelihood the search maximizes. + """ + + finished = False + + minimum_iterations_per_full_updates = 3 * self.n_live + + if self.iterations_per_full_update < minimum_iterations_per_full_updates: + + self.iterations_per_full_update = minimum_iterations_per_full_updates + + logger.info( + f""" + The number of iterations_per_full_update is less than 3 times the number of live points, which can cause + issues where Nautilus loses sampling information due to stopping to output results. The number of + iterations per update has been increased to 3 times the number of live points, therefore a value + of {minimum_iterations_per_full_updates}. + + To remove this warning, increase the number of iterations_per_full_update to three or more times the + number of live points. + """ + ) + + while not finished: + + iterations, total_iterations = self.iterations_from( + search_internal=search_internal + ) + + search_internal.run( + f_live=self.f_live, + n_shell=self.n_shell, + n_eff=self.n_eff, + discard_exploration=self.discard_exploration, + verbose=self.verbose, + n_like_max=iterations, + ) + + iterations_after_run = self.iterations_from(search_internal=search_internal)[1] + + if ( + total_iterations == iterations_after_run + or iterations_after_run == self.n_like_max + ): + finished = True + + if not finished: + + self.perform_update( + model=model, + analysis=analysis, + during_analysis=True, + fitness=fitness, + search_internal=search_internal + ) + + return search_internal + + def iterations_from( + self, search_internal + ) -> Tuple[int, int]: + """ + Returns the next number of iterations that a dynesty call will use and the total number of iterations + that have been performed so far. + + This is used so that the `iterations_per_full_update` input leads to on-the-fly output of dynesty results. + + It also ensures dynesty does not perform more samples than the `n_like_max` input variable. + + Parameters + ---------- + search_internal + The Dynesty sampler (static or dynamic) which is run and performs nested sampling. + + Returns + ------- + The next number of iterations that a dynesty run sampling will perform and the total number of iterations + it has performed so far. + """ + + if isinstance(self.paths, NullPaths): + if self.n_like_max is not None and self.n_like_max != float("inf"): + return int(self.n_like_max), int(self.n_like_max) + return int(1e99), int(1e99) + + try: + total_iterations = len(search_internal.posterior()[1]) + except ValueError: + total_iterations = 0 + + iterations = total_iterations + self.iterations_per_full_update + + if self.n_like_max is not None and self.n_like_max != float("inf"): + if iterations > self.n_like_max: + iterations = int(self.n_like_max) + + return iterations, total_iterations + + def output_search_internal(self, search_internal): + """ + Output the sampler results to hard-disk in their internal format. + + The multiprocessing `Pool` object cannot be pickled and thus the sampler cannot be saved to hard-disk. This + function therefore extracts the necessary information from the sampler and saves it to hard-disk. + + Parameters + ---------- + sampler + The nautilus sampler object containing the results of the model-fit. + """ + + pool_l = search_internal.pool_l + pool_s = search_internal.pool_s + + search_internal.pool_l = None + search_internal.pool_s = None + + self.paths.save_search_internal( + obj=search_internal, + ) + + search_internal.pool_l = pool_l + search_internal.pool_s = pool_s + + try: + os.remove(self.checkpoint_file) + except (TypeError, FileNotFoundError): + pass + + def samples_info_from(self, search_internal=None): + return { + "log_evidence": search_internal.evidence(), + "total_samples": int(search_internal.n_like), + "total_accepted_samples": int(search_internal.n_like), + "time": self.timer.time if self.timer else None, + "number_live_points": int(search_internal.n_live), + } + + def samples_via_internal_from( + self, model: AbstractPriorModel, search_internal=None + ): + """ + Returns a `Samples` object from the nautilus internal results. + + The samples contain all information on the parameter space sampling (e.g. the parameters, + log likelihoods, etc.). + + The internal search results are converted from the native format used by the search to lists of values + (e.g. `parameter_lists`, `log_likelihood_list`). + + Parameters + ---------- + model + Maps input vectors of unit parameter values to physical values and model instances via priors. + """ + + if search_internal is None: + search_internal = self.paths.load_search_internal() + + parameters, log_weights, log_likelihoods = search_internal.posterior() + + parameter_lists = parameters.tolist() + log_likelihood_list = log_likelihoods.tolist() + weight_list = np.exp(log_weights).tolist() + + log_prior_list = [ + sum(model.log_prior_list_from_vector(vector=vector)) + for vector in parameter_lists + ] + + sample_list = Sample.from_lists( + model=model, + parameter_lists=parameter_lists, + log_likelihood_list=log_likelihood_list, + log_prior_list=log_prior_list, + weight_list=weight_list, + ) + + return SamplesNest( + model=model, + sample_list=sample_list, + samples_info=self.samples_info_from(search_internal=search_internal), + ) + + @property + def batch_size(self): return self.n_batch \ No newline at end of file diff --git a/autofit/non_linear/settings.py b/autofit/non_linear/settings.py index d0dc298aa..5389ab8b4 100644 --- a/autofit/non_linear/settings.py +++ b/autofit/non_linear/settings.py @@ -1,60 +1,60 @@ -from typing import Optional, List - -from autofit.database.sqlalchemy_ import sa - - -class SettingsSearch: - def __init__( - self, - path_prefix: str, - unique_tag: Optional[str] = None, - number_of_cores: Optional[int] = 1, - session: Optional[sa.orm.Session] = None, - info: Optional[dict] = None, - use_jax_vmap: bool = True, - ): - """ - Stores all the input settings that are used in search's and their `fit functions. - - This is used for more concisely passing settings through pipelines written using the empirical Bayesian - functionality. - - Parameters - ---------- - path_prefix - The prefix of folders between the output path and the search folders. - unique_tag - The unique tag for this model-fit, which will be given a unique entry in the sqlite database and also acts as - the folder after the path prefix and before the search name. This is typically the name of the dataset. - number_of_cores - The number of CPU cores used to parallelize the model-fit. This is used internally in a non-linear search - for most model fits, but is done on a per-fit basis for grid based searches (e.g. sensitivity mapping). - session - The SQLite database session which is active means results are directly wrtten to the SQLite database - at the end of a fit and loaded from the database at the start. - info - Optional dictionary containing information about the model-fit that is stored in the database and can be - loaded by the aggregator after the model-fit is complete. - """ - - self.path_prefix = path_prefix - self.unique_tag = unique_tag - self.number_of_cores = number_of_cores - self.session = session - self.use_jax_vmap = use_jax_vmap - - self.info = info - - @property - def search_dict(self): - return { - "path_prefix": self.path_prefix, - "unique_tag": self.unique_tag, - "number_of_cores": self.number_of_cores, - "session": self.session, - "use_jax_vmap": self.use_jax_vmap, - } - - @property - def fit_dict(self): - return {"info": self.info} +from typing import Optional, List + +from autofit.database.sqlalchemy_ import sa + + +class SettingsSearch: + def __init__( + self, + path_prefix: str, + unique_tag: Optional[str] = None, + number_of_cores: Optional[int] = 1, + session: Optional[sa.orm.Session] = None, + info: Optional[dict] = None, + use_jax_vmap: bool = True, + ): + """ + Stores all the input settings that are used in search's and their `fit functions. + + This is used for more concisely passing settings through pipelines written using the empirical Bayesian + functionality. + + Parameters + ---------- + path_prefix + The prefix of folders between the output path and the search folders. + unique_tag + The unique tag for this model-fit, which will be given a unique entry in the sqlite database and also acts as + the folder after the path prefix and before the search name. This is typically the name of the dataset. + number_of_cores + The number of CPU cores used to parallelize the model-fit. This is used internally in a non-linear search + for most model fits, but is done on a per-fit basis for grid based searches (e.g. sensitivity mapping). + session + The SQLite database session which is active means results are directly wrtten to the SQLite database + at the end of a fit and loaded from the database at the start. + info + Optional dictionary containing information about the model-fit that is stored in the database and can be + loaded by the aggregator after the model-fit is complete. + """ + + self.path_prefix = path_prefix + self.unique_tag = unique_tag + self.number_of_cores = number_of_cores + self.session = session + self.use_jax_vmap = use_jax_vmap + + self.info = info + + @property + def search_dict(self): + return { + "path_prefix": self.path_prefix, + "unique_tag": self.unique_tag, + "number_of_cores": self.number_of_cores, + "session": self.session, + "use_jax_vmap": self.use_jax_vmap, + } + + @property + def fit_dict(self): + return {"info": self.info} diff --git a/autofit/non_linear/timer.py b/autofit/non_linear/timer.py index 3b39a308f..615023c6e 100644 --- a/autofit/non_linear/timer.py +++ b/autofit/non_linear/timer.py @@ -1,79 +1,79 @@ -import datetime as dt -import os -import time - - -class Timer: - - def __init__(self, timer_path: str): - """Times the run-time of the non-linear searches, by outputting a start-time file to the hard-disk and using - this to determine the total run time when a `NonLinearSearch` update is performed. - - Parameters - ---------- - timer_path - The directory in which the timer should save results - """ - - self.timer_path = timer_path - os.makedirs( - timer_path, - exist_ok=True - ) - - def start(self): - """ - Record the start time of a `NonLinearSearch` as universal date time, so that the run-time of the search can be - recorded. - """ - - start_time_path = self.timer_path / ".start_time" - - try: - with open(start_time_path) as f: - float(f.read()) - except FileNotFoundError: - start = time.time() - with open(start_time_path, "w+") as f: - f.write(str(start)) - - def update(self): - """ - Update the timer of the `NonLinearSearch` so it reflections how long the `NonLinearSearch` ahs been running. - """ - - try: - execution_time = str(time.time() - float(self.start_time)) - except TypeError: - return - - with open( - self.timer_path / ".time", "w+" - ) as f: - f.write(execution_time) - - @property - def start_time(self): - """ - Load the start time written to hard disk from the .start_time file. - """ - try: - with open( - self.timer_path / ".start_time", "r" - ) as f: - return f.read() - except FileNotFoundError: - return None - - @property - def time(self): - """ - Load the total time of the `NonLinearSearch` written to hard disk fom the .start_time file. - """ - try: - with open( - self.timer_path / ".time", "r" - ) as f: - return f.read() - except FileNotFoundError: - return None +import datetime as dt +import os +import time + + +class Timer: + + def __init__(self, timer_path: str): + """Times the run-time of the non-linear searches, by outputting a start-time file to the hard-disk and using + this to determine the total run time when a `NonLinearSearch` update is performed. + + Parameters + ---------- + timer_path + The directory in which the timer should save results + """ + + self.timer_path = timer_path + os.makedirs( + timer_path, + exist_ok=True + ) + + def start(self): + """ + Record the start time of a `NonLinearSearch` as universal date time, so that the run-time of the search can be + recorded. + """ + + start_time_path = self.timer_path / ".start_time" + + try: + with open(start_time_path) as f: + float(f.read()) + except FileNotFoundError: + start = time.time() + with open(start_time_path, "w+") as f: + f.write(str(start)) + + def update(self): + """ + Update the timer of the `NonLinearSearch` so it reflections how long the `NonLinearSearch` ahs been running. + """ + + try: + execution_time = str(time.time() - float(self.start_time)) + except TypeError: + return + + with open( + self.timer_path / ".time", "w+" + ) as f: + f.write(execution_time) + + @property + def start_time(self): + """ + Load the start time written to hard disk from the .start_time file. + """ + try: + with open( + self.timer_path / ".start_time", "r" + ) as f: + return f.read() + except FileNotFoundError: + return None + + @property + def time(self): + """ + Load the total time of the `NonLinearSearch` written to hard disk fom the .start_time file. + """ + try: + with open( + self.timer_path / ".time", "r" + ) as f: + return f.read() + except FileNotFoundError: + return None diff --git a/autofit/text/__init__.py b/autofit/text/__init__.py index b2f215bf8..86d68ec5c 100644 --- a/autofit/text/__init__.py +++ b/autofit/text/__init__.py @@ -1 +1 @@ -from autofit.text import samples_text as Samples +from autofit.text import samples_text as Samples diff --git a/autofit/text/formatter.py b/autofit/text/formatter.py index dfeb71c93..e699e9ea8 100644 --- a/autofit/text/formatter.py +++ b/autofit/text/formatter.py @@ -1,257 +1,257 @@ -import csv -import logging -from typing import Tuple, Union - -from pathlib import Path - -from autonerves import conf -from autofit.tools.util import open_ - -logger = logging.getLogger(__name__) - - -class FormatNode: - def __init__(self): - self._dict = dict() - self.value = None - - def __getitem__(self, item): - if item not in self._dict: - self._dict[item] = FormatNode() - return self._dict[item] - - def __len__(self): - return len(self._dict) - - def items(self): - return self._dict.items() - - def list(self, indent=4, line_length=90): - lines = [] - for key, value in self.items(): - indent_string = indent * " " - if value.value is not None: - value_string = str(value.value) - space_string = max((line_length - len(str(key))), 1) * " " - lines.append(f"{key}{space_string}{value_string}") - - if len(value) > 0: - sub_lines = value.list( - indent=indent, - line_length=line_length - indent, - ) - if value.value is None: - lines.append(key) - for line in sub_lines: - lines.append(f"{indent_string}{line}") - return lines - - -class TextFormatter: - def __init__(self, line_length=90, indent=4): - self.dict = FormatNode() - self.line_length = line_length - self.indent = indent - - def add_to_dict(self, path: Tuple[str, ...], value: str, info_dict: FormatNode): - key = path[0] - node = info_dict[key] - if len(path) == 1: - node.value = value - else: - self.add_to_dict(path[1:], value, node) - - def add(self, path: Tuple[str, ...], value): - self.add_to_dict(path, value, self.dict) - - @property - def text(self): - return "\n".join(map(str, self.list)) - - @property - def list(self): - return self.dict.list( - indent=self.indent, - line_length=self.line_length, - ) - - -def format_string_for_parameter_name(parameter_name: str) -> str: - """ - Get the format for the label. Attempts to extract the key string associated with - the dimension. Seems dodgy. - - Parameters - ---------- - parameter_name - A string label - - Returns - ------- - format - The format string (e.g {:.2f}) - """ - label_conf = conf.instance["notation"]["label_format"] - - try: - # noinspection PyProtectedMember - for key, value in sorted( - label_conf["format"].items(), - key=lambda item: len(item[0]), - reverse=True, - ): - if key in parameter_name: - return value - except KeyError: - pass - - logger.debug( - "Could not find an entry for the parameter {} in the label_format.ini config at path {}".format( - parameter_name, conf.instance.paths - ) - ) - - return "{:.4f}" - - -def convert_name_to_label(parameter_name, name_to_label): - if not name_to_label: - return parameter_name - - label_conf = conf.instance["notation"]["label"] - - try: - return label_conf["label"][parameter_name] - except KeyError: - logger.debug( - "Could not find an entry for the parameter {} in the label_format.iniconfig at paths {}".format( - parameter_name, conf.instance.paths - ) - ) - return parameter_name[0] - - -def add_whitespace(str0, str1, whitespace): - return f"{str0}{str1.rjust(whitespace - len(str0) + len(str1))}" - - -def value_result_string_from( - parameter_name, value, values_at_sigma=None, unit=None, format_string=None -): - format_str = format_string or format_string_for_parameter_name(parameter_name) - value = format_str.format(value) - - if unit is not None: - unit = f" {unit}" - else: - unit = "" - - if values_at_sigma is None: - return f"{value}{unit}" - else: - lower_value_at_sigma = format_str.format(values_at_sigma[0]) - upper_value_at_sigma = format_str.format(values_at_sigma[1]) - return f"{value} ({lower_value_at_sigma}, {upper_value_at_sigma}){unit}" - - -def parameter_result_latex_from( - parameter_name, - value, - errors=None, - superscript="", - unit=None, - format_string=None, - name_to_label=False, - include_name=True, - include_quickmath=False, -): - format_str = format_string or format_string_for_parameter_name(parameter_name) - value = format_str.format(value) - - name = convert_name_to_label( - parameter_name=parameter_name, name_to_label=name_to_label - ) - - if unit is not None: - unit = f" {unit}" - else: - unit = "" - - if not superscript: - superscript = "" - else: - superscript = f"^{{\\rm{{{superscript}}}}}" - - if errors is None: - if include_name: - parameter_result_latex = f"{name}{superscript} = {value}{unit}" - else: - parameter_result_latex = f"{value}{unit}" - - else: - lower_value_at_sigma = format_str.format(errors[0]) - upper_value_at_sigma = format_str.format(errors[1]) - - parameter_result = ( - f"{value}^{{+{upper_value_at_sigma}}}_{{-{lower_value_at_sigma}}}{unit}" - ) - - if include_name: - parameter_result_latex = f"{name}{superscript} = {value}^{{+{upper_value_at_sigma}}}_{{-{lower_value_at_sigma}}}{unit}" - else: - parameter_result_latex = parameter_result - - if "e" in format_str: - psplit = parameter_result.split("e") - - parameter_result_latex = ( - f"" - f"{psplit[0]}" - f"{psplit[1][3:]}" - f"{psplit[2][3:]}" - f"{psplit[3][-1]}" - f" \\times 10^{{{int(psplit[1][1:3])}}}" - ) - - if not include_quickmath: - return f"{parameter_result_latex} & " - return f"${parameter_result_latex}$ & " - - -def output_list_of_strings_to_file(file, list_of_strings): - with open_(file, "w") as f: - f.write("".join(list_of_strings)) - - -def write_table(headers, rows, filename: Union[str, Path]): - """ - Write a table of parameters, posteriors, priors and likelihoods. - - Parameters - ---------- - filename - Where the table is to be written - headers - The headers of the table - rows - The rows of the table - """ - column_max_widths = [ - max(len(str(value)) for value in column) - for column in zip(*([headers] + list(rows))) - ] - - with open(filename, "w+") as f: - writer = csv.writer(f) - - def write_row(row_): - writer.writerow( - [ - "{0:>{1}}".format("" if value is None else str(value), width) - for width, value in zip(column_max_widths, row_) - ] - ) - - write_row(headers) - for row in rows: - write_row(row) +import csv +import logging +from typing import Tuple, Union + +from pathlib import Path + +from autonerves import conf +from autofit.tools.util import open_ + +logger = logging.getLogger(__name__) + + +class FormatNode: + def __init__(self): + self._dict = dict() + self.value = None + + def __getitem__(self, item): + if item not in self._dict: + self._dict[item] = FormatNode() + return self._dict[item] + + def __len__(self): + return len(self._dict) + + def items(self): + return self._dict.items() + + def list(self, indent=4, line_length=90): + lines = [] + for key, value in self.items(): + indent_string = indent * " " + if value.value is not None: + value_string = str(value.value) + space_string = max((line_length - len(str(key))), 1) * " " + lines.append(f"{key}{space_string}{value_string}") + + if len(value) > 0: + sub_lines = value.list( + indent=indent, + line_length=line_length - indent, + ) + if value.value is None: + lines.append(key) + for line in sub_lines: + lines.append(f"{indent_string}{line}") + return lines + + +class TextFormatter: + def __init__(self, line_length=90, indent=4): + self.dict = FormatNode() + self.line_length = line_length + self.indent = indent + + def add_to_dict(self, path: Tuple[str, ...], value: str, info_dict: FormatNode): + key = path[0] + node = info_dict[key] + if len(path) == 1: + node.value = value + else: + self.add_to_dict(path[1:], value, node) + + def add(self, path: Tuple[str, ...], value): + self.add_to_dict(path, value, self.dict) + + @property + def text(self): + return "\n".join(map(str, self.list)) + + @property + def list(self): + return self.dict.list( + indent=self.indent, + line_length=self.line_length, + ) + + +def format_string_for_parameter_name(parameter_name: str) -> str: + """ + Get the format for the label. Attempts to extract the key string associated with + the dimension. Seems dodgy. + + Parameters + ---------- + parameter_name + A string label + + Returns + ------- + format + The format string (e.g {:.2f}) + """ + label_conf = conf.instance["notation"]["label_format"] + + try: + # noinspection PyProtectedMember + for key, value in sorted( + label_conf["format"].items(), + key=lambda item: len(item[0]), + reverse=True, + ): + if key in parameter_name: + return value + except KeyError: + pass + + logger.debug( + "Could not find an entry for the parameter {} in the label_format.ini config at path {}".format( + parameter_name, conf.instance.paths + ) + ) + + return "{:.4f}" + + +def convert_name_to_label(parameter_name, name_to_label): + if not name_to_label: + return parameter_name + + label_conf = conf.instance["notation"]["label"] + + try: + return label_conf["label"][parameter_name] + except KeyError: + logger.debug( + "Could not find an entry for the parameter {} in the label_format.iniconfig at paths {}".format( + parameter_name, conf.instance.paths + ) + ) + return parameter_name[0] + + +def add_whitespace(str0, str1, whitespace): + return f"{str0}{str1.rjust(whitespace - len(str0) + len(str1))}" + + +def value_result_string_from( + parameter_name, value, values_at_sigma=None, unit=None, format_string=None +): + format_str = format_string or format_string_for_parameter_name(parameter_name) + value = format_str.format(value) + + if unit is not None: + unit = f" {unit}" + else: + unit = "" + + if values_at_sigma is None: + return f"{value}{unit}" + else: + lower_value_at_sigma = format_str.format(values_at_sigma[0]) + upper_value_at_sigma = format_str.format(values_at_sigma[1]) + return f"{value} ({lower_value_at_sigma}, {upper_value_at_sigma}){unit}" + + +def parameter_result_latex_from( + parameter_name, + value, + errors=None, + superscript="", + unit=None, + format_string=None, + name_to_label=False, + include_name=True, + include_quickmath=False, +): + format_str = format_string or format_string_for_parameter_name(parameter_name) + value = format_str.format(value) + + name = convert_name_to_label( + parameter_name=parameter_name, name_to_label=name_to_label + ) + + if unit is not None: + unit = f" {unit}" + else: + unit = "" + + if not superscript: + superscript = "" + else: + superscript = f"^{{\\rm{{{superscript}}}}}" + + if errors is None: + if include_name: + parameter_result_latex = f"{name}{superscript} = {value}{unit}" + else: + parameter_result_latex = f"{value}{unit}" + + else: + lower_value_at_sigma = format_str.format(errors[0]) + upper_value_at_sigma = format_str.format(errors[1]) + + parameter_result = ( + f"{value}^{{+{upper_value_at_sigma}}}_{{-{lower_value_at_sigma}}}{unit}" + ) + + if include_name: + parameter_result_latex = f"{name}{superscript} = {value}^{{+{upper_value_at_sigma}}}_{{-{lower_value_at_sigma}}}{unit}" + else: + parameter_result_latex = parameter_result + + if "e" in format_str: + psplit = parameter_result.split("e") + + parameter_result_latex = ( + f"" + f"{psplit[0]}" + f"{psplit[1][3:]}" + f"{psplit[2][3:]}" + f"{psplit[3][-1]}" + f" \\times 10^{{{int(psplit[1][1:3])}}}" + ) + + if not include_quickmath: + return f"{parameter_result_latex} & " + return f"${parameter_result_latex}$ & " + + +def output_list_of_strings_to_file(file, list_of_strings): + with open_(file, "w") as f: + f.write("".join(list_of_strings)) + + +def write_table(headers, rows, filename: Union[str, Path]): + """ + Write a table of parameters, posteriors, priors and likelihoods. + + Parameters + ---------- + filename + Where the table is to be written + headers + The headers of the table + rows + The rows of the table + """ + column_max_widths = [ + max(len(str(value)) for value in column) + for column in zip(*([headers] + list(rows))) + ] + + with open(filename, "w+") as f: + writer = csv.writer(f) + + def write_row(row_): + writer.writerow( + [ + "{0:>{1}}".format("" if value is None else str(value), width) + for width, value in zip(column_max_widths, row_) + ] + ) + + write_row(headers) + for row in rows: + write_row(row) diff --git a/autofit/text/samples_text.py b/autofit/text/samples_text.py index 5ba7e84ea..52804548d 100644 --- a/autofit/text/samples_text.py +++ b/autofit/text/samples_text.py @@ -1,99 +1,99 @@ -import logging - -from autofit.mapper.prior_model.representative import find_groups -from autofit.text import formatter as frm - -logger = logging.getLogger(__name__) - - -def values_from_samples(samples, median_pdf_model): - if median_pdf_model: - return samples.median_pdf(as_instance=False) - return samples.max_log_likelihood(as_instance=False) - - -def summary( - samples, sigma=3.0, median_pdf_model=True, indent=1, line_length=None -) -> str: - """ - Create a string summarizing the results of the `NonLinearSearch` at an input sigma value. - - This function is used for creating the model.results files of a non-linear search. - - Parameters - ---------- - sigma - The sigma within which the PDF is used to estimate errors (e.g. sigma = 1.0 uses 0.6826 of the PDF). - """ - - values = values_from_samples(samples=samples, median_pdf_model=median_pdf_model) - values_at_sigma = samples.values_at_sigma(sigma=sigma, as_instance=False) - - parameter_names = samples.model.parameter_names - - if line_length is None: - line_length = len(max(parameter_names, key=len)) + 8 - - sigma_formatter = frm.TextFormatter(indent=indent, line_length=line_length) - - prior_result_map = {} - - for i, (_, prior) in enumerate(samples.model.unique_path_prior_tuples): - prior_result_map[prior] = frm.value_result_string_from( - parameter_name=parameter_names[i], - value=values[i], - values_at_sigma=values_at_sigma[i], - ) - - paths = [] - for path, prior in samples.model.path_priors_tuples: - paths.append((path, prior_result_map[prior])) - - for path, value in find_groups(paths): - sigma_formatter.add(path, value) - - return f"\n\nSummary ({sigma} sigma limits):\n\n{sigma_formatter.text}" - - -def latex( - samples, - median_pdf_model=True, - sigma=3.0, - name_to_label=True, - include_name=True, - include_quickmath=False, - prefix="", - suffix="", -) -> str: - """ - Create a string summarizing the results of the `NonLinearSearch` at an input sigma value. - - This function is used for creating the model.results files of a non-linear search. - - Parameters - ---------- - sigma - The sigma within which the PDF is used to estimate errors (e.g. sigma = 1.0 uses 0.6826 of the PDF). - """ - - values = values_from_samples(samples=samples, median_pdf_model=median_pdf_model) - errors_at_sigma = samples.errors_at_sigma(sigma=sigma, as_instance=False) - - table = [] - - for i in range(samples.model.prior_count): - label_value = frm.parameter_result_latex_from( - parameter_name=samples.model.parameter_names[i], - value=values[i], - errors=errors_at_sigma[i], - superscript=samples.model.superscripts[i], - name_to_label=name_to_label, - include_name=include_name, - include_quickmath=include_quickmath, - ) - - table.append(f"{label_value}") - - table = "".join(table)[:-3] - - return f"{prefix}{table}{suffix}" +import logging + +from autofit.mapper.prior_model.representative import find_groups +from autofit.text import formatter as frm + +logger = logging.getLogger(__name__) + + +def values_from_samples(samples, median_pdf_model): + if median_pdf_model: + return samples.median_pdf(as_instance=False) + return samples.max_log_likelihood(as_instance=False) + + +def summary( + samples, sigma=3.0, median_pdf_model=True, indent=1, line_length=None +) -> str: + """ + Create a string summarizing the results of the `NonLinearSearch` at an input sigma value. + + This function is used for creating the model.results files of a non-linear search. + + Parameters + ---------- + sigma + The sigma within which the PDF is used to estimate errors (e.g. sigma = 1.0 uses 0.6826 of the PDF). + """ + + values = values_from_samples(samples=samples, median_pdf_model=median_pdf_model) + values_at_sigma = samples.values_at_sigma(sigma=sigma, as_instance=False) + + parameter_names = samples.model.parameter_names + + if line_length is None: + line_length = len(max(parameter_names, key=len)) + 8 + + sigma_formatter = frm.TextFormatter(indent=indent, line_length=line_length) + + prior_result_map = {} + + for i, (_, prior) in enumerate(samples.model.unique_path_prior_tuples): + prior_result_map[prior] = frm.value_result_string_from( + parameter_name=parameter_names[i], + value=values[i], + values_at_sigma=values_at_sigma[i], + ) + + paths = [] + for path, prior in samples.model.path_priors_tuples: + paths.append((path, prior_result_map[prior])) + + for path, value in find_groups(paths): + sigma_formatter.add(path, value) + + return f"\n\nSummary ({sigma} sigma limits):\n\n{sigma_formatter.text}" + + +def latex( + samples, + median_pdf_model=True, + sigma=3.0, + name_to_label=True, + include_name=True, + include_quickmath=False, + prefix="", + suffix="", +) -> str: + """ + Create a string summarizing the results of the `NonLinearSearch` at an input sigma value. + + This function is used for creating the model.results files of a non-linear search. + + Parameters + ---------- + sigma + The sigma within which the PDF is used to estimate errors (e.g. sigma = 1.0 uses 0.6826 of the PDF). + """ + + values = values_from_samples(samples=samples, median_pdf_model=median_pdf_model) + errors_at_sigma = samples.errors_at_sigma(sigma=sigma, as_instance=False) + + table = [] + + for i in range(samples.model.prior_count): + label_value = frm.parameter_result_latex_from( + parameter_name=samples.model.parameter_names[i], + value=values[i], + errors=errors_at_sigma[i], + superscript=samples.model.superscripts[i], + name_to_label=name_to_label, + include_name=include_name, + include_quickmath=include_quickmath, + ) + + table.append(f"{label_value}") + + table = "".join(table)[:-3] + + return f"{prefix}{table}{suffix}" diff --git a/autofit/text/text_util.py b/autofit/text/text_util.py index fb6e33a34..9d8720faf 100644 --- a/autofit/text/text_util.py +++ b/autofit/text/text_util.py @@ -1,165 +1,165 @@ -import datetime as dt -from typing import List - -from autonerves import conf -from autofit.mapper.prior_model.representative import find_groups -from autofit.text import formatter as frm, samples_text -from autofit.tools.util import info_whitespace - - -def padding(item, target=6): - string = str(item) - difference = target - len(string) - prefix = difference * " " - return f"{prefix}{string}" - - -def result_max_lh_info_from(max_log_likelihood_sample : List[float], max_log_likelihood : float, model) -> List[str]: - """ - Output the maximum log likelihood model only, for quick reference. - """ - results = [] - - results += [ - frm.add_whitespace( - str0="Maximum Log Likelihood ", - str1="{:.8f}".format(max_log_likelihood), - whitespace=info_whitespace(), - ) - ] - - results += ["\n\n", model.parameterization] - - results += ["\n\nMaximum Log Likelihood Model:\n\n"] - - formatter = frm.TextFormatter(line_length=info_whitespace()) - - paths = [] - - for (_, prior), value in zip( - model.unique_path_prior_tuples, - max_log_likelihood_sample, - ): - for path in model.all_paths_for_prior(prior): - paths.append((path, value)) - - for path, value in find_groups(paths): - formatter.add(path, format_str().format(value)) - results += [formatter.text + "\n"] - - return results - - -def result_info_from(samples) -> str: - """ - Output the full model.results file, which include the most-likely model, most-probable model at 1 and 3 - sigma confidence and information on the maximum log likelihood. - """ - from autofit.non_linear.test_mode import skip_fit_output - - if skip_fit_output(): - return "[fit output skipped — PYAUTO_SKIP_FIT_OUTPUT=1]" - - results = [] - - if hasattr(samples, "log_evidence"): - if samples.log_evidence is not None: - results += [ - frm.add_whitespace( - str0="Bayesian Evidence ", - str1="{:.8f}".format(samples.log_evidence), - whitespace=info_whitespace(), - ) - ] - results += ["\n"] - - max_log_likelihood_sample = samples.max_log_likelihood(as_instance=False) - - results += result_max_lh_info_from( - max_log_likelihood_sample=max_log_likelihood_sample, - max_log_likelihood=(max(samples.log_likelihood_list)), - model=samples.model, - ) - - if hasattr(samples, "pdf_converged"): - if samples.pdf_converged: - results += samples_text.summary( - samples=samples, sigma=3.0, indent=4, line_length=info_whitespace() - ) - results += ["\n"] - results += samples_text.summary( - samples=samples, sigma=1.0, indent=4, line_length=info_whitespace() - ) - - else: - results += [ - "\n WARNING: The samples have not converged enough to compute a PDF and model errors. \n " - "The model below over estimates errors. \n\n" - ] - results += samples_text.summary( - samples=samples, sigma=1.0, indent=4, line_length=info_whitespace() - ) - - results += ["\n\ninstances\n"] - - formatter = frm.TextFormatter(line_length=info_whitespace()) - - for path, value in find_groups(samples.model.path_float_tuples): - formatter.add(path, value) - - results += ["\n" + formatter.text] - - return "".join(results) - - -def search_summary_from_samples(samples) -> [str]: - line = [f"Total Samples = {samples.total_samples}\n"] - if hasattr(samples, "total_accepted_samples"): - line.append(f"Total Accepted Samples = {samples.total_accepted_samples}\n") - line.append(f"Acceptance Ratio = {samples.acceptance_ratio}\n") - if samples.time is not None: - line.append(f"Time To Run = {dt.timedelta(seconds=float(samples.time))}\n") - line.append( - f"Time Per Sample (seconds) = {float(samples.time) / samples.total_samples}\n" - ) - return line - - -def search_summary_to_file( - samples, - log_likelihood_function_time, - filename, - visualization_time=None, -): - summary = search_summary_from_samples(samples=samples) - summary.append( - f"Log Likelihood Function Evaluation Time (seconds) = {log_likelihood_function_time}\n" - ) - - expected_time = dt.timedelta( - seconds=float(samples.total_samples * log_likelihood_function_time) - ) - summary.append(f"Expected Time To Run (seconds) = {expected_time}\n") - - try: - speed_up_factor = float(expected_time.total_seconds()) / float(samples.time) - summary.append( - f"Speed Up Factor (e.g. due to parallelization) = {speed_up_factor}\n" - ) - except TypeError: - pass - - if visualization_time is not None: - summary.append( - f"Visualization Time (seconds) = {visualization_time}" - ) - - frm.output_list_of_strings_to_file(file=filename, list_of_strings=summary) - - -def format_str() -> str: - """The format string for the model.results file, describing to how many decimal points every parameter - estimate is output in the model.results file. - """ - decimal_places = conf.instance["general"]["output"]["model_results_decimal_places"] - return f"{{:.{decimal_places}f}}" +import datetime as dt +from typing import List + +from autonerves import conf +from autofit.mapper.prior_model.representative import find_groups +from autofit.text import formatter as frm, samples_text +from autofit.tools.util import info_whitespace + + +def padding(item, target=6): + string = str(item) + difference = target - len(string) + prefix = difference * " " + return f"{prefix}{string}" + + +def result_max_lh_info_from(max_log_likelihood_sample : List[float], max_log_likelihood : float, model) -> List[str]: + """ + Output the maximum log likelihood model only, for quick reference. + """ + results = [] + + results += [ + frm.add_whitespace( + str0="Maximum Log Likelihood ", + str1="{:.8f}".format(max_log_likelihood), + whitespace=info_whitespace(), + ) + ] + + results += ["\n\n", model.parameterization] + + results += ["\n\nMaximum Log Likelihood Model:\n\n"] + + formatter = frm.TextFormatter(line_length=info_whitespace()) + + paths = [] + + for (_, prior), value in zip( + model.unique_path_prior_tuples, + max_log_likelihood_sample, + ): + for path in model.all_paths_for_prior(prior): + paths.append((path, value)) + + for path, value in find_groups(paths): + formatter.add(path, format_str().format(value)) + results += [formatter.text + "\n"] + + return results + + +def result_info_from(samples) -> str: + """ + Output the full model.results file, which include the most-likely model, most-probable model at 1 and 3 + sigma confidence and information on the maximum log likelihood. + """ + from autofit.non_linear.test_mode import skip_fit_output + + if skip_fit_output(): + return "[fit output skipped — PYAUTO_SKIP_FIT_OUTPUT=1]" + + results = [] + + if hasattr(samples, "log_evidence"): + if samples.log_evidence is not None: + results += [ + frm.add_whitespace( + str0="Bayesian Evidence ", + str1="{:.8f}".format(samples.log_evidence), + whitespace=info_whitespace(), + ) + ] + results += ["\n"] + + max_log_likelihood_sample = samples.max_log_likelihood(as_instance=False) + + results += result_max_lh_info_from( + max_log_likelihood_sample=max_log_likelihood_sample, + max_log_likelihood=(max(samples.log_likelihood_list)), + model=samples.model, + ) + + if hasattr(samples, "pdf_converged"): + if samples.pdf_converged: + results += samples_text.summary( + samples=samples, sigma=3.0, indent=4, line_length=info_whitespace() + ) + results += ["\n"] + results += samples_text.summary( + samples=samples, sigma=1.0, indent=4, line_length=info_whitespace() + ) + + else: + results += [ + "\n WARNING: The samples have not converged enough to compute a PDF and model errors. \n " + "The model below over estimates errors. \n\n" + ] + results += samples_text.summary( + samples=samples, sigma=1.0, indent=4, line_length=info_whitespace() + ) + + results += ["\n\ninstances\n"] + + formatter = frm.TextFormatter(line_length=info_whitespace()) + + for path, value in find_groups(samples.model.path_float_tuples): + formatter.add(path, value) + + results += ["\n" + formatter.text] + + return "".join(results) + + +def search_summary_from_samples(samples) -> [str]: + line = [f"Total Samples = {samples.total_samples}\n"] + if hasattr(samples, "total_accepted_samples"): + line.append(f"Total Accepted Samples = {samples.total_accepted_samples}\n") + line.append(f"Acceptance Ratio = {samples.acceptance_ratio}\n") + if samples.time is not None: + line.append(f"Time To Run = {dt.timedelta(seconds=float(samples.time))}\n") + line.append( + f"Time Per Sample (seconds) = {float(samples.time) / samples.total_samples}\n" + ) + return line + + +def search_summary_to_file( + samples, + log_likelihood_function_time, + filename, + visualization_time=None, +): + summary = search_summary_from_samples(samples=samples) + summary.append( + f"Log Likelihood Function Evaluation Time (seconds) = {log_likelihood_function_time}\n" + ) + + expected_time = dt.timedelta( + seconds=float(samples.total_samples * log_likelihood_function_time) + ) + summary.append(f"Expected Time To Run (seconds) = {expected_time}\n") + + try: + speed_up_factor = float(expected_time.total_seconds()) / float(samples.time) + summary.append( + f"Speed Up Factor (e.g. due to parallelization) = {speed_up_factor}\n" + ) + except TypeError: + pass + + if visualization_time is not None: + summary.append( + f"Visualization Time (seconds) = {visualization_time}" + ) + + frm.output_list_of_strings_to_file(file=filename, list_of_strings=summary) + + +def format_str() -> str: + """The format string for the model.results file, describing to how many decimal points every parameter + estimate is output in the model.results file. + """ + decimal_places = conf.instance["general"]["output"]["model_results_decimal_places"] + return f"{{:.{decimal_places}f}}" diff --git a/autofit/tools/util.py b/autofit/tools/util.py index f9efa8c60..533ba2318 100644 --- a/autofit/tools/util.py +++ b/autofit/tools/util.py @@ -1,135 +1,135 @@ -import inspect -import json -import os -import sys -import zipfile -from contextlib import contextmanager -from functools import wraps -from pathlib import Path -from typing import Union - -import numpy as np - -from autonerves import conf - - -def split_paths(func): - """ - Split string paths if they are passed. - - e.g. "lens.mass.centre" -> ["lens", "mass", "centre"] - """ - - @wraps(func) - def wrapper(self, paths): - paths = [path.split(".") if isinstance(path, str) else path for path in paths] - return func(self, paths) - - return wrapper - - -class IntervalCounter: - def __init__(self, interval): - self.count = 0 - self.interval = interval - - def __call__(self): - if self.interval == -1: - return False - self.count += 1 - return self.count % self.interval == 0 - - -def zip_directory(source_directory, output=None): - output = output or f"{source_directory}.zip" - with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as f: - for root, dirs, files in os.walk(source_directory): - for file in files: - f.write( - Path(root) / file, - Path(root[len(str(source_directory)):]) / file, - ) - - -def open_(filename, *flags): - directory = Path(filename) - os.makedirs(directory.parent, exist_ok=True) - return open(filename, *flags) - - -@contextmanager -def suppress_stdout(): - with open(os.devnull, "w") as devnull: - old_stdout = sys.stdout - sys.stdout = devnull - try: - yield - finally: - sys.stdout = old_stdout - - -def numpy_array_to_json( - array: np.ndarray, file_path: Union[Path, str], overwrite: bool = False -): - """ - Write a NumPy array to a json file. - - Parameters - ---------- - array - The array that is written to json. - file_path - The full path of the file that is output, including the file name and `.json` extension. - overwrite - If `True` and a file already exists with the input file_path the .json file is overwritten. If - `False`, an error will be raised. - - Returns - ------- - None - - Examples - -------- - array_2d = np.ones((5,5)) - numpy_array_to_json(array_2d=array_2d, file_path='/path/to/file/filename.json', overwrite=True) - """ - - file_dir = Path(file_path).parent - - if not file_dir.exists(): - os.makedirs(file_dir) - - if overwrite and Path(file_path).exists(): - os.remove(file_path) - - with open(file_path, "w+") as f: - json.dump(array.tolist(), f) - - -def numpy_array_from_json(file_path: Union[Path, str]): - """ - Read a 1D NumPy array from a .json file. - - After loading the NumPy array, the array is flipped upside-down using np.flipud. This is so that the structures - appear the same orientation as .json files loaded in DS9. - - Parameters - ---------- - file_path - The full path of the file that is loaded, including the file name and ``.json`` extension. - - Returns - ------- - ndarray - The NumPy array that is loaded from the .json file. - - Examples - -------- - array_2d = numpy_array_from_json(file_path='/path/to/file/filename.json') - """ - with open(file_path, "r") as f: - return np.asarray(json.load(f)) - - -def info_whitespace(): - return conf.instance["general"]["output"]["info_whitespace_length"] +import inspect +import json +import os +import sys +import zipfile +from contextlib import contextmanager +from functools import wraps +from pathlib import Path +from typing import Union + +import numpy as np + +from autonerves import conf + + +def split_paths(func): + """ + Split string paths if they are passed. + + e.g. "lens.mass.centre" -> ["lens", "mass", "centre"] + """ + + @wraps(func) + def wrapper(self, paths): + paths = [path.split(".") if isinstance(path, str) else path for path in paths] + return func(self, paths) + + return wrapper + + +class IntervalCounter: + def __init__(self, interval): + self.count = 0 + self.interval = interval + + def __call__(self): + if self.interval == -1: + return False + self.count += 1 + return self.count % self.interval == 0 + + +def zip_directory(source_directory, output=None): + output = output or f"{source_directory}.zip" + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as f: + for root, dirs, files in os.walk(source_directory): + for file in files: + f.write( + Path(root) / file, + Path(root[len(str(source_directory)):]) / file, + ) + + +def open_(filename, *flags): + directory = Path(filename) + os.makedirs(directory.parent, exist_ok=True) + return open(filename, *flags) + + +@contextmanager +def suppress_stdout(): + with open(os.devnull, "w") as devnull: + old_stdout = sys.stdout + sys.stdout = devnull + try: + yield + finally: + sys.stdout = old_stdout + + +def numpy_array_to_json( + array: np.ndarray, file_path: Union[Path, str], overwrite: bool = False +): + """ + Write a NumPy array to a json file. + + Parameters + ---------- + array + The array that is written to json. + file_path + The full path of the file that is output, including the file name and `.json` extension. + overwrite + If `True` and a file already exists with the input file_path the .json file is overwritten. If + `False`, an error will be raised. + + Returns + ------- + None + + Examples + -------- + array_2d = np.ones((5,5)) + numpy_array_to_json(array_2d=array_2d, file_path='/path/to/file/filename.json', overwrite=True) + """ + + file_dir = Path(file_path).parent + + if not file_dir.exists(): + os.makedirs(file_dir) + + if overwrite and Path(file_path).exists(): + os.remove(file_path) + + with open(file_path, "w+") as f: + json.dump(array.tolist(), f) + + +def numpy_array_from_json(file_path: Union[Path, str]): + """ + Read a 1D NumPy array from a .json file. + + After loading the NumPy array, the array is flipped upside-down using np.flipud. This is so that the structures + appear the same orientation as .json files loaded in DS9. + + Parameters + ---------- + file_path + The full path of the file that is loaded, including the file name and ``.json`` extension. + + Returns + ------- + ndarray + The NumPy array that is loaded from the .json file. + + Examples + -------- + array_2d = numpy_array_from_json(file_path='/path/to/file/filename.json') + """ + with open(file_path, "r") as f: + return np.asarray(json.load(f)) + + +def info_whitespace(): + return conf.instance["general"]["output"]["info_whitespace_length"] diff --git a/docs/Makefile b/docs/Makefile index 73a28c713..d4bb2cbb9 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,20 +1,20 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_templates/custom-class-template.rst b/docs/_templates/custom-class-template.rst index 14b3b95f3..e6acd3c5b 100644 --- a/docs/_templates/custom-class-template.rst +++ b/docs/_templates/custom-class-template.rst @@ -1,36 +1,36 @@ -{{ fullname | escape | underline}} - -.. currentmodule:: {{ module }} - -.. autoclass:: {{ objname }} - :members: - :show-inheritance: - :exclude-members: ndarray, __init__, __new__ - :special-members: __call__, __add__, __mul__ - - {% block methods %} - {% if methods %} - .. rubric:: {{ _('Methods') }} - - .. autosummary:: - :nosignatures: - {% for item in methods %} - {%- if not item.startswith('_') %} - ~{{ name }}.{{ item }} - {%- endif -%} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block attributes %} - {% if attributes %} - .. rubric:: {{ _('Attributes') }} - - .. autosummary:: - {% for item in attributes %} - {%- if not item.startswith('_') %} - ~{{ name }}.{{ item }} - {%- endif -%} - {%- endfor %} - {% endif %} +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :show-inheritance: + :exclude-members: ndarray, __init__, __new__ + :special-members: __call__, __add__, __mul__ + + {% block methods %} + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + :nosignatures: + {% for item in methods %} + {%- if not item.startswith('_') %} + ~{{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + {%- if not item.startswith('_') %} + ~{{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} {% endblock %} \ No newline at end of file diff --git a/docs/_templates/custom_module_template.rst b/docs/_templates/custom_module_template.rst index fd46b9053..dd90e32ec 100644 --- a/docs/_templates/custom_module_template.rst +++ b/docs/_templates/custom_module_template.rst @@ -1,66 +1,66 @@ -{{ fullname | escape | underline}} - -.. automodule:: {{ fullname }} - - {% block attributes %} - {% if attributes %} - .. rubric:: Module attributes - - .. autosummary:: - :toctree: - {% for item in attributes %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block functions %} - {% if functions %} - .. rubric:: {{ _('Functions') }} - - .. autosummary:: - :toctree: - :nosignatures: - {% for item in functions %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block classes %} - {% if classes %} - .. rubric:: {{ _('Classes') }} - - .. autosummary:: - :toctree: - :template: custom-class-template.rst - :nosignatures: - {% for item in classes %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block exceptions %} - {% if exceptions %} - .. rubric:: {{ _('Exceptions') }} - - .. autosummary:: - :toctree: - {% for item in exceptions %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - -{% block modules %} -{% if modules %} -.. autosummary:: - :toctree: - :template: custom-module-template.rst - :recursive: -{% for item in modules %} - {{ item }} -{%- endfor %} -{% endif %} +{{ fullname | escape | underline}} + +.. automodule:: {{ fullname }} + + {% block attributes %} + {% if attributes %} + .. rubric:: Module attributes + + .. autosummary:: + :toctree: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block functions %} + {% if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + :nosignatures: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block classes %} + {% if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + :template: custom-class-template.rst + :nosignatures: + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block exceptions %} + {% if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + +{% block modules %} +{% if modules %} +.. autosummary:: + :toctree: + :template: custom-module-template.rst + :recursive: +{% for item in modules %} + {{ item }} +{%- endfor %} +{% endif %} {% endblock %} \ No newline at end of file diff --git a/docs/api/analysis.rst b/docs/api/analysis.rst index 24425acfc..765552668 100644 --- a/docs/api/analysis.rst +++ b/docs/api/analysis.rst @@ -1,27 +1,27 @@ -======== -Analysis -======== - -The ``Analysis`` object defines the ``log_likelihood_function`` of your model-fitting problem. - -It acts as an interface between the data, model and the non-linear search. - -**Examples / Tutorials:** - -- `readthedocs: example using Analysis object `_. -- `autofit_workspace: simple tutorial `_ -- `autofit_workspace: complex tutorial `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - --------- -Analysis --------- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - +======== +Analysis +======== + +The ``Analysis`` object defines the ``log_likelihood_function`` of your model-fitting problem. + +It acts as an interface between the data, model and the non-linear search. + +**Examples / Tutorials:** + +- `readthedocs: example using Analysis object `_. +- `autofit_workspace: simple tutorial `_ +- `autofit_workspace: complex tutorial `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +-------- +Analysis +-------- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + Analysis \ No newline at end of file diff --git a/docs/api/database.rst b/docs/api/database.rst index 1f656d835..844d89e73 100644 --- a/docs/api/database.rst +++ b/docs/api/database.rst @@ -1,26 +1,26 @@ -======== -Database -======== - -PyAutoFit's database feature outputs all model-fitting results as a sqlite3 (https://docs.python.org/3/library/sqlite3.html) -relational database, such that all results can be efficiently loaded into a Jupyter notebook or Python script for -inspection, analysis and interpretation. - -**Examples / Tutorials:** - -- `readthedocs: example using database functionality `_ -- `autofit_workspace: tutorial using database `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - ----------- -Aggregator ----------- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - +======== +Database +======== + +PyAutoFit's database feature outputs all model-fitting results as a sqlite3 (https://docs.python.org/3/library/sqlite3.html) +relational database, such that all results can be efficiently loaded into a Jupyter notebook or Python script for +inspection, analysis and interpretation. + +**Examples / Tutorials:** + +- `readthedocs: example using database functionality `_ +- `autofit_workspace: tutorial using database `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +---------- +Aggregator +---------- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + Aggregator \ No newline at end of file diff --git a/docs/api/model.rst b/docs/api/model.rst index d69f7068b..a599cf8d4 100644 --- a/docs/api/model.rst +++ b/docs/api/model.rst @@ -1,31 +1,31 @@ -====== -Models -====== - -Model objects are used for composing models that are fitted to data. - -It is recommended the `model API cookbooks `_ are used for guidance on building complex model. - -**Examples / Tutorials:** - -- `Model API Cookbooks (recommended) `_. - -- `readthedocs: example using Model object `_. -- `readthedocs: example using Collection object `_. -- `autofit_workspace: simple tutorial `_ -- `autofit_workspace: complex tutorial `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - ------- -Models ------- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - Model +====== +Models +====== + +Model objects are used for composing models that are fitted to data. + +It is recommended the `model API cookbooks `_ are used for guidance on building complex model. + +**Examples / Tutorials:** + +- `Model API Cookbooks (recommended) `_. + +- `readthedocs: example using Model object `_. +- `readthedocs: example using Collection object `_. +- `autofit_workspace: simple tutorial `_ +- `autofit_workspace: complex tutorial `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +------ +Models +------ + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + Model Collection \ No newline at end of file diff --git a/docs/api/plot.rst b/docs/api/plot.rst index da930f2a6..7feb8ae27 100644 --- a/docs/api/plot.rst +++ b/docs/api/plot.rst @@ -1,27 +1,27 @@ -======== -Plotters -======== - -Create figures and subplots of non-linear search specific visualization of every search algorithm supported -by **PyAutoFit**. - -**Examples / Tutorials:** - -- `readthedocs: non-linear search example `_ -- `autofit_workspace: plot tutorials `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - --------- -Plotters --------- - -.. currentmodule:: autofit.plot - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - NestPlotter - MCMCPlotter +======== +Plotters +======== + +Create figures and subplots of non-linear search specific visualization of every search algorithm supported +by **PyAutoFit**. + +**Examples / Tutorials:** + +- `readthedocs: non-linear search example `_ +- `autofit_workspace: plot tutorials `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +-------- +Plotters +-------- + +.. currentmodule:: autofit.plot + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + NestPlotter + MCMCPlotter MLEPlotter \ No newline at end of file diff --git a/docs/api/priors.rst b/docs/api/priors.rst index 7b6bacc84..4c86af576 100644 --- a/docs/api/priors.rst +++ b/docs/api/priors.rst @@ -1,30 +1,30 @@ -====== -Priors -====== - -The priors of parameters of every component of a mdoel, which is fitted to data, are customized using ``Prior`` objects. - -**Examples / Tutorials:** - -- `Model API Cookbooks (recommended) `_. - -- `readthedocs: example using Model object `_. -- `readthedocs: example using Collection object `_. -- `autofit_workspace: simple tutorial `_ -- `autofit_workspace: complex tutorial `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - -Priors ------- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - UniformPrior - GaussianPrior - LogUniformPrior +====== +Priors +====== + +The priors of parameters of every component of a mdoel, which is fitted to data, are customized using ``Prior`` objects. + +**Examples / Tutorials:** + +- `Model API Cookbooks (recommended) `_. + +- `readthedocs: example using Model object `_. +- `readthedocs: example using Collection object `_. +- `autofit_workspace: simple tutorial `_ +- `autofit_workspace: complex tutorial `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +Priors +------ + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + UniformPrior + GaussianPrior + LogUniformPrior LogGaussianPrior \ No newline at end of file diff --git a/docs/api/samples.rst b/docs/api/samples.rst index cdbd545e7..cd99f4ccd 100644 --- a/docs/api/samples.rst +++ b/docs/api/samples.rst @@ -1,31 +1,31 @@ -======= -Samples -======= - -Every sample of a model-fit and non-liner search are stored in a ``Samples`` object, which can be manipulated to -inspect the results in detail (e.g. perform parameter estimation with errors). - -For example, for an MCMC model-fit, the ``Samples`` objects contains every sample of walker. - -**Examples / Tutorials:** - -- `readthedocs: example on using results `_. -- `autofit_workspace: simple results tutorial `_ -- `autofit_workspace: complex result tutorial `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - -Samples -------- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - Samples - SamplesPDF - SamplesMCMC - SamplesNest +======= +Samples +======= + +Every sample of a model-fit and non-liner search are stored in a ``Samples`` object, which can be manipulated to +inspect the results in detail (e.g. perform parameter estimation with errors). + +For example, for an MCMC model-fit, the ``Samples`` objects contains every sample of walker. + +**Examples / Tutorials:** + +- `readthedocs: example on using results `_. +- `autofit_workspace: simple results tutorial `_ +- `autofit_workspace: complex result tutorial `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +Samples +------- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + Samples + SamplesPDF + SamplesMCMC + SamplesNest SamplesStored \ No newline at end of file diff --git a/docs/api/searches.rst b/docs/api/searches.rst index 77debb1b4..bb35a77a6 100644 --- a/docs/api/searches.rst +++ b/docs/api/searches.rst @@ -1,93 +1,93 @@ -=================== -Non-Linear Searches -=================== - -A non-linear search is an algorithm which fits a model to data. - -**PyAutoFit** currently supports three types of non-linear search algorithms: nested samplers (nest), -Markov Chain Monte Carlo (MCMC) and Maximum Likelihood Estimators (MLE). - -**Examples / Tutorials:** - -- `readthedocs: example using non-linear searches `_. -- `autofit_workspace: simple tutorial `_ -- `autofit_workspace: complex tutorial `_ -- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ - -Nested Samplers ---------------- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - DynestyDynamic - DynestyStatic - -MCMC ----- - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - Emcee - Zeus - -Maximum Likelihood Estimators ------------------------------ - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - BFGS - LBFGS - -There are also a number of tools which are used to customize the behaviour of non-linear searches in **PyAutoFit**, -including directory output structure, parameter sample initialization and MCMC auto correlation analysis. - -Tools ------ - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - DirectoryPaths - DatabasePaths - Result - InitializerBall - InitializerPrior - AutoCorrelationsSettings - -**PyAutoFit** can perform a parallelized grid-search of non-linear searches, where a subset of parameters in the -model are fitted in over a discrete grid. - -**Examples / Tutorials:** - -- `readthedocs: example using a non-linear search grid search `_. -- `autofit_workspace: example using a non-linear search grid search `_ - -GridSearch ----------- - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - SearchGridSearch - GridSearchResult +=================== +Non-Linear Searches +=================== + +A non-linear search is an algorithm which fits a model to data. + +**PyAutoFit** currently supports three types of non-linear search algorithms: nested samplers (nest), +Markov Chain Monte Carlo (MCMC) and Maximum Likelihood Estimators (MLE). + +**Examples / Tutorials:** + +- `readthedocs: example using non-linear searches `_. +- `autofit_workspace: simple tutorial `_ +- `autofit_workspace: complex tutorial `_ +- `HowToFit: tutorial lectures (detailed step-by-step examples) `_ + +Nested Samplers +--------------- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + DynestyDynamic + DynestyStatic + +MCMC +---- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + Emcee + Zeus + +Maximum Likelihood Estimators +----------------------------- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + BFGS + LBFGS + +There are also a number of tools which are used to customize the behaviour of non-linear searches in **PyAutoFit**, +including directory output structure, parameter sample initialization and MCMC auto correlation analysis. + +Tools +----- + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + DirectoryPaths + DatabasePaths + Result + InitializerBall + InitializerPrior + AutoCorrelationsSettings + +**PyAutoFit** can perform a parallelized grid-search of non-linear searches, where a subset of parameters in the +model are fitted in over a discrete grid. + +**Examples / Tutorials:** + +- `readthedocs: example using a non-linear search grid search `_. +- `autofit_workspace: example using a non-linear search grid search `_ + +GridSearch +---------- + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + SearchGridSearch + GridSearchResult diff --git a/docs/api/source.rst b/docs/api/source.rst index d22330054..44bf3b3bb 100644 --- a/docs/api/source.rst +++ b/docs/api/source.rst @@ -1,24 +1,24 @@ -=========== -Source Code -=========== - -This page provided API docs for functionality which is typically not used by users, but is used internally in the -**PyAutoFit** source code. - -These docs are intended for developers, or users doing non-standard computations using internal **PyAutoFit** objects. - -Model Mapping -------------- - -These tools are used internally by **PyAutoFit** to map input lists of values (e.g. a unit cube of parameter values) -to model instances. - -.. currentmodule:: autofit - -.. autosummary:: - :toctree: _autosummary - :template: custom-class-template.rst - :recursive: - - ModelMapper - ModelInstance +=========== +Source Code +=========== + +This page provided API docs for functionality which is typically not used by users, but is used internally in the +**PyAutoFit** source code. + +These docs are intended for developers, or users doing non-standard computations using internal **PyAutoFit** objects. + +Model Mapping +------------- + +These tools are used internally by **PyAutoFit** to map input lists of values (e.g. a unit cube of parameter values) +to model instances. + +.. currentmodule:: autofit + +.. autosummary:: + :toctree: _autosummary + :template: custom-class-template.rst + :recursive: + + ModelMapper + ModelInstance diff --git a/docs/conf.py b/docs/conf.py index 6efb02c94..24f2b8a71 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,143 +1,143 @@ -import datetime -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use Path(...).resolve() to make it absolute, like shown here. -# - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(".").resolve())) - -import autofit - -# -- Project information ----------------------------------------------------- - -year = datetime.date.today().year -project = "PyAutoFit" -copyright = "2025, James Nightingale, Richard Hayes" -author = "James Nightingale, Richard Hayes" - -# The full version, including alpha/beta/rc tags -release = autofit.__version__ -master_doc = "index" - - -# -- General configuration --------------------------------------------------- - -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.autosummary", - "sphinx.ext.extlinks", - "sphinx.ext.intersphinx", - "sphinx.ext.mathjax", - "sphinx.ext.todo", - "sphinx.ext.napoleon", - "sphinx.ext.viewcode", - "numpydoc", - "sphinx_autodoc_typehints", - # External stuff - "myst_parser", - "sphinx_copybutton", - "sphinx_design", - "sphinx_inline_tabs", -] -templates_path = ["_templates"] - -set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints -add_module_names = False # Remove namespaces from class/method signatures - -# -- Options for extlinks ---------------------------------------------------- - -extlinks = { - "pypi": ("https://pypi.org/project/%s/", "%s"), -} - -# -- Options for intersphinx ------------------------------------------------- - -intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "sphinx": ("https://www.sphinx-doc.org/en/master", None), -} - -# -- Options for TODOs ------------------------------------------------------- - -todo_include_todos = True - -# -- Options for Markdown files ---------------------------------------------- - -myst_enable_extensions = [ - "colon_fence", - "deflist", - "dollarmath", -] -myst_heading_anchors = 3 - -autosummary_generate = True -autosummary_imported_members = True -autodoc_member_order = "bysource" -autodoc_default_options = { - "members": True, - "undoc-members": True, - "show-inheritance": True, -} -autodoc_class_signature = "separated" -autoclass_content = "init" - -numpydoc_show_class_members = False -numpydoc_show_inherited_class_members = False -numpydoc_class_members_toctree = True - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - - -# -- Options for HTML output ------------------------------------------------- - -html_theme = "furo" -html_title = "PyAutoFit" -html_short_title = "PyAutoFit" -html_permalinks_icon = '#' -html_last_updated_fmt = "%b %d, %Y" - -html_show_sourcelink = False -html_show_sphinx = True -html_show_copyright = True - -# pygments_style = "sphinx" -# pygments_dark_style = "monokai" -add_function_parentheses = False - -language = "en" - -html_static_path = ["_static"] -html_css_files = ["pyauto.css"] - -html_theme_options = { - "light_css_variables": { - "color-brand-primary": "#c2410c", - "color-brand-content": "#c2410c", - }, - "dark_css_variables": { - "color-brand-primary": "#f28c38", - "color-brand-content": "#f28c38", - }, -} - -from sphinx.builders.html import StandaloneHTMLBuilder - -StandaloneHTMLBuilder.supported_image_types = ["image/gif", "image/png", "image/jpeg"] - -typehints_fully_qualified = False -always_document_param_types = False -typehints_document_rtype = False +import datetime +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use Path(...).resolve() to make it absolute, like shown here. +# + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(".").resolve())) + +import autofit + +# -- Project information ----------------------------------------------------- + +year = datetime.date.today().year +project = "PyAutoFit" +copyright = "2025, James Nightingale, Richard Hayes" +author = "James Nightingale, Richard Hayes" + +# The full version, including alpha/beta/rc tags +release = autofit.__version__ +master_doc = "index" + + +# -- General configuration --------------------------------------------------- + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", + "sphinx.ext.todo", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "numpydoc", + "sphinx_autodoc_typehints", + # External stuff + "myst_parser", + "sphinx_copybutton", + "sphinx_design", + "sphinx_inline_tabs", +] +templates_path = ["_templates"] + +set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints +add_module_names = False # Remove namespaces from class/method signatures + +# -- Options for extlinks ---------------------------------------------------- + +extlinks = { + "pypi": ("https://pypi.org/project/%s/", "%s"), +} + +# -- Options for intersphinx ------------------------------------------------- + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "sphinx": ("https://www.sphinx-doc.org/en/master", None), +} + +# -- Options for TODOs ------------------------------------------------------- + +todo_include_todos = True + +# -- Options for Markdown files ---------------------------------------------- + +myst_enable_extensions = [ + "colon_fence", + "deflist", + "dollarmath", +] +myst_heading_anchors = 3 + +autosummary_generate = True +autosummary_imported_members = True +autodoc_member_order = "bysource" +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} +autodoc_class_signature = "separated" +autoclass_content = "init" + +numpydoc_show_class_members = False +numpydoc_show_inherited_class_members = False +numpydoc_class_members_toctree = True + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "furo" +html_title = "PyAutoFit" +html_short_title = "PyAutoFit" +html_permalinks_icon = '#' +html_last_updated_fmt = "%b %d, %Y" + +html_show_sourcelink = False +html_show_sphinx = True +html_show_copyright = True + +# pygments_style = "sphinx" +# pygments_dark_style = "monokai" +add_function_parentheses = False + +language = "en" + +html_static_path = ["_static"] +html_css_files = ["pyauto.css"] + +html_theme_options = { + "light_css_variables": { + "color-brand-primary": "#c2410c", + "color-brand-content": "#c2410c", + }, + "dark_css_variables": { + "color-brand-primary": "#f28c38", + "color-brand-content": "#f28c38", + }, +} + +from sphinx.builders.html import StandaloneHTMLBuilder + +StandaloneHTMLBuilder.supported_image_types = ["image/gif", "image/png", "image/jpeg"] + +typehints_fully_qualified = False +always_document_param_types = False +typehints_document_rtype = False diff --git a/docs/make.bat b/docs/make.bat index 2119f5109..922152e96 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,35 +1,35 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/files/citation.tex b/files/citation.tex index 4f67ad8be..c2dcd148c 100644 --- a/files/citation.tex +++ b/files/citation.tex @@ -1,49 +1,49 @@ -In the main body of the paper: - -We use the probabilistic programming language \textt{PyAutoFit}\footnote{https://github.com/rhayes777/PyAutoFit} \citep{pyautofit} to... - -At the end of the paper (delete as appropriate, see https://pyautofit.readthedocs.io/en/latest/general/citations.html): - -\section*{Software Citations} - -This work uses the following software packages: - -\begin{itemize} - -\item -\href{https://github.com/dfm/corner.py}{\textt{corner.py}} -\citep{corner} - -\item -\href{https://github.com/joshspeagle/dynesty}{\textt{dynesty}} -\citep{dynesty} - -\item -\href{https://github.com/dfm/emcee}{\textt{emcee}} -\citep{emcee} - -\item -\href{https://github.com/matplotlib/matplotlib}{\textt{matplotlib}} -\citep{matplotlib} - -\item -\href{https://github.com/numpy/numpy}{\textt{NumPy}} -\citep{numpy} - -\item -\href{https://www.python.org/}{\textt{Python}} -\citep{python} - -\item -\href{https://github.com/scipy/scipy}{\textt{Scipy}} -\citep{scipy} - -\item -\href{https://www.sqlite.org/index.html} -\citep{sqlite} - -\item -\href{https://github.com/minaskar/zeus}{\textt{zeus}} -\citep{zeus1, zeus2} - +In the main body of the paper: + +We use the probabilistic programming language \textt{PyAutoFit}\footnote{https://github.com/rhayes777/PyAutoFit} \citep{pyautofit} to... + +At the end of the paper (delete as appropriate, see https://pyautofit.readthedocs.io/en/latest/general/citations.html): + +\section*{Software Citations} + +This work uses the following software packages: + +\begin{itemize} + +\item +\href{https://github.com/dfm/corner.py}{\textt{corner.py}} +\citep{corner} + +\item +\href{https://github.com/joshspeagle/dynesty}{\textt{dynesty}} +\citep{dynesty} + +\item +\href{https://github.com/dfm/emcee}{\textt{emcee}} +\citep{emcee} + +\item +\href{https://github.com/matplotlib/matplotlib}{\textt{matplotlib}} +\citep{matplotlib} + +\item +\href{https://github.com/numpy/numpy}{\textt{NumPy}} +\citep{numpy} + +\item +\href{https://www.python.org/}{\textt{Python}} +\citep{python} + +\item +\href{https://github.com/scipy/scipy}{\textt{Scipy}} +\citep{scipy} + +\item +\href{https://www.sqlite.org/index.html} +\citep{sqlite} + +\item +\href{https://github.com/minaskar/zeus}{\textt{zeus}} +\citep{zeus1, zeus2} + \end{itemize} \ No newline at end of file diff --git a/files/citations.bib b/files/citations.bib index 9e4107837..d7945bc08 100644 --- a/files/citations.bib +++ b/files/citations.bib @@ -1,186 +1,186 @@ -@article{corner, - doi = {10.21105/joss.00024}, - url = {https://doi.org/10.21105/joss.00024}, - year = {2016}, - month = {jun}, - publisher = {The Open Journal}, - volume = {1}, - number = {2}, - pages = {24}, - author = {Daniel Foreman-Mackey}, - title = {corner.py: Scatterplot matrices in Python}, - journal = {The Journal of Open Source Software} -} -@article{dynesty, -abstract = {We present dynesty, a public, open-source, python package to estimate Bayesian posteriors and evidences (marginal likelihoods) using the dynamic nested sampling methods developed by Higson et al. By adaptively allocating samples based on posterior structure, dynamic nested sampling has the benefits of Markov chain Monte Carlo (MCMC) algorithms that focus exclusively on posterior estimation while retaining nested sampling's ability to estimate evidences and sample from complex, multimodal distributions. We provide an overview of nested sampling, its extension to dynamic nested sampling, the algorithmic challenges involved, and the various approaches taken to solve them in this and previous work. We then examine dynesty's performance on a variety of toy problems along with several astronomical applications. We find in particular problems dynesty can provide substantial improvements in sampling efficiency compared to popular MCMC approaches in the astronomical literature. More detailed statistical results related to nested sampling are also included in the appendix.}, -archivePrefix = {arXiv}, -arxivId = {1904.02180}, -author = {Speagle, Joshua S}, -doi = {10.1093/mnras/staa278}, -eprint = {1904.02180}, -file = {:home/jammy/Documents/Papers/PPLs/Dynesty.pdf:pdf}, -issn = {0035-8711}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {data analysis,methods,statistical}, -number = {3}, -pages = {3132--3158}, -title = {{dynesty: a dynamic nested sampling package for estimating Bayesian posteriors and evidences}}, -volume = {493}, -year = {2020} -} -@article{emcee, -abstract = {We introduce a stable, well tested Python implementation of the affine-invariant ensemble sampler for Markov chain Monte Carlo (MCMC) proposed by Goodman {\&} Weare (2010). The code is open source and has already been used in several published projects in the astrophysics literature. The algorithm behind emcee has several advantages over traditional MCMC sampling methods and it has excellent performance as measured by the autocorrelation time (or function calls per independent sample). One major advantage of the algorithm is that it requires hand-tuning of only 1 or 2 parameters compared to {\$}\backslashsim N{\^{}}2{\$} for a traditional algorithm in an N-dimensional parameter space. In this document, we describe the algorithm and the details of our implementation and API. Exploiting the parallelism of the ensemble method, emcee permits any user to take advantage of multiple CPU cores without extra effort. The code is available online at http://dan.iel.fm/emcee under the MIT License.}, -archivePrefix = {arXiv}, -arxivId = {1202.3665}, -author = {Foreman-Mackey, Daniel and Hogg, David W. and Lang, Dustin and Goodman, Jonathan}, -doi = {10.1086/670067}, -eprint = {1202.3665}, -file = {:home/jammy/Documents/Papers/PPLs/Emcee.pdf:pdf}, -issn = {00046280}, -journal = {Publications of the Astronomical Society of the Pacific}, -number = {925}, -pages = {306--312}, -title = {{emcee : The MCMC Hammer }}, -volume = {125}, -year = {2013} -} -@article{matplotlib, - Author = {Hunter, J. D.}, - Title = {Matplotlib: A 2D graphics environment}, - Journal = {Computing in Science \& Engineering}, - Volume = {9}, - Number = {3}, - Pages = {90--95}, - abstract = {Matplotlib is a 2D graphics package used for Python for - application development, interactive scripting, and publication-quality - image generation across user interfaces and operating systems.}, - publisher = {IEEE COMPUTER SOC}, - doi = {10.1109/MCSE.2007.55}, - year = 2007 -} -@article{multinest, -abstract = {We present further development and the first public release of our multimodal nested sampling algorithm, called MultiNest. This Bayesian inference tool calculates the evidence, with an associated error estimate, and produces posterior samples from distributions that may contain multiple modes and pronounced (curving) degeneracies in high dimensions. The developments presented here lead to further substantial improvements in sampling efficiency and robustness, as compared to the original algorithm presented in Feroz and Hobson, which itself significantly outperformed existing Markov chain Monte Carlo techniques in a wide range of astrophysical inference problems. The accuracy and economy of the MultiNest algorithm are demonstrated by application to two toy problems and to a cosmological inference problem focusing on the extension of the vanilla $\Lambda$ cold dark matter model to include spatial curvature and a varying equation of state for dark energy. The MultiNest software, which is fully parallelized using MPI and includes an interface to CosmoMC, is available at http://www.mrao.cam.ac.uk/software/multinest/. It will also be released as part of the SuperBayeS package, for the analysis of supersymmetric theories of particle physics, at http://www.superbayes.org. {\textcopyright} 2009 RAS.}, -archivePrefix = {arXiv}, -arxivId = {0809.3437}, -author = {Feroz, F. and Hobson, M. P. and Bridges, M.}, -doi = {10.1111/j.1365-2966.2009.14548.x}, -eprint = {0809.3437}, -isbn = {0035-8711}, -issn = {00358711}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {Methods: Data analysis,Methods: Statistical}, -number = {4}, -pages = {1601--1614}, -pmid = {29176}, -title = {{MultiNest: An efficient and robust Bayesian inference tool for cosmology and particle physics}}, -volume = {398}, -year = {2009} -} -@ARTICLE{nautilus, - author = {{Lange}, Johannes U.}, - title = "{NAUTILUS: boosting Bayesian importance nested sampling with deep learning}", - journal = {\mnras}, - keywords = {methods: data analysis, methods: statistical, software: data analysis, Astrophysics - Instrumentation and Methods for Astrophysics, Astrophysics - Cosmology and Nongalactic Astrophysics, Astrophysics - Earth and Planetary Astrophysics, Astrophysics - Astrophysics of Galaxies, Computer Science - Machine Learning}, - year = 2023, - month = oct, - volume = {525}, - number = {2}, - pages = {3181-3194}, - doi = {10.1093/mnras/stad2441}, -archivePrefix = {arXiv}, - eprint = {2306.16923}, - primaryClass = {astro-ph.IM}, - adsurl = {https://ui.adsabs.harvard.edu/abs/2023MNRAS.525.3181L}, - adsnote = {Provided by the SAO/NASA Astrophysics Data System} -} -@ARTICLE{numpy, - author={S. {van der Walt} and S. C. {Colbert} and G. {Varoquaux}}, - doi={10.1109/MCSE.2011.37}, - journal={Computing in Science Engineering}, - title={The NumPy Array2D: A Structure for Efficient Numerical Computation}, - year={2011}, - volume={13}, - number={2}, - pages={22-30}} -@article{pymultinest, -abstract = {Context. Aims. Active galactic nuclei are known to have complex X-ray spectra that depend on both the properties of the accreting super-massive black hole (e.g. mass, accretion rate) and the distribution of obscuring material in its vicinity (i.e. the "torus"). Often however, simple and even unphysical models are adopted to represent the X-ray spectra of AGN, which do not capture the complexity and diversity of the observations. In the case of blank field surveys in particular, this should have an impact on e.g. the determination of the AGN luminosity function, the inferred accretion history of the Universe and also on our understanding of the relation between AGN and their host galaxies. Methods. We develop a Bayesian framework for model comparison and parameter estimation of X-ray spectra. We take into account uncertainties associated with both the Poisson nature of X-ray data and the determination of source redshift using photometric methods. We also demonstrate how Bayesian model comparison can be used to select among ten different physically motivated X-ray spectral models the one that provides a better representation of the observations. This methodology is applied to X-ray AGN in the 4 Ms Chandra Deep Field South. Results. For the {\~{}}350 AGN in that field, our analysis identifies four components needed to represent the diversity of the observed X-ray spectra: (1) an intrinsic power law; (2) a cold obscurer which reprocesses the radiation due to photo-electric absorption, Compton scattering and Fe-K fluorescence; (3) an unabsorbed power law associated with Thomson scattering off ionised clouds; and (4) Compton reflection, most noticeable from a stronger-than-expected Fe-K line. Simpler models, such as a photo-electrically absorbed power law with a Thomson scattering component, are ruled out with decisive evidence (B {\textgreater} 100). We also find that ignoring the Thomson scattering component results in underestimation of the inferred column density, NH, of the obscurer. Regarding the geometry of the obscurer, there is strong evidence against both a completely closed (e.g. sphere), or entirely open (e.g. blob of material along the line of sight), toroidal geometry in favour of an intermediate case. Conclusions. Despite the use of low-count spectra, our methodology is able to draw strong inferences on the geometry of the torus. Simpler models are ruled out in favour of a geometrically extended structure with significant Compton scattering. We confirm the presence of a soft component, possibly associated with Thomson scattering off ionised clouds in the opening two of the torus. The additional Compton reflection required by data over that predicted by toroidal geometry models, may be a sign of a density gradient in the torus or reflection off the accretion disk. Finally, we release a catalogue of AGN in the CDFS with estimated parameters such as the accretion luminosity in the 2-10 keV band and the column density, NH, of the obscurer. {\textcopyright} ESO, 2014.}, -archivePrefix = {arXiv}, -arxivId = {1402.0004}, -author = {Buchner, J. and Georgakakis, A. and Nandra, K. and Hsu, L. and Rangel, C. and Brightman, M. and Merloni, A. and Salvato, M. and Donley, J. and Kocevski, D.}, -doi = {10.1051/0004-6361/201322971}, -eprint = {1402.0004}, -file = {:home/jammy/Documents/Papers/Stats/ButchnerPyMultiNest.pdf:pdf}, -issn = {14320746}, -journal = {Astronomy and Astrophysics}, -keywords = {Accretion, accretion disks,galaxies: high-redshift,galaxies: nuclei,Methods: data analysis,Methods: statistical,X-rays: galaxies}, -pages = {A125}, -title = {{X-ray spectral modelling of the AGN obscuring region in the CDFS: Bayesian model selection and catalogue}}, -volume = {564}, -year = {2014} -} -@article{pyautofit, - doi = {10.21105/joss.02550}, - url = {https://doi.org/10.21105/joss.02550}, - year = {2021}, - publisher = {The Open Journal}, - volume = {6}, - number = {58}, - pages = {2550}, - author = {Nightingale, J. W. and Hayes, R. G. and Griffiths, M.}, - title = {`PyAutoFit`: A Classy Probabilistic Programming Language for Model Composition and Fitting}, - journal = {Journal of Open Source Software} -} - -@book{python, - author = {Van Rossum, Guido and Drake, Fred L.}, - title = {Python 3 Reference Manual}, - year = {2009}, - isbn = {1441412697}, - publisher = {CreateSpace}, - address = {Scotts Valley, CA} -} -@article{scipy, - author = {{Virtanen}, Pauli and {Gommers}, Ralf and {Oliphant}, - Travis E. and {Haberland}, Matt and {Reddy}, Tyler and - {Cournapeau}, David and {Burovski}, Evgeni and {Peterson}, Pearu - and {Weckesser}, Warren and {Bright}, Jonathan and {van der Walt}, - St{\'e}fan J. and {Brett}, Matthew and {Wilson}, Joshua and - {Jarrod Millman}, K. and {Mayorov}, Nikolay and {Nelson}, Andrew - R.~J. and {Jones}, Eric and {Kern}, Robert and {Larson}, Eric and - {Carey}, CJ and {Polat}, {\.I}lhan and {Feng}, Yu and {Moore}, - Eric W. and {Vand erPlas}, Jake and {Laxalde}, Denis and - {Perktold}, Josef and {Cimrman}, Robert and {Henriksen}, Ian and - {Quintero}, E.~A. and {Harris}, Charles R and {Archibald}, Anne M. - and {Ribeiro}, Ant{\^o}nio H. and {Pedregosa}, Fabian and - {van Mulbregt}, Paul and {Contributors}, SciPy 1. 0}, - title = "{SciPy 1.0: Fundamental Algorithms for Scientific - Computing in Python}", - journal = {Nature Methods}, - year = "2020", - volume={17}, - pages={261--272}, - adsurl = {https://rdcu.be/b08Wh}, - doi = {10.1038/s41592-019-0686-2}, -} -@misc{sqlite2020, - title={{SQLite}}, - url={https://www.sqlite.org/index.html}, - version={3.31.1}, - year={2020}, - author={Hipp, Richard D} -} - -@article{zeus1, - title={zeus: A Python Implementation of the Ensemble Slice Sampling method}, - author={Minas Karamanis and Florian Beutler}, - year={2021}, - note={in prep} -} -@article{zeus2, - title={Ensemble Slice Sampling}, - author={Minas Karamanis and Florian Beutler}, - year={2020}, - eprint={2002.06212}, - archivePrefix={arXiv}, - primaryClass={stat.ML} +@article{corner, + doi = {10.21105/joss.00024}, + url = {https://doi.org/10.21105/joss.00024}, + year = {2016}, + month = {jun}, + publisher = {The Open Journal}, + volume = {1}, + number = {2}, + pages = {24}, + author = {Daniel Foreman-Mackey}, + title = {corner.py: Scatterplot matrices in Python}, + journal = {The Journal of Open Source Software} +} +@article{dynesty, +abstract = {We present dynesty, a public, open-source, python package to estimate Bayesian posteriors and evidences (marginal likelihoods) using the dynamic nested sampling methods developed by Higson et al. By adaptively allocating samples based on posterior structure, dynamic nested sampling has the benefits of Markov chain Monte Carlo (MCMC) algorithms that focus exclusively on posterior estimation while retaining nested sampling's ability to estimate evidences and sample from complex, multimodal distributions. We provide an overview of nested sampling, its extension to dynamic nested sampling, the algorithmic challenges involved, and the various approaches taken to solve them in this and previous work. We then examine dynesty's performance on a variety of toy problems along with several astronomical applications. We find in particular problems dynesty can provide substantial improvements in sampling efficiency compared to popular MCMC approaches in the astronomical literature. More detailed statistical results related to nested sampling are also included in the appendix.}, +archivePrefix = {arXiv}, +arxivId = {1904.02180}, +author = {Speagle, Joshua S}, +doi = {10.1093/mnras/staa278}, +eprint = {1904.02180}, +file = {:home/jammy/Documents/Papers/PPLs/Dynesty.pdf:pdf}, +issn = {0035-8711}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {data analysis,methods,statistical}, +number = {3}, +pages = {3132--3158}, +title = {{dynesty: a dynamic nested sampling package for estimating Bayesian posteriors and evidences}}, +volume = {493}, +year = {2020} +} +@article{emcee, +abstract = {We introduce a stable, well tested Python implementation of the affine-invariant ensemble sampler for Markov chain Monte Carlo (MCMC) proposed by Goodman {\&} Weare (2010). The code is open source and has already been used in several published projects in the astrophysics literature. The algorithm behind emcee has several advantages over traditional MCMC sampling methods and it has excellent performance as measured by the autocorrelation time (or function calls per independent sample). One major advantage of the algorithm is that it requires hand-tuning of only 1 or 2 parameters compared to {\$}\backslashsim N{\^{}}2{\$} for a traditional algorithm in an N-dimensional parameter space. In this document, we describe the algorithm and the details of our implementation and API. Exploiting the parallelism of the ensemble method, emcee permits any user to take advantage of multiple CPU cores without extra effort. The code is available online at http://dan.iel.fm/emcee under the MIT License.}, +archivePrefix = {arXiv}, +arxivId = {1202.3665}, +author = {Foreman-Mackey, Daniel and Hogg, David W. and Lang, Dustin and Goodman, Jonathan}, +doi = {10.1086/670067}, +eprint = {1202.3665}, +file = {:home/jammy/Documents/Papers/PPLs/Emcee.pdf:pdf}, +issn = {00046280}, +journal = {Publications of the Astronomical Society of the Pacific}, +number = {925}, +pages = {306--312}, +title = {{emcee : The MCMC Hammer }}, +volume = {125}, +year = {2013} +} +@article{matplotlib, + Author = {Hunter, J. D.}, + Title = {Matplotlib: A 2D graphics environment}, + Journal = {Computing in Science \& Engineering}, + Volume = {9}, + Number = {3}, + Pages = {90--95}, + abstract = {Matplotlib is a 2D graphics package used for Python for + application development, interactive scripting, and publication-quality + image generation across user interfaces and operating systems.}, + publisher = {IEEE COMPUTER SOC}, + doi = {10.1109/MCSE.2007.55}, + year = 2007 +} +@article{multinest, +abstract = {We present further development and the first public release of our multimodal nested sampling algorithm, called MultiNest. This Bayesian inference tool calculates the evidence, with an associated error estimate, and produces posterior samples from distributions that may contain multiple modes and pronounced (curving) degeneracies in high dimensions. The developments presented here lead to further substantial improvements in sampling efficiency and robustness, as compared to the original algorithm presented in Feroz and Hobson, which itself significantly outperformed existing Markov chain Monte Carlo techniques in a wide range of astrophysical inference problems. The accuracy and economy of the MultiNest algorithm are demonstrated by application to two toy problems and to a cosmological inference problem focusing on the extension of the vanilla $\Lambda$ cold dark matter model to include spatial curvature and a varying equation of state for dark energy. The MultiNest software, which is fully parallelized using MPI and includes an interface to CosmoMC, is available at http://www.mrao.cam.ac.uk/software/multinest/. It will also be released as part of the SuperBayeS package, for the analysis of supersymmetric theories of particle physics, at http://www.superbayes.org. {\textcopyright} 2009 RAS.}, +archivePrefix = {arXiv}, +arxivId = {0809.3437}, +author = {Feroz, F. and Hobson, M. P. and Bridges, M.}, +doi = {10.1111/j.1365-2966.2009.14548.x}, +eprint = {0809.3437}, +isbn = {0035-8711}, +issn = {00358711}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {Methods: Data analysis,Methods: Statistical}, +number = {4}, +pages = {1601--1614}, +pmid = {29176}, +title = {{MultiNest: An efficient and robust Bayesian inference tool for cosmology and particle physics}}, +volume = {398}, +year = {2009} +} +@ARTICLE{nautilus, + author = {{Lange}, Johannes U.}, + title = "{NAUTILUS: boosting Bayesian importance nested sampling with deep learning}", + journal = {\mnras}, + keywords = {methods: data analysis, methods: statistical, software: data analysis, Astrophysics - Instrumentation and Methods for Astrophysics, Astrophysics - Cosmology and Nongalactic Astrophysics, Astrophysics - Earth and Planetary Astrophysics, Astrophysics - Astrophysics of Galaxies, Computer Science - Machine Learning}, + year = 2023, + month = oct, + volume = {525}, + number = {2}, + pages = {3181-3194}, + doi = {10.1093/mnras/stad2441}, +archivePrefix = {arXiv}, + eprint = {2306.16923}, + primaryClass = {astro-ph.IM}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2023MNRAS.525.3181L}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} +@ARTICLE{numpy, + author={S. {van der Walt} and S. C. {Colbert} and G. {Varoquaux}}, + doi={10.1109/MCSE.2011.37}, + journal={Computing in Science Engineering}, + title={The NumPy Array2D: A Structure for Efficient Numerical Computation}, + year={2011}, + volume={13}, + number={2}, + pages={22-30}} +@article{pymultinest, +abstract = {Context. Aims. Active galactic nuclei are known to have complex X-ray spectra that depend on both the properties of the accreting super-massive black hole (e.g. mass, accretion rate) and the distribution of obscuring material in its vicinity (i.e. the "torus"). Often however, simple and even unphysical models are adopted to represent the X-ray spectra of AGN, which do not capture the complexity and diversity of the observations. In the case of blank field surveys in particular, this should have an impact on e.g. the determination of the AGN luminosity function, the inferred accretion history of the Universe and also on our understanding of the relation between AGN and their host galaxies. Methods. We develop a Bayesian framework for model comparison and parameter estimation of X-ray spectra. We take into account uncertainties associated with both the Poisson nature of X-ray data and the determination of source redshift using photometric methods. We also demonstrate how Bayesian model comparison can be used to select among ten different physically motivated X-ray spectral models the one that provides a better representation of the observations. This methodology is applied to X-ray AGN in the 4 Ms Chandra Deep Field South. Results. For the {\~{}}350 AGN in that field, our analysis identifies four components needed to represent the diversity of the observed X-ray spectra: (1) an intrinsic power law; (2) a cold obscurer which reprocesses the radiation due to photo-electric absorption, Compton scattering and Fe-K fluorescence; (3) an unabsorbed power law associated with Thomson scattering off ionised clouds; and (4) Compton reflection, most noticeable from a stronger-than-expected Fe-K line. Simpler models, such as a photo-electrically absorbed power law with a Thomson scattering component, are ruled out with decisive evidence (B {\textgreater} 100). We also find that ignoring the Thomson scattering component results in underestimation of the inferred column density, NH, of the obscurer. Regarding the geometry of the obscurer, there is strong evidence against both a completely closed (e.g. sphere), or entirely open (e.g. blob of material along the line of sight), toroidal geometry in favour of an intermediate case. Conclusions. Despite the use of low-count spectra, our methodology is able to draw strong inferences on the geometry of the torus. Simpler models are ruled out in favour of a geometrically extended structure with significant Compton scattering. We confirm the presence of a soft component, possibly associated with Thomson scattering off ionised clouds in the opening two of the torus. The additional Compton reflection required by data over that predicted by toroidal geometry models, may be a sign of a density gradient in the torus or reflection off the accretion disk. Finally, we release a catalogue of AGN in the CDFS with estimated parameters such as the accretion luminosity in the 2-10 keV band and the column density, NH, of the obscurer. {\textcopyright} ESO, 2014.}, +archivePrefix = {arXiv}, +arxivId = {1402.0004}, +author = {Buchner, J. and Georgakakis, A. and Nandra, K. and Hsu, L. and Rangel, C. and Brightman, M. and Merloni, A. and Salvato, M. and Donley, J. and Kocevski, D.}, +doi = {10.1051/0004-6361/201322971}, +eprint = {1402.0004}, +file = {:home/jammy/Documents/Papers/Stats/ButchnerPyMultiNest.pdf:pdf}, +issn = {14320746}, +journal = {Astronomy and Astrophysics}, +keywords = {Accretion, accretion disks,galaxies: high-redshift,galaxies: nuclei,Methods: data analysis,Methods: statistical,X-rays: galaxies}, +pages = {A125}, +title = {{X-ray spectral modelling of the AGN obscuring region in the CDFS: Bayesian model selection and catalogue}}, +volume = {564}, +year = {2014} +} +@article{pyautofit, + doi = {10.21105/joss.02550}, + url = {https://doi.org/10.21105/joss.02550}, + year = {2021}, + publisher = {The Open Journal}, + volume = {6}, + number = {58}, + pages = {2550}, + author = {Nightingale, J. W. and Hayes, R. G. and Griffiths, M.}, + title = {`PyAutoFit`: A Classy Probabilistic Programming Language for Model Composition and Fitting}, + journal = {Journal of Open Source Software} +} + +@book{python, + author = {Van Rossum, Guido and Drake, Fred L.}, + title = {Python 3 Reference Manual}, + year = {2009}, + isbn = {1441412697}, + publisher = {CreateSpace}, + address = {Scotts Valley, CA} +} +@article{scipy, + author = {{Virtanen}, Pauli and {Gommers}, Ralf and {Oliphant}, + Travis E. and {Haberland}, Matt and {Reddy}, Tyler and + {Cournapeau}, David and {Burovski}, Evgeni and {Peterson}, Pearu + and {Weckesser}, Warren and {Bright}, Jonathan and {van der Walt}, + St{\'e}fan J. and {Brett}, Matthew and {Wilson}, Joshua and + {Jarrod Millman}, K. and {Mayorov}, Nikolay and {Nelson}, Andrew + R.~J. and {Jones}, Eric and {Kern}, Robert and {Larson}, Eric and + {Carey}, CJ and {Polat}, {\.I}lhan and {Feng}, Yu and {Moore}, + Eric W. and {Vand erPlas}, Jake and {Laxalde}, Denis and + {Perktold}, Josef and {Cimrman}, Robert and {Henriksen}, Ian and + {Quintero}, E.~A. and {Harris}, Charles R and {Archibald}, Anne M. + and {Ribeiro}, Ant{\^o}nio H. and {Pedregosa}, Fabian and + {van Mulbregt}, Paul and {Contributors}, SciPy 1. 0}, + title = "{SciPy 1.0: Fundamental Algorithms for Scientific + Computing in Python}", + journal = {Nature Methods}, + year = "2020", + volume={17}, + pages={261--272}, + adsurl = {https://rdcu.be/b08Wh}, + doi = {10.1038/s41592-019-0686-2}, +} +@misc{sqlite2020, + title={{SQLite}}, + url={https://www.sqlite.org/index.html}, + version={3.31.1}, + year={2020}, + author={Hipp, Richard D} +} + +@article{zeus1, + title={zeus: A Python Implementation of the Ensemble Slice Sampling method}, + author={Minas Karamanis and Florian Beutler}, + year={2021}, + note={in prep} +} +@article{zeus2, + title={Ensemble Slice Sampling}, + author={Minas Karamanis and Florian Beutler}, + year={2020}, + eprint={2002.06212}, + archivePrefix={arXiv}, + primaryClass={stat.ML} } \ No newline at end of file diff --git a/files/citations.md b/files/citations.md index 2e554bb7f..629873b0e 100644 --- a/files/citations.md +++ b/files/citations.md @@ -1,23 +1,23 @@ -I**nesrt in the main body of the paper:** - -We use the probabilistic programming language `PyAutoFit` https://github.com/PyAutoLabs/PyAutoFit) [@pyautofit] to... - -**At the end of the paper (delete as appropriate, see https://pyautofit.readthedocs.io/en/latest/general/citations.html):** - -# Software Citations - -This work uses the following software packages: - -- `corner.py` https://github.com/dfm/corner.py [@corner] -- `dynesty` https://github.com/joshspeagle/dynesty [@dynesty] -- `emcee` https://github.com/dfm/emcee [@emcee] -- `matplotlib` https://github.com/matplotlib/matplotlib [@matplotlib] -- `NumPy` https://github.com/numpy/numpy [@numpy] -- `PyAutoFit` https://github.com/PyAutoLabs/PyAutoFit [@pyautofit] -- `PyMultiNest` https://github.com/JohannesBuchner/PyMultiNest [@multinest] [@pymultinest] - -- `Python` https://www.python.org/ [@python] -- `Scipy` https://github.com/scipy/scipy [@scipy] -- `SQLite` https://www.sqlite.org/index.html [@sqlite] - +I**nesrt in the main body of the paper:** + +We use the probabilistic programming language `PyAutoFit` https://github.com/PyAutoLabs/PyAutoFit) [@pyautofit] to... + +**At the end of the paper (delete as appropriate, see https://pyautofit.readthedocs.io/en/latest/general/citations.html):** + +# Software Citations + +This work uses the following software packages: + +- `corner.py` https://github.com/dfm/corner.py [@corner] +- `dynesty` https://github.com/joshspeagle/dynesty [@dynesty] +- `emcee` https://github.com/dfm/emcee [@emcee] +- `matplotlib` https://github.com/matplotlib/matplotlib [@matplotlib] +- `NumPy` https://github.com/numpy/numpy [@numpy] +- `PyAutoFit` https://github.com/PyAutoLabs/PyAutoFit [@pyautofit] +- `PyMultiNest` https://github.com/JohannesBuchner/PyMultiNest [@multinest] [@pymultinest] + +- `Python` https://www.python.org/ [@python] +- `Scipy` https://github.com/scipy/scipy [@scipy] +- `SQLite` https://www.sqlite.org/index.html [@sqlite] + - `Zeus` https://github.com/minaskar/zeus [@zeus1] [@zeus2] \ No newline at end of file diff --git a/paper/README.md b/paper/README.md index b39481882..4c5855b4f 100644 --- a/paper/README.md +++ b/paper/README.md @@ -1,5 +1,5 @@ -PyAutoFit JOSS Paper -==================== - -Paper accompanying [PyAutoFit](https://github.com/PyAutoLabs/PyAutoFit) for submission to the Journal of Open Source +PyAutoFit JOSS Paper +==================== + +Paper accompanying [PyAutoFit](https://github.com/PyAutoLabs/PyAutoFit) for submission to the Journal of Open Source Software (JOSS). \ No newline at end of file diff --git a/paper/paper.bib b/paper/paper.bib index b17b343e4..bb65aec5c 100644 --- a/paper/paper.bib +++ b/paper/paper.bib @@ -1,286 +1,286 @@ -@Article{ numpy, - title = {Array2D programming with {NumPy}}, - author = {Charles R. Harris and K. Jarrod Millman and St{'{e}}fan J. - van der Walt and Ralf Gommers and Pauli Virtanen and David - Cournapeau and Eric Wieser and Julian Taylor and Sebastian - Berg and Nathaniel J. Smith and Robert Kern and Matti Picus - and Stephan Hoyer and Marten H. van Kerkwijk and Matthew - Brett and Allan Haldane and Jaime Fern{'{a}}ndez del - R{'{\i}}o and Mark Wiebe and Pearu Peterson and Pierre - G{'{e}}rard-Marchant and Kevin Sheppard and Tyler Reddy and - Warren Weckesser and Hameer Abbasi and Christoph Gohlke and - Travis E. Oliphant}, - year = {2020}, - month = sep, - journal = {Nature}, - volume = {585}, - number = {7825}, - pages = {357--362}, - doi = {10.1038/s41586-020-2649-2}, - publisher = {Springer Science and Business Media {LLC}}, - url = {https://doi.org/10.1038/s41586-020-2649-2} -} - -@article{corner, - doi = {10.21105/joss.00024}, - url = {https://doi.org/10.21105/joss.00024}, - year = {2016}, - month = {jun}, - publisher = {The Open Journal}, - volume = {1}, - number = {2}, - pages = {24}, - author = {Daniel Foreman-Mackey}, - title = {corner.py: Scatterplot matrices in Python}, - journal = {The Journal of Open Source Software} -} - -@article{dynesty, - author = {Speagle, Joshua S}, - title = "{dynesty: a dynamic nested sampling package for estimating Bayesian posteriors and evidences}", - journal = {Monthly Notices of the Royal Astronomical Society}, - volume = {493}, - number = {3}, - pages = {3132-3158}, - year = {2020}, - month = {02}, - issn = {0035-8711}, - doi = {10.1093/mnras/staa278}, - url = {https://doi.org/10.1093/mnras/staa278}, - eprint = {https://academic.oup.com/mnras/article-pdf/493/3/3132/32890730/staa278.pdf}, -} - -@ARTICLE{emcee, - author = {{Foreman-Mackey}, Daniel and {Hogg}, David W. and {Lang}, Dustin and {Goodman}, Jonathan}, - title = "{emcee: The MCMC Hammer}", - journal = {Publications of the Astronomical Society of the Pacific}, - keywords = {Astrophysics - Instrumentation and Methods for Astrophysics, Physics - Computational Physics, Statistics - Computation}, - year = 2013, - month = mar, - volume = {125}, - number = {925}, - pages = {306}, - doi = {10.1086/670067}, -archivePrefix = {arXiv}, - eprint = {1202.3665}, - primaryClass = {astro-ph.IM}, - adsurl = {https://ui.adsabs.harvard.edu/abs/2013PASP..125..306F}, - adsnote = {Provided by the SAO/NASA Astrophysics Data System} -} - -@Article{matplotlib, - Author = {Hunter, J. D.}, - Title = {Matplotlib: A 2D graphics environment}, - Journal = {Computing in Science \& Engineering}, - Volume = {9}, - Number = {3}, - Pages = {90--95}, - abstract = {Matplotlib is a 2D graphics package used for Python for - application development, interactive scripting, and publication-quality - image generation across user interfaces and operating systems.}, - publisher = {IEEE COMPUTER SOC}, - doi = {10.1109/MCSE.2007.55}, - year = 2007 -} -@ARTICLE{scipy, - author = {{Virtanen}, Pauli and {Gommers}, Ralf and {Oliphant}, - Travis E. and {Haberland}, Matt and {Reddy}, Tyler and - {Cournapeau}, David and {Burovski}, Evgeni and {Peterson}, Pearu - and {Weckesser}, Warren and {Bright}, Jonathan and {van der Walt}, - St{\'e}fan J. and {Brett}, Matthew and {Wilson}, Joshua and - {Jarrod Millman}, K. and {Mayorov}, Nikolay and {Nelson}, Andrew - R.~J. and {Jones}, Eric and {Kern}, Robert and {Larson}, Eric and - {Carey}, CJ and {Polat}, {\.I}lhan and {Feng}, Yu and {Moore}, - Eric W. and {Vand erPlas}, Jake and {Laxalde}, Denis and - {Perktold}, Josef and {Cimrman}, Robert and {Henriksen}, Ian and - {Quintero}, E.~A. and {Harris}, Charles R and {Archibald}, Anne M. - and {Ribeiro}, Ant{\^o}nio H. and {Pedregosa}, Fabian and - {van Mulbregt}, Paul and {Contributors}, SciPy 1. 0}, - title = "{SciPy 1.0: Fundamental Algorithms for Scientific - Computing in Python}", - journal = {Nature Methods}, - year = "2020", - volume={17}, - pages={261--272}, - adsurl = {https://rdcu.be/b08Wh}, - doi = {10.1038/s41592-019-0686-2}, -} -@article{Haussler2013, -archivePrefix = {arXiv}, -arxivId = {1212.3332}, -author = {H{\"{a}}u{\ss}ler, Boris and Bamford, Steven P. and Vika, Marina and Rojas, Alex L. and Barden, Marco and Kelvin, Lee S. and Alpaslan, Mehmet and Robotham, Aaron S.G. and Driver, Simon P. and Baldry, I. K. and Brough, Sarah and Hopkins, Andrew M. and Liske, Jochen and Nichol, Robert C. and Popescu, Cristina C. and Tuffs, Richard J.}, -doi = {10.1093/mnras/sts633}, -eprint = {1212.3332}, -issn = {00358711}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {galaxies: fundamental parameters,galaxies: structure,Techniques: image processing,methods: data analysis}, -month = {mar}, -number = {1}, -pages = {330--369}, -title = {{Megamorph - multiwavelength measurement of galaxy structure: Complete S{\'{e}}rsic profile information from modern surveys}}, -volume = {430}, -year = {2013} -} -@article{Nightingale2015, -archivePrefix = {arXiv}, -arxivId = {1412.7436}, -author = {Nightingale, J. W. and Dye, S.}, -doi = {10.1093/mnras/stv1455}, -eprint = {1412.7436}, -issn = {13652966}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {galaxies: evolution,galaxies: structure,Methods: observational}, -month = {sep}, -number = {3}, -pages = {2940--2959}, -title = {{Adaptive semi-linear inversion of strong gravitational lens imaging}}, -volume = {452}, -year = {2015} -} -@article{Nightingale2018, -archivePrefix = {arXiv}, -arxivId = {1708.07377}, -author = {Nightingale, J. W. and Dye, S. and Massey, Richard J.}, -doi = {10.1093/mnras/sty1264}, -eprint = {1708.07377}, -file = {:home/jammy/Documents/Papers{\_}Me/AutoLensChangesMarked.pdf:pdf}, -issn = {13652966}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {Galaxy: structure,Gravitational lensing,Methods: data analysis}, -number = {4}, -pages = {4738--4784}, -title = {{AutoLens: Automated modeling of a strong lens's light, mass, and source}}, -url = {https://academic.oup.com/mnras/article/478/4/4738/5001434}, -volume = {478}, -year = {2018} -} -@article{Nightingale2019, -archivePrefix = {arXiv}, -arxivId = {1901.07801}, -author = {Nightingale, J. W. and Massey, Richard J. and Harvey, David R. and Cooper, Andrew P. and Etherington, Amy and Tam, Sut Ieng and Hayes, Richard G.}, -doi = {10.1093/mnras/stz2220}, -eprint = {1901.07801}, -file = {:home/jammy/Documents/Papers{\_}Me/Gal{\_}Structure{\_}Final/GalaxyStructure.pdf:pdf}, -issn = {13652966}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {galaxies: Evolution,galaxies: Formation,Gravitational lensing: Strong}, -number = {2}, -pages = {2049--2068}, -title = {{Galaxy structure with strong gravitational lensing: Decomposing the internal mass distribution of massive elliptical galaxies}}, -url = {http://arxiv.org/abs/1901.07801}, -volume = {489}, -year = {2019} -} -@article{Salvatier2016, -archivePrefix = {arXiv}, -arxivId = {1507.08050}, -author = {Salvatier, John and Wiecki, Thomas V. and Fonnesbeck, Christopher}, -doi = {10.7717/peerj-cs.55}, -eprint = {1507.08050}, -file = {:home/jammy/Documents/Papers/PPLs/PyMC3.pdf:pdf}, -issn = {23765992}, -journal = {PeerJ Computer Science}, -keywords = {Bayesian statistic,Markov chain Monte Carlo,Probabilistic Programming,Python,Statistical modeling}, -number = {4}, -pages = {1--24}, -title = {{Probabilistic programming in Python using PyMC3}}, -volume = {2016}, -year = {2016} -} -@article{Carpenter2017, -author = {Carpenter, Bob and Gelman, Andrew and Hoffman, Matthew D. and Lee, Daniel and Goodrich, Ben and Betancourt, Michael and Brubaker, Marcus A. and Guo, Jiqiang and Li, Peter and Riddell, Allen}, -doi = {10.18637/jss.v076.i01}, -file = {:home/jammy/Documents/Papers/PPLs/STAN.pdf:pdf}, -issn = {15487660}, -journal = {Journal of Statistical Software}, -keywords = {Algorithmic differentiation,Bayesian inference,Probabilistic program,Stan}, -number = {1}, -title = {{Stan: A probabilistic programming language}}, -volume = {76}, -year = {2017} -} -@article{Bingham2019, -archivePrefix = {arXiv}, -arxivId = {1810.09538}, -author = {Bingham, Eli and Chen, Jonathan P. and Jankowiak, Martin and Obermeyer, F. and Pradhan, Neeraj and Karaletsos, Theofanis and Singh, Rohit and Szerlip, Paul and Horsfall, Paul and Goodman, Noah D.}, -eprint = {1810.09538}, -file = {:home/jammy/Documents/Papers/PPLs/Pyro.pdf:pdf}, -issn = {15337928}, -journal = {Journal of Machine Learning Research}, -keywords = {Approximate Bayesian inference,Deep learning,Generative models,Graphical models,Probabilistic programming}, -number = {Xxxx}, -pages = {0--5}, -title = {{Pyro: Deep universal probabilistic programming}}, -volume = {20}, -year = {2019} -} -@ARTICLE{tensorflow, - author = {{Dillon}, Joshua V. and {Langmore}, Ian and {Tran}, Dustin and - {Brevdo}, Eugene and {Vasudevan}, Srinivas and {Moore}, Dave and - {Patton}, Brian and {Alemi}, Alex and {Hoffman}, Matt and - {Saurous}, Rif A.}, - title = "{TensorFlow Distributions}", - journal = {arXiv e-prints}, - keywords = {Computer Science - Machine Learning, Computer Science - Artificial Intelligence, Computer Science - Programming Languages, Statistics - Machine Learning}, - year = 2017, - month = nov, - eid = {arXiv:1711.10604}, - pages = {arXiv:1711.10604}, -archivePrefix = {arXiv}, - eprint = {1711.10604}, - primaryClass = {cs.LG}, - adsurl = {https://ui.adsabs.harvard.edu/abs/2017arXiv171110604D}, - adsnote = {Provided by the SAO/NASA Astrophysics Data System} -} -@article{pymultinest, -archivePrefix = {arXiv}, -arxivId = {1402.0004}, -author = {Buchner, J. and Georgakakis, A. and Nandra, K. and Hsu, L. and Rangel, C. and Brightman, M. and Merloni, A. and Salvato, M. and Donley, J. and Kocevski, D.}, -doi = {10.1051/0004-6361/201322971}, -eprint = {1402.0004}, -file = {:home/jammy/Documents/Papers/Stats/ButchnerPyMultiNest.pdf:pdf}, -issn = {14320746}, -journal = {Astronomy and Astrophysics}, -keywords = {Accretion, accretion disks,galaxies: high-redshift,galaxies: nuclei,Methods: data analysis,Methods: statistical,X-rays: galaxies}, -pages = {A125}, -title = {{X-ray spectral modelling of the AGN obscuring region in the CDFS: Bayesian model selection and catalogue}}, -volume = {564}, -year = {2014} -} -@article{multinest, -archivePrefix = {arXiv}, -arxivId = {0809.3437}, -author = {Feroz, F. and Hobson, M. P. and Bridges, M.}, -doi = {10.1111/j.1365-2966.2009.14548.x}, -eprint = {0809.3437}, -isbn = {0035-8711}, -issn = {00358711}, -journal = {Monthly Notices of the Royal Astronomical Society}, -keywords = {Methods: Data analysis,Methods: Statistical}, -number = {4}, -pages = {1601--1614}, -pmid = {29176}, -title = {{MultiNest: An efficient and robust Bayesian inference tool for cosmology and particle physics}}, -volume = {398}, -year = {2009} -} -@book{python, - author = {Van Rossum, Guido and Drake, Fred L.}, - title = {Python 3 Reference Manual}, - year = {2009}, - isbn = {1441412697}, - publisher = {CreateSpace}, - address = {Scotts Valley, CA} -} -@article{uravu, - doi = {10.21105/joss.02214}, - url = {https://doi.org/10.21105/joss.02214}, - year = {2020}, - publisher = {The Open Journal}, - volume = {5}, - number = {50}, - pages = {2214}, - author = {Andrew R. McCluskey and Tim Snow}, - title = {uravu: Making Bayesian modelling easy(er)}, - journal = {Journal of Open Source Software} -} +@Article{ numpy, + title = {Array2D programming with {NumPy}}, + author = {Charles R. Harris and K. Jarrod Millman and St{'{e}}fan J. + van der Walt and Ralf Gommers and Pauli Virtanen and David + Cournapeau and Eric Wieser and Julian Taylor and Sebastian + Berg and Nathaniel J. Smith and Robert Kern and Matti Picus + and Stephan Hoyer and Marten H. van Kerkwijk and Matthew + Brett and Allan Haldane and Jaime Fern{'{a}}ndez del + R{'{\i}}o and Mark Wiebe and Pearu Peterson and Pierre + G{'{e}}rard-Marchant and Kevin Sheppard and Tyler Reddy and + Warren Weckesser and Hameer Abbasi and Christoph Gohlke and + Travis E. Oliphant}, + year = {2020}, + month = sep, + journal = {Nature}, + volume = {585}, + number = {7825}, + pages = {357--362}, + doi = {10.1038/s41586-020-2649-2}, + publisher = {Springer Science and Business Media {LLC}}, + url = {https://doi.org/10.1038/s41586-020-2649-2} +} + +@article{corner, + doi = {10.21105/joss.00024}, + url = {https://doi.org/10.21105/joss.00024}, + year = {2016}, + month = {jun}, + publisher = {The Open Journal}, + volume = {1}, + number = {2}, + pages = {24}, + author = {Daniel Foreman-Mackey}, + title = {corner.py: Scatterplot matrices in Python}, + journal = {The Journal of Open Source Software} +} + +@article{dynesty, + author = {Speagle, Joshua S}, + title = "{dynesty: a dynamic nested sampling package for estimating Bayesian posteriors and evidences}", + journal = {Monthly Notices of the Royal Astronomical Society}, + volume = {493}, + number = {3}, + pages = {3132-3158}, + year = {2020}, + month = {02}, + issn = {0035-8711}, + doi = {10.1093/mnras/staa278}, + url = {https://doi.org/10.1093/mnras/staa278}, + eprint = {https://academic.oup.com/mnras/article-pdf/493/3/3132/32890730/staa278.pdf}, +} + +@ARTICLE{emcee, + author = {{Foreman-Mackey}, Daniel and {Hogg}, David W. and {Lang}, Dustin and {Goodman}, Jonathan}, + title = "{emcee: The MCMC Hammer}", + journal = {Publications of the Astronomical Society of the Pacific}, + keywords = {Astrophysics - Instrumentation and Methods for Astrophysics, Physics - Computational Physics, Statistics - Computation}, + year = 2013, + month = mar, + volume = {125}, + number = {925}, + pages = {306}, + doi = {10.1086/670067}, +archivePrefix = {arXiv}, + eprint = {1202.3665}, + primaryClass = {astro-ph.IM}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2013PASP..125..306F}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} + +@Article{matplotlib, + Author = {Hunter, J. D.}, + Title = {Matplotlib: A 2D graphics environment}, + Journal = {Computing in Science \& Engineering}, + Volume = {9}, + Number = {3}, + Pages = {90--95}, + abstract = {Matplotlib is a 2D graphics package used for Python for + application development, interactive scripting, and publication-quality + image generation across user interfaces and operating systems.}, + publisher = {IEEE COMPUTER SOC}, + doi = {10.1109/MCSE.2007.55}, + year = 2007 +} +@ARTICLE{scipy, + author = {{Virtanen}, Pauli and {Gommers}, Ralf and {Oliphant}, + Travis E. and {Haberland}, Matt and {Reddy}, Tyler and + {Cournapeau}, David and {Burovski}, Evgeni and {Peterson}, Pearu + and {Weckesser}, Warren and {Bright}, Jonathan and {van der Walt}, + St{\'e}fan J. and {Brett}, Matthew and {Wilson}, Joshua and + {Jarrod Millman}, K. and {Mayorov}, Nikolay and {Nelson}, Andrew + R.~J. and {Jones}, Eric and {Kern}, Robert and {Larson}, Eric and + {Carey}, CJ and {Polat}, {\.I}lhan and {Feng}, Yu and {Moore}, + Eric W. and {Vand erPlas}, Jake and {Laxalde}, Denis and + {Perktold}, Josef and {Cimrman}, Robert and {Henriksen}, Ian and + {Quintero}, E.~A. and {Harris}, Charles R and {Archibald}, Anne M. + and {Ribeiro}, Ant{\^o}nio H. and {Pedregosa}, Fabian and + {van Mulbregt}, Paul and {Contributors}, SciPy 1. 0}, + title = "{SciPy 1.0: Fundamental Algorithms for Scientific + Computing in Python}", + journal = {Nature Methods}, + year = "2020", + volume={17}, + pages={261--272}, + adsurl = {https://rdcu.be/b08Wh}, + doi = {10.1038/s41592-019-0686-2}, +} +@article{Haussler2013, +archivePrefix = {arXiv}, +arxivId = {1212.3332}, +author = {H{\"{a}}u{\ss}ler, Boris and Bamford, Steven P. and Vika, Marina and Rojas, Alex L. and Barden, Marco and Kelvin, Lee S. and Alpaslan, Mehmet and Robotham, Aaron S.G. and Driver, Simon P. and Baldry, I. K. and Brough, Sarah and Hopkins, Andrew M. and Liske, Jochen and Nichol, Robert C. and Popescu, Cristina C. and Tuffs, Richard J.}, +doi = {10.1093/mnras/sts633}, +eprint = {1212.3332}, +issn = {00358711}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {galaxies: fundamental parameters,galaxies: structure,Techniques: image processing,methods: data analysis}, +month = {mar}, +number = {1}, +pages = {330--369}, +title = {{Megamorph - multiwavelength measurement of galaxy structure: Complete S{\'{e}}rsic profile information from modern surveys}}, +volume = {430}, +year = {2013} +} +@article{Nightingale2015, +archivePrefix = {arXiv}, +arxivId = {1412.7436}, +author = {Nightingale, J. W. and Dye, S.}, +doi = {10.1093/mnras/stv1455}, +eprint = {1412.7436}, +issn = {13652966}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {galaxies: evolution,galaxies: structure,Methods: observational}, +month = {sep}, +number = {3}, +pages = {2940--2959}, +title = {{Adaptive semi-linear inversion of strong gravitational lens imaging}}, +volume = {452}, +year = {2015} +} +@article{Nightingale2018, +archivePrefix = {arXiv}, +arxivId = {1708.07377}, +author = {Nightingale, J. W. and Dye, S. and Massey, Richard J.}, +doi = {10.1093/mnras/sty1264}, +eprint = {1708.07377}, +file = {:home/jammy/Documents/Papers{\_}Me/AutoLensChangesMarked.pdf:pdf}, +issn = {13652966}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {Galaxy: structure,Gravitational lensing,Methods: data analysis}, +number = {4}, +pages = {4738--4784}, +title = {{AutoLens: Automated modeling of a strong lens's light, mass, and source}}, +url = {https://academic.oup.com/mnras/article/478/4/4738/5001434}, +volume = {478}, +year = {2018} +} +@article{Nightingale2019, +archivePrefix = {arXiv}, +arxivId = {1901.07801}, +author = {Nightingale, J. W. and Massey, Richard J. and Harvey, David R. and Cooper, Andrew P. and Etherington, Amy and Tam, Sut Ieng and Hayes, Richard G.}, +doi = {10.1093/mnras/stz2220}, +eprint = {1901.07801}, +file = {:home/jammy/Documents/Papers{\_}Me/Gal{\_}Structure{\_}Final/GalaxyStructure.pdf:pdf}, +issn = {13652966}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {galaxies: Evolution,galaxies: Formation,Gravitational lensing: Strong}, +number = {2}, +pages = {2049--2068}, +title = {{Galaxy structure with strong gravitational lensing: Decomposing the internal mass distribution of massive elliptical galaxies}}, +url = {http://arxiv.org/abs/1901.07801}, +volume = {489}, +year = {2019} +} +@article{Salvatier2016, +archivePrefix = {arXiv}, +arxivId = {1507.08050}, +author = {Salvatier, John and Wiecki, Thomas V. and Fonnesbeck, Christopher}, +doi = {10.7717/peerj-cs.55}, +eprint = {1507.08050}, +file = {:home/jammy/Documents/Papers/PPLs/PyMC3.pdf:pdf}, +issn = {23765992}, +journal = {PeerJ Computer Science}, +keywords = {Bayesian statistic,Markov chain Monte Carlo,Probabilistic Programming,Python,Statistical modeling}, +number = {4}, +pages = {1--24}, +title = {{Probabilistic programming in Python using PyMC3}}, +volume = {2016}, +year = {2016} +} +@article{Carpenter2017, +author = {Carpenter, Bob and Gelman, Andrew and Hoffman, Matthew D. and Lee, Daniel and Goodrich, Ben and Betancourt, Michael and Brubaker, Marcus A. and Guo, Jiqiang and Li, Peter and Riddell, Allen}, +doi = {10.18637/jss.v076.i01}, +file = {:home/jammy/Documents/Papers/PPLs/STAN.pdf:pdf}, +issn = {15487660}, +journal = {Journal of Statistical Software}, +keywords = {Algorithmic differentiation,Bayesian inference,Probabilistic program,Stan}, +number = {1}, +title = {{Stan: A probabilistic programming language}}, +volume = {76}, +year = {2017} +} +@article{Bingham2019, +archivePrefix = {arXiv}, +arxivId = {1810.09538}, +author = {Bingham, Eli and Chen, Jonathan P. and Jankowiak, Martin and Obermeyer, F. and Pradhan, Neeraj and Karaletsos, Theofanis and Singh, Rohit and Szerlip, Paul and Horsfall, Paul and Goodman, Noah D.}, +eprint = {1810.09538}, +file = {:home/jammy/Documents/Papers/PPLs/Pyro.pdf:pdf}, +issn = {15337928}, +journal = {Journal of Machine Learning Research}, +keywords = {Approximate Bayesian inference,Deep learning,Generative models,Graphical models,Probabilistic programming}, +number = {Xxxx}, +pages = {0--5}, +title = {{Pyro: Deep universal probabilistic programming}}, +volume = {20}, +year = {2019} +} +@ARTICLE{tensorflow, + author = {{Dillon}, Joshua V. and {Langmore}, Ian and {Tran}, Dustin and + {Brevdo}, Eugene and {Vasudevan}, Srinivas and {Moore}, Dave and + {Patton}, Brian and {Alemi}, Alex and {Hoffman}, Matt and + {Saurous}, Rif A.}, + title = "{TensorFlow Distributions}", + journal = {arXiv e-prints}, + keywords = {Computer Science - Machine Learning, Computer Science - Artificial Intelligence, Computer Science - Programming Languages, Statistics - Machine Learning}, + year = 2017, + month = nov, + eid = {arXiv:1711.10604}, + pages = {arXiv:1711.10604}, +archivePrefix = {arXiv}, + eprint = {1711.10604}, + primaryClass = {cs.LG}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2017arXiv171110604D}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} +@article{pymultinest, +archivePrefix = {arXiv}, +arxivId = {1402.0004}, +author = {Buchner, J. and Georgakakis, A. and Nandra, K. and Hsu, L. and Rangel, C. and Brightman, M. and Merloni, A. and Salvato, M. and Donley, J. and Kocevski, D.}, +doi = {10.1051/0004-6361/201322971}, +eprint = {1402.0004}, +file = {:home/jammy/Documents/Papers/Stats/ButchnerPyMultiNest.pdf:pdf}, +issn = {14320746}, +journal = {Astronomy and Astrophysics}, +keywords = {Accretion, accretion disks,galaxies: high-redshift,galaxies: nuclei,Methods: data analysis,Methods: statistical,X-rays: galaxies}, +pages = {A125}, +title = {{X-ray spectral modelling of the AGN obscuring region in the CDFS: Bayesian model selection and catalogue}}, +volume = {564}, +year = {2014} +} +@article{multinest, +archivePrefix = {arXiv}, +arxivId = {0809.3437}, +author = {Feroz, F. and Hobson, M. P. and Bridges, M.}, +doi = {10.1111/j.1365-2966.2009.14548.x}, +eprint = {0809.3437}, +isbn = {0035-8711}, +issn = {00358711}, +journal = {Monthly Notices of the Royal Astronomical Society}, +keywords = {Methods: Data analysis,Methods: Statistical}, +number = {4}, +pages = {1601--1614}, +pmid = {29176}, +title = {{MultiNest: An efficient and robust Bayesian inference tool for cosmology and particle physics}}, +volume = {398}, +year = {2009} +} +@book{python, + author = {Van Rossum, Guido and Drake, Fred L.}, + title = {Python 3 Reference Manual}, + year = {2009}, + isbn = {1441412697}, + publisher = {CreateSpace}, + address = {Scotts Valley, CA} +} +@article{uravu, + doi = {10.21105/joss.02214}, + url = {https://doi.org/10.21105/joss.02214}, + year = {2020}, + publisher = {The Open Journal}, + volume = {5}, + number = {50}, + pages = {2214}, + author = {Andrew R. McCluskey and Tim Snow}, + title = {uravu: Making Bayesian modelling easy(er)}, + journal = {Journal of Open Source Software} +} diff --git a/paper/paper.json b/paper/paper.json index c4bc2faa4..e9ab88360 100644 --- a/paper/paper.json +++ b/paper/paper.json @@ -1,23 +1,23 @@ -{ - "@context": "https://raw.githubusercontent.com/codemeta/codemeta/main/codemeta.jsonld", - "@type": "Code", - "author": [ - { - "@id": "http://orcid.org/0000-0002-8987-7401", - "@type": "Person", - "email": "james.w.nightingale@durham.ac.uk", - "name": "James W. Nightingale", - "affiliation": "Institute for Computational Cosmology, Durham University" - } - ], - "identifier": "", - "codeRepository": "https://github.com/rhayes777/PyAutoFit", - "datePublished": "2020-07-24", - "dateModified": "2020-07-24", - "dateCreated": "2020-07-24", - "description": "`PyAutoLens`: Open-Source Strong Gravitational Lensing", - "keywords": "astronomy, gravitational lensing, galaxies, cosmology, Python", - "license": "MIT", - "title": "PyAutoLens", - "version": "v1.7.8" +{ + "@context": "https://raw.githubusercontent.com/codemeta/codemeta/main/codemeta.jsonld", + "@type": "Code", + "author": [ + { + "@id": "http://orcid.org/0000-0002-8987-7401", + "@type": "Person", + "email": "james.w.nightingale@durham.ac.uk", + "name": "James W. Nightingale", + "affiliation": "Institute for Computational Cosmology, Durham University" + } + ], + "identifier": "", + "codeRepository": "https://github.com/rhayes777/PyAutoFit", + "datePublished": "2020-07-24", + "dateModified": "2020-07-24", + "dateCreated": "2020-07-24", + "description": "`PyAutoLens`: Open-Source Strong Gravitational Lensing", + "keywords": "astronomy, gravitational lensing, galaxies, cosmology, Python", + "license": "MIT", + "title": "PyAutoLens", + "version": "v1.7.8" } \ No newline at end of file diff --git a/paper/paper.md b/paper/paper.md index 08d199fc2..5b40d236e 100644 --- a/paper/paper.md +++ b/paper/paper.md @@ -1,166 +1,166 @@ ---- -title: "`PyAutoFit`: A Classy Probabilistic Programming Language for Model Composition and Fitting" -tags: - - Python - - statistics - - Bayesian inference - - probabilistic programming - - model fitting -authors: - - name: James. W. Nightingale - orcid: 0000-0002-8987-7401 - affiliation: 1 - - name: Richard G. Hayes - affiliation: 1 - - name: Matthew Griffiths - orcid: 0000-0002-2553-2447 - affiliation: 2 -affiliations: - - name: Institute for Computational Cosmology, Stockton Rd, Durham, United Kingdom, DH1 3LE - index: 1 - - name: ConcR Ltd, London, UK - index: 2 -date: 17 July 2020 -codeRepository: https://github.com/PyAutoLabs/PyAutoFit -license: MIT -bibliography: paper.bib ---- - -# Summary - -A major trend in academia and data science is the rapid adoption of Bayesian statistics for data analysis and modeling, -leading to the development of probabilistic programming languages (PPL). A PPL provides a framework that allows users -to easily specify a probabilistic model and perform inference automatically. `PyAutoFit` is a Python-based PPL which -interfaces with all aspects of the modeling (e.g., the model, data, fitting procedure, visualization, results) and -therefore provides complete management of every aspect of modeling. This includes composing high-dimensionality models -from individual model components, customizing the fitting procedure and performing data augmentation before a model-fit. -Advanced features include database tools for analysing large suites of modeling results and exploiting domain-specific -knowledge of a problem via non-linear search chaining. Accompanying `PyAutoFit` is the [autofit workspace](https://github.com/PyAutoLabs/autofit_workspace), -which includes example scripts, together with the standalone [HowToFit](https://github.com/PyAutoLabs/HowToFit) -lecture series which introduces non-experts to model-fitting and provides a guide on how to begin a project -using `PyAutoFit`. Readers can try `PyAutoFit` right now by -going to [the introduction Jupyter notebook on Colab](https://colab.research.google.com/github/PyAutoLabs/autofit_workspace/blob/2026.7.25.2/notebooks/overview/overview_1_the_basics.ipynb) -or checkout our [readthedocs](https://pyautofit.readthedocs.io/en/latest/) for a complete overview -of **PyAutoFit**'s features. - -# Background of Probabilistic Programming - -Probabilistic programming languages (PPLs) have enabled contemporary statistical inference techniques to be applied -to a diverse range of problems across academia and industry. Packages such as PyMC3 [@Salvatier2016], -Pyro [@Bingham2019] and STAN [@Carpenter2017] offer general-purpose frameworks where users can specify a generative -model and fit it to data using a variety of non-linear fitting techniques. Each package is specialized to problems -of a certain nature, with many focused on problems like generalized linear modeling or determining the -distribution(s) from which the data was drawn. For these problems the model is typically composed of the equations and -distributions that are fitted to the data, which are easily expressed syntactically such that the PPL API offers an -expressive way to define the model and extensions can be implemented in an intuitive and straightforward way. - -# Statement of Need - -`PyAutoFit` is a PPL whose core design is providing a direct interface with the model, data, fitting procedure and -results, allowing it to provide comprehensive management of many different aspects of model-fitting. **PyAutoFit** began -as an Astronomy project for fitting large imaging datasets of galaxies, after the developers found that existing PPLs -were not suited to the type of model fitting problems Astronomers faced. This includes efficiently analysing large and -homogenous datasets with an identical model fitting procedure, making it straight forward to fit many models to -large datasets with streamlined model comparison and massively parallel support for problems where an expensive -likelihood function means run-times can be of order days or longer. More recent development has generalized `PyAutoFit`, -making it suitable to a broader range of model-fitting problems. - -# Software Description - -To compose a model with `PyAutoFit` model components are written as Python classes, allowing `PyAutoFit` to -define the model and associated parameters in an expressive way that is tied to the modeling software's API. A -model fit then requires that a `PyAutoFit` `Analysis` class is written, which combines the data, model and likelihood -function and defines how the model-fit is performed using a `NonLinearSearch`. The `NonLinearSearch` -procedure is defined using an external inference library such as `dynesty` [@dynesty], `emcee` [@emcee] -or `scipy` [@scipy]. - -The `Analysis` class provides a model specific interface between `PyAutoFit` and the modeling software, allowing it -to handle the 'heavy lifting' that comes with writing model-fitting software. This includes interfacing with the -non-linear search, outputting results in a structured path format and model-specific visualization during and -after the non-linear search. Results are output in a database structure that allows the `Aggregator` tool to load -results post-analysis via a Python script or Jupyter notebook. This includes methods for summarizing the results of -every fit, filtering results to inspect subsets of model fits and visualizing results. Results are loaded as `Python` -generators, ensuring the `Aggregator` can be used to interpret large files in a memory efficient way. `PyAutoFit` is -therefore suited to 'big data' problems where independent fits to large homogeneous data-sets using an identical -model-fitting procedure are performed. - -# Model Abstraction and Composition - -For many modeling problems the model comprises abstract model components representing objects or processes in a -physical system. For example, galaxy morphology studies in astrophysics where model components represent the light -profile of stars [@Haussler2013; @Nightingale2019]. For these problems the likelihood function is typically a -sequence of numerical processes (e.g., convolutions, Fourier transforms, linear algebra) and extensions to the model -often requires the addition of new model components in a way that is non-trivially included in the fitting process -and likelihood function. Existing PPLs have tools for these problems, for example 'black-box' likelihood functions -in PyMC3. However, these solutions decouple model composition from the data and fitting procedure, making the model -less expressive, restricting model customization and reducing flexibility in how the model-fit is performed. - -By writing model components as Python classes, the model and its associated parameters are defined in an expressive -way that is tied to the modeling software’s API. Model composition with `PyAutoFit` allows complex models to be built -from these individual components, abstracting the details of how they change model-fitting procedure from the user. -Models can be fully customized, allowing adjustment of individual parameter priors, the fixing or coupling of -parameters between model components and removing regions of parameter space via parameter assertions. Adding new model -components to a `PyAutoFit` project is straightforward, whereby adding a new Python class means it works within -the entire modeling framework. `PyAutoFit` is therefore ideal for problems where there is a desire to compose, fit and -compare many similar (but slightly different) models to a single dataset, with the `Aggregator` including tools to -facilitate this. - -For many model fitting problems, domain specific knowledge of the model can be exploited to speed up the non-linear -search and ensure it locates the global maximum likelihood solution. For example, initial fits can be performed -using simplified model parameterizations, augmented datasets and faster non-linear fitting techniques. Through -experience users may know that certain model components share minimal covariance, meaning that separate fits to each -model component (in parameter spaces of reduced dimensionality) can be performed before fitting them simultaneously. -The results of these simplified fits can then be used to initialize fits using a higher dimensionality model. -Breaking down a model-fit in this way uses `PyAutoFit`'s non-linear search chaining, which granularizes the non-linear -fitting procedure into a series of linked non-linear searches. Initial model-fits are followed by fits that gradually -increase the model complexity, using the information gained throughout the pipeline to guide each `NonLinearSearch` -and thus enable accurate fitting of models of arbitrary complexity. - -# History - -`PyAutoFit` is a generalization of [PyAutoLens](https://github.com/PyAutoLabs/PyAutoLens), an Astronomy package -developed to analyse images of gravitationally lensed galaxies. Modeling gravitational lenses historically requires -large amounts of human time and supervision, an approach which does not scale to the incoming samples of 100000 objects. -Domain exploitation enabled full automation of the lens modeling procedure [@Nightingale2015; @Nightingale2018], with -model customization and the aggregator enabling one to fit large datasets with many different models. More -recently, `PyAutoFit` has been applied to calibrating radiation damage to charge coupled imaging devices and a model -of cancer tumour growth. - -# Workspace and HowToFit Tutorials - -`PyAutoFit` is distributed with the [autofit workspace](https://github.com/PyAutoLabs/autofit_workspace), which -contains example scripts for composing a model, performing a fit, using the `Aggregator` and `PyAutoFit`'s advanced -statistical inference methods. Complementing the workspace is the standalone -[HowToFit](https://github.com/PyAutoLabs/HowToFit) repository, a series of Jupyter notebook tutorials aimed at -non-experts, introducing them to model-fitting and Bayesian inference. They teach users how to write model-components -and `Analysis` classes in `PyAutoFit`, use these to fit a dataset and interpret the model-fitting results. The lectures -are available on our [Colab](https://colab.research.google.com/github/PyAutoLabs/autofit_workspace/blob/2026.7.25.2/notebooks/overview/overview_1_the_basics.ipynb) and may therefore be -taken without a local `PyAutoFit` installation. - -# Software Citations - -`PyAutoFit` is written in Python 3.8 - 3.11 [@python] and uses the following software packages: - -- `corner.py` https://github.com/dfm/corner.py [@corner] -- `dynesty` https://github.com/joshspeagle/dynesty [@dynesty] -- `emcee` https://github.com/dfm/emcee [@emcee] -- `matplotlib` https://github.com/matplotlib/matplotlib [@matplotlib] -- `NumPy` https://github.com/numpy/numpy [@numpy] -- `PyMultiNest` https://github.com/JohannesBuchner/PyMultiNest [@multinest] [@pymultinest] - -- `Scipy` https://github.com/scipy/scipy [@scipy] - -# Related Probabilistic Programming Languages - -- `PyMC3` https://github.com/pymc-devs/pymc3 [@Salvatier2016] -- `Pyro` https://github.com/pyro-ppl/pyro [@Bingham2019] -- `STAN` https://github.com/stan-dev/stan [@Carpenter2017] -- `TensorFlow Probability` https://github.com/tensorflow/probability [@tensorflow] -- `uravu` https://github.com/arm61/uravu [@uravu] - -# Acknowledgements - -JWN and RJM are supported by the UK Space Agency, through grant ST/V001582/1, and by InnovateUK through grant TS/V002856/1. RGH is supported by STFC Opportunities grant ST/T002565/1. -This work used the DiRAC@Durham facility managed by the Institute for Computational Cosmology on behalf of the STFC DiRAC HPC Facility (www.dirac.ac.uk). The equipment was funded by BEIS capital funding via STFC capital grants ST/K00042X/1, ST/P002293/1, ST/R002371/1 and ST/S002502/1, Durham University and STFC operations grant ST/R000832/1. DiRAC is part of the National e-Infrastructure. - -# References +--- +title: "`PyAutoFit`: A Classy Probabilistic Programming Language for Model Composition and Fitting" +tags: + - Python + - statistics + - Bayesian inference + - probabilistic programming + - model fitting +authors: + - name: James. W. Nightingale + orcid: 0000-0002-8987-7401 + affiliation: 1 + - name: Richard G. Hayes + affiliation: 1 + - name: Matthew Griffiths + orcid: 0000-0002-2553-2447 + affiliation: 2 +affiliations: + - name: Institute for Computational Cosmology, Stockton Rd, Durham, United Kingdom, DH1 3LE + index: 1 + - name: ConcR Ltd, London, UK + index: 2 +date: 17 July 2020 +codeRepository: https://github.com/PyAutoLabs/PyAutoFit +license: MIT +bibliography: paper.bib +--- + +# Summary + +A major trend in academia and data science is the rapid adoption of Bayesian statistics for data analysis and modeling, +leading to the development of probabilistic programming languages (PPL). A PPL provides a framework that allows users +to easily specify a probabilistic model and perform inference automatically. `PyAutoFit` is a Python-based PPL which +interfaces with all aspects of the modeling (e.g., the model, data, fitting procedure, visualization, results) and +therefore provides complete management of every aspect of modeling. This includes composing high-dimensionality models +from individual model components, customizing the fitting procedure and performing data augmentation before a model-fit. +Advanced features include database tools for analysing large suites of modeling results and exploiting domain-specific +knowledge of a problem via non-linear search chaining. Accompanying `PyAutoFit` is the [autofit workspace](https://github.com/PyAutoLabs/autofit_workspace), +which includes example scripts, together with the standalone [HowToFit](https://github.com/PyAutoLabs/HowToFit) +lecture series which introduces non-experts to model-fitting and provides a guide on how to begin a project +using `PyAutoFit`. Readers can try `PyAutoFit` right now by +going to [the introduction Jupyter notebook on Colab](https://colab.research.google.com/github/PyAutoLabs/autofit_workspace/blob/2026.7.25.2/notebooks/overview/overview_1_the_basics.ipynb) +or checkout our [readthedocs](https://pyautofit.readthedocs.io/en/latest/) for a complete overview +of **PyAutoFit**'s features. + +# Background of Probabilistic Programming + +Probabilistic programming languages (PPLs) have enabled contemporary statistical inference techniques to be applied +to a diverse range of problems across academia and industry. Packages such as PyMC3 [@Salvatier2016], +Pyro [@Bingham2019] and STAN [@Carpenter2017] offer general-purpose frameworks where users can specify a generative +model and fit it to data using a variety of non-linear fitting techniques. Each package is specialized to problems +of a certain nature, with many focused on problems like generalized linear modeling or determining the +distribution(s) from which the data was drawn. For these problems the model is typically composed of the equations and +distributions that are fitted to the data, which are easily expressed syntactically such that the PPL API offers an +expressive way to define the model and extensions can be implemented in an intuitive and straightforward way. + +# Statement of Need + +`PyAutoFit` is a PPL whose core design is providing a direct interface with the model, data, fitting procedure and +results, allowing it to provide comprehensive management of many different aspects of model-fitting. **PyAutoFit** began +as an Astronomy project for fitting large imaging datasets of galaxies, after the developers found that existing PPLs +were not suited to the type of model fitting problems Astronomers faced. This includes efficiently analysing large and +homogenous datasets with an identical model fitting procedure, making it straight forward to fit many models to +large datasets with streamlined model comparison and massively parallel support for problems where an expensive +likelihood function means run-times can be of order days or longer. More recent development has generalized `PyAutoFit`, +making it suitable to a broader range of model-fitting problems. + +# Software Description + +To compose a model with `PyAutoFit` model components are written as Python classes, allowing `PyAutoFit` to +define the model and associated parameters in an expressive way that is tied to the modeling software's API. A +model fit then requires that a `PyAutoFit` `Analysis` class is written, which combines the data, model and likelihood +function and defines how the model-fit is performed using a `NonLinearSearch`. The `NonLinearSearch` +procedure is defined using an external inference library such as `dynesty` [@dynesty], `emcee` [@emcee] +or `scipy` [@scipy]. + +The `Analysis` class provides a model specific interface between `PyAutoFit` and the modeling software, allowing it +to handle the 'heavy lifting' that comes with writing model-fitting software. This includes interfacing with the +non-linear search, outputting results in a structured path format and model-specific visualization during and +after the non-linear search. Results are output in a database structure that allows the `Aggregator` tool to load +results post-analysis via a Python script or Jupyter notebook. This includes methods for summarizing the results of +every fit, filtering results to inspect subsets of model fits and visualizing results. Results are loaded as `Python` +generators, ensuring the `Aggregator` can be used to interpret large files in a memory efficient way. `PyAutoFit` is +therefore suited to 'big data' problems where independent fits to large homogeneous data-sets using an identical +model-fitting procedure are performed. + +# Model Abstraction and Composition + +For many modeling problems the model comprises abstract model components representing objects or processes in a +physical system. For example, galaxy morphology studies in astrophysics where model components represent the light +profile of stars [@Haussler2013; @Nightingale2019]. For these problems the likelihood function is typically a +sequence of numerical processes (e.g., convolutions, Fourier transforms, linear algebra) and extensions to the model +often requires the addition of new model components in a way that is non-trivially included in the fitting process +and likelihood function. Existing PPLs have tools for these problems, for example 'black-box' likelihood functions +in PyMC3. However, these solutions decouple model composition from the data and fitting procedure, making the model +less expressive, restricting model customization and reducing flexibility in how the model-fit is performed. + +By writing model components as Python classes, the model and its associated parameters are defined in an expressive +way that is tied to the modeling software’s API. Model composition with `PyAutoFit` allows complex models to be built +from these individual components, abstracting the details of how they change model-fitting procedure from the user. +Models can be fully customized, allowing adjustment of individual parameter priors, the fixing or coupling of +parameters between model components and removing regions of parameter space via parameter assertions. Adding new model +components to a `PyAutoFit` project is straightforward, whereby adding a new Python class means it works within +the entire modeling framework. `PyAutoFit` is therefore ideal for problems where there is a desire to compose, fit and +compare many similar (but slightly different) models to a single dataset, with the `Aggregator` including tools to +facilitate this. + +For many model fitting problems, domain specific knowledge of the model can be exploited to speed up the non-linear +search and ensure it locates the global maximum likelihood solution. For example, initial fits can be performed +using simplified model parameterizations, augmented datasets and faster non-linear fitting techniques. Through +experience users may know that certain model components share minimal covariance, meaning that separate fits to each +model component (in parameter spaces of reduced dimensionality) can be performed before fitting them simultaneously. +The results of these simplified fits can then be used to initialize fits using a higher dimensionality model. +Breaking down a model-fit in this way uses `PyAutoFit`'s non-linear search chaining, which granularizes the non-linear +fitting procedure into a series of linked non-linear searches. Initial model-fits are followed by fits that gradually +increase the model complexity, using the information gained throughout the pipeline to guide each `NonLinearSearch` +and thus enable accurate fitting of models of arbitrary complexity. + +# History + +`PyAutoFit` is a generalization of [PyAutoLens](https://github.com/PyAutoLabs/PyAutoLens), an Astronomy package +developed to analyse images of gravitationally lensed galaxies. Modeling gravitational lenses historically requires +large amounts of human time and supervision, an approach which does not scale to the incoming samples of 100000 objects. +Domain exploitation enabled full automation of the lens modeling procedure [@Nightingale2015; @Nightingale2018], with +model customization and the aggregator enabling one to fit large datasets with many different models. More +recently, `PyAutoFit` has been applied to calibrating radiation damage to charge coupled imaging devices and a model +of cancer tumour growth. + +# Workspace and HowToFit Tutorials + +`PyAutoFit` is distributed with the [autofit workspace](https://github.com/PyAutoLabs/autofit_workspace), which +contains example scripts for composing a model, performing a fit, using the `Aggregator` and `PyAutoFit`'s advanced +statistical inference methods. Complementing the workspace is the standalone +[HowToFit](https://github.com/PyAutoLabs/HowToFit) repository, a series of Jupyter notebook tutorials aimed at +non-experts, introducing them to model-fitting and Bayesian inference. They teach users how to write model-components +and `Analysis` classes in `PyAutoFit`, use these to fit a dataset and interpret the model-fitting results. The lectures +are available on our [Colab](https://colab.research.google.com/github/PyAutoLabs/autofit_workspace/blob/2026.7.25.2/notebooks/overview/overview_1_the_basics.ipynb) and may therefore be +taken without a local `PyAutoFit` installation. + +# Software Citations + +`PyAutoFit` is written in Python 3.8 - 3.11 [@python] and uses the following software packages: + +- `corner.py` https://github.com/dfm/corner.py [@corner] +- `dynesty` https://github.com/joshspeagle/dynesty [@dynesty] +- `emcee` https://github.com/dfm/emcee [@emcee] +- `matplotlib` https://github.com/matplotlib/matplotlib [@matplotlib] +- `NumPy` https://github.com/numpy/numpy [@numpy] +- `PyMultiNest` https://github.com/JohannesBuchner/PyMultiNest [@multinest] [@pymultinest] + +- `Scipy` https://github.com/scipy/scipy [@scipy] + +# Related Probabilistic Programming Languages + +- `PyMC3` https://github.com/pymc-devs/pymc3 [@Salvatier2016] +- `Pyro` https://github.com/pyro-ppl/pyro [@Bingham2019] +- `STAN` https://github.com/stan-dev/stan [@Carpenter2017] +- `TensorFlow Probability` https://github.com/tensorflow/probability [@tensorflow] +- `uravu` https://github.com/arm61/uravu [@uravu] + +# Acknowledgements + +JWN and RJM are supported by the UK Space Agency, through grant ST/V001582/1, and by InnovateUK through grant TS/V002856/1. RGH is supported by STFC Opportunities grant ST/T002565/1. +This work used the DiRAC@Durham facility managed by the Institute for Computational Cosmology on behalf of the STFC DiRAC HPC Facility (www.dirac.ac.uk). The equipment was funded by BEIS capital funding via STFC capital grants ST/K00042X/1, ST/P002293/1, ST/R002371/1 and ST/S002502/1, Durham University and STFC operations grant ST/R000832/1. DiRAC is part of the National e-Infrastructure. + +# References diff --git a/pyproject.toml b/pyproject.toml index 0f1aba552..29fe3be85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,101 +1,101 @@ -[build-system] -requires = ["setuptools>=79.0", "setuptools-scm", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "autofit" -dynamic = ["version"] -description = "Classy Probabilistic Programming" -readme = { file = "README.md", content-type = "text/markdown" } -license = { text = "MIT" } -requires-python = ">=3.9" -authors = [ - { name = "James Nightingale", email = "James.Nightingale@newcastle.ac.uk" }, - { name = "Richard Hayes", email = "richard@rghsoftware.co.uk" }, -] -classifiers = [ - "Intended Audience :: Science/Research", - "Topic :: Scientific/Engineering :: Physics", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" -] -keywords = ["cli"] -dependencies = [ - "autonerves", - "array_api_compat", - "anesthetic>=2.9.0", - "corner==2.2.2", - "decorator>=4.2.1", - "dill>=0.3.1.1", - "dynesty==2.1.5", - "typing-inspect>=0.4.0", - "emcee>=3.1.6", - "gprof2dot==2021.2.21", - "matplotlib", - "numpydoc>=1.0.0", - - "h5py>=3.11.0", - "SQLAlchemy>=2.0.32,<2.1.0", - "scipy<=1.17.1", - "astunparse==1.6.3", - "threadpoolctl>=3.1.0", - "timeout-decorator==0.5.0", - "xxhash<=3.4.1", - "networkx==3.1", - "pyvis==0.3.2", - "psutil==6.1.0" -] - -[project.urls] -Homepage = "https://github.com/PyAutoLabs/PyAutoFit" - -[tool.setuptools] -include-package-data = true - -[tool.setuptools.packages.find] -exclude = ["docs", "test_autofit", "test_autofit*"] - -[tool.setuptools_scm] -version_scheme = "post-release" -local_scheme = "no-local-version" - - -[project.optional-dependencies] -jax = ["autonerves[jax]", "optax>=0.2.5"] -mcp = ["mcp"] -optional = [ - "autofit[jax]", - "astropy>=5.0", - "blackjax>=1.2.0", - "getdist==1.4", - "nautilus-sampler==1.0.5", - "zeus-mcmc==2.5.4", -] -docs=[ - "sphinx", - "furo", - "myst-parser", - "sphinx_copybutton", - "sphinx_design", - "sphinx_inline_tabs", - "sphinx_autodoc_typehints" -] - -test = ["pytest"] -dev = ["pytest", "black"] - -[tool.setuptools.package-data] -"autofit.config" = ["*"] - -[tool.pytest.ini_options] -testpaths = ["test_autofit"] -filterwarnings = [ - "ignore:cuda_plugin_extension:UserWarning", - "ignore::DeprecationWarning:jax", - "ignore:relationship .* will copy column:sqlalchemy.exc.SAWarning", +[build-system] +requires = ["setuptools>=79.0", "setuptools-scm", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "autofit" +dynamic = ["version"] +description = "Classy Probabilistic Programming" +readme = { file = "README.md", content-type = "text/markdown" } +license = { text = "MIT" } +requires-python = ">=3.9" +authors = [ + { name = "James Nightingale", email = "James.Nightingale@newcastle.ac.uk" }, + { name = "Richard Hayes", email = "richard@rghsoftware.co.uk" }, +] +classifiers = [ + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Physics", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" +] +keywords = ["cli"] +dependencies = [ + "autonerves", + "array_api_compat", + "anesthetic>=2.9.0", + "corner==2.2.2", + "decorator>=4.2.1", + "dill>=0.3.1.1", + "dynesty==2.1.5", + "typing-inspect>=0.4.0", + "emcee>=3.1.6", + "gprof2dot==2021.2.21", + "matplotlib", + "numpydoc>=1.0.0", + + "h5py>=3.11.0", + "SQLAlchemy>=2.0.32,<2.1.0", + "scipy<=1.17.1", + "astunparse==1.6.3", + "threadpoolctl>=3.1.0", + "timeout-decorator==0.5.0", + "xxhash<=3.4.1", + "networkx==3.1", + "pyvis==0.3.2", + "psutil==6.1.0" +] + +[project.urls] +Homepage = "https://github.com/PyAutoLabs/PyAutoFit" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +exclude = ["docs", "test_autofit", "test_autofit*"] + +[tool.setuptools_scm] +version_scheme = "post-release" +local_scheme = "no-local-version" + + +[project.optional-dependencies] +jax = ["autonerves[jax]", "optax>=0.2.5"] +mcp = ["mcp"] +optional = [ + "autofit[jax]", + "astropy>=5.0", + "blackjax>=1.2.0", + "getdist==1.4", + "nautilus-sampler==1.0.5", + "zeus-mcmc==2.5.4", +] +docs=[ + "sphinx", + "furo", + "myst-parser", + "sphinx_copybutton", + "sphinx_design", + "sphinx_inline_tabs", + "sphinx_autodoc_typehints" +] + +test = ["pytest"] +dev = ["pytest", "black"] + +[tool.setuptools.package-data] +"autofit.config" = ["*"] + +[tool.pytest.ini_options] +testpaths = ["test_autofit"] +filterwarnings = [ + "ignore:cuda_plugin_extension:UserWarning", + "ignore::DeprecationWarning:jax", + "ignore:relationship .* will copy column:sqlalchemy.exc.SAWarning", ] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index f6b60952a..9af7e6f11 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ -[aliases] +[aliases] test=pytest \ No newline at end of file diff --git a/setup.py b/setup.py index 4da4962ed..467a065b1 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,8 @@ -import os -from setuptools import setup - -version = os.environ.get("VERSION", "1.0.dev0") - -setup( - version=version, +import os +from setuptools import setup + +version = os.environ.get("VERSION", "1.0.dev0") + +setup( + version=version, ) \ No newline at end of file diff --git a/test_autofit/aggregator/conftest.py b/test_autofit/aggregator/conftest.py index a2deb2e51..ceceb45d0 100644 --- a/test_autofit/aggregator/conftest.py +++ b/test_autofit/aggregator/conftest.py @@ -1,37 +1,37 @@ -from pathlib import Path - -import pytest - -import autofit as af -from autofit import SearchOutput - - -@pytest.fixture(name="aggregator") -def make_path_aggregator(session): - fits = [ - af.db.Fit(id="complete", is_complete=True), - af.db.Fit(id="incomplete", is_complete=False), - ] - for i, fit in enumerate(fits): - fit["dataset"] = {"name": "dataset"} - fit["pipeline"] = f"pipeline{i}" - session.add_all(fits) - session.flush() - return af.Aggregator(session) - - -@pytest.fixture(name="directory") -def make_directory(): - return Path(__file__).parent - - -@pytest.fixture(name="aggregator_directory") -def make_aggregator_directory(directory): - directory = Path(__file__).resolve().parent - - return directory.parent / "tools" / "files" / "aggregator" - - -@pytest.fixture(name="search_output") -def make_search_output(directory): - return SearchOutput(directory / "search_output") +from pathlib import Path + +import pytest + +import autofit as af +from autofit import SearchOutput + + +@pytest.fixture(name="aggregator") +def make_path_aggregator(session): + fits = [ + af.db.Fit(id="complete", is_complete=True), + af.db.Fit(id="incomplete", is_complete=False), + ] + for i, fit in enumerate(fits): + fit["dataset"] = {"name": "dataset"} + fit["pipeline"] = f"pipeline{i}" + session.add_all(fits) + session.flush() + return af.Aggregator(session) + + +@pytest.fixture(name="directory") +def make_directory(): + return Path(__file__).parent + + +@pytest.fixture(name="aggregator_directory") +def make_aggregator_directory(directory): + directory = Path(__file__).resolve().parent + + return directory.parent / "tools" / "files" / "aggregator" + + +@pytest.fixture(name="search_output") +def make_search_output(directory): + return SearchOutput(directory / "search_output") diff --git a/test_autofit/aggregator/search_output_derived/files/derived_quantities.csv b/test_autofit/aggregator/search_output_derived/files/derived_quantities.csv index 85435c133..9451f1fa0 100644 --- a/test_autofit/aggregator/search_output_derived/files/derived_quantities.csv +++ b/test_autofit/aggregator/search_output_derived/files/derived_quantities.csv @@ -1,2 +1,2 @@ - fwhm -10 + fwhm +10 diff --git a/test_autofit/aggregator/search_output_derived/files/samples.csv b/test_autofit/aggregator/search_output_derived/files/samples.csv index abd8eb144..3e742ea8a 100644 --- a/test_autofit/aggregator/search_output_derived/files/samples.csv +++ b/test_autofit/aggregator/search_output_derived/files/samples.csv @@ -1,2 +1,2 @@ - centre, normalization, sigma, log_likelihood, log_prior, log_posterior,weight - 49.89547152732197, 25.0243143120209, 9.82192444968168, 182.126490185352, 0.03996113489989339, 182.1664513202519, 1.0 + centre, normalization, sigma, log_likelihood, log_prior, log_posterior,weight + 49.89547152732197, 25.0243143120209, 9.82192444968168, 182.126490185352, 0.03996113489989339, 182.1664513202519, 1.0 diff --git a/test_autofit/aggregator/test_aggregator.py b/test_autofit/aggregator/test_aggregator.py index 6f7d656cf..97b378f55 100644 --- a/test_autofit/aggregator/test_aggregator.py +++ b/test_autofit/aggregator/test_aggregator.py @@ -1,64 +1,64 @@ -import pytest - -import autofit as af - - -@pytest.fixture(name="aggregator_x3") -def make_aggregator_x3(session): - fits = [af.db.Fit(id=f"fit_{i}", is_complete=True) for i in range(3)] - session.add_all(fits) - session.flush() - return af.Aggregator(session) - - -def test_slicing(aggregator_x3): - assert len(aggregator_x3[:2]) == 2 - assert len(aggregator_x3[1:3]) == 2 - assert len(aggregator_x3[:-1]) == 2 - assert len(aggregator_x3[-2:]) == 2 - assert len(aggregator_x3[2:]) == 1 - - -def test_completed_aggregator( - aggregator -): - aggregator = aggregator( - aggregator.search.is_complete - ) - assert len(aggregator) == 1 - - -class TestLoading: - def test_unzip(self, aggregator): - assert len(aggregator) == 2 - - def test_pickles(self, aggregator): - assert list(aggregator.values("dataset"))[0]["name"] == "dataset" - - -class TestOperations: - def test_attribute(self, aggregator): - assert list(aggregator.values("pipeline")) == [ - "pipeline0", - "pipeline1" - ] - - def test_indexing(self, aggregator): - assert list(aggregator[1:].values("pipeline")) == ["pipeline1"] - assert list(aggregator[:1].values("pipeline")) == ["pipeline0"] - assert list(aggregator[1: 2].values("pipeline")) == ["pipeline1"] - assert list(aggregator[0: 1].values("pipeline")) == ["pipeline0"] - assert list(aggregator[-1:].values("pipeline")) == ["pipeline1"] - assert list(aggregator[:-1].values("pipeline")) == ["pipeline0"] - assert aggregator[0]["pipeline"] == "pipeline0" - assert aggregator[-1]["pipeline"] == "pipeline1" - - def test_map(self, aggregator): - def some_function(fit): - return f"{fit.id} {fit['pipeline']}" - - results = aggregator.map(some_function) - assert list(results) == [ - 'complete pipeline0', - 'incomplete pipeline1' - ] +import pytest + +import autofit as af + + +@pytest.fixture(name="aggregator_x3") +def make_aggregator_x3(session): + fits = [af.db.Fit(id=f"fit_{i}", is_complete=True) for i in range(3)] + session.add_all(fits) + session.flush() + return af.Aggregator(session) + + +def test_slicing(aggregator_x3): + assert len(aggregator_x3[:2]) == 2 + assert len(aggregator_x3[1:3]) == 2 + assert len(aggregator_x3[:-1]) == 2 + assert len(aggregator_x3[-2:]) == 2 + assert len(aggregator_x3[2:]) == 1 + + +def test_completed_aggregator( + aggregator +): + aggregator = aggregator( + aggregator.search.is_complete + ) + assert len(aggregator) == 1 + + +class TestLoading: + def test_unzip(self, aggregator): + assert len(aggregator) == 2 + + def test_pickles(self, aggregator): + assert list(aggregator.values("dataset"))[0]["name"] == "dataset" + + +class TestOperations: + def test_attribute(self, aggregator): + assert list(aggregator.values("pipeline")) == [ + "pipeline0", + "pipeline1" + ] + + def test_indexing(self, aggregator): + assert list(aggregator[1:].values("pipeline")) == ["pipeline1"] + assert list(aggregator[:1].values("pipeline")) == ["pipeline0"] + assert list(aggregator[1: 2].values("pipeline")) == ["pipeline1"] + assert list(aggregator[0: 1].values("pipeline")) == ["pipeline0"] + assert list(aggregator[-1:].values("pipeline")) == ["pipeline1"] + assert list(aggregator[:-1].values("pipeline")) == ["pipeline0"] + assert aggregator[0]["pipeline"] == "pipeline0" + assert aggregator[-1]["pipeline"] == "pipeline1" + + def test_map(self, aggregator): + def some_function(fit): + return f"{fit.id} {fit['pipeline']}" + + results = aggregator.map(some_function) + assert list(results) == [ + 'complete pipeline0', + 'incomplete pipeline1' + ] diff --git a/test_autofit/config/non_linear/GridSearch.yaml b/test_autofit/config/non_linear/GridSearch.yaml index eb03a4d07..965fe5c43 100644 --- a/test_autofit/config/non_linear/GridSearch.yaml +++ b/test_autofit/config/non_linear/GridSearch.yaml @@ -1,5 +1,5 @@ -general: - number_of_cores: '3 # The number of cores the search is parallelized over by default, - using Python multiprocessing.' - step_size: '0.1 # The default step size of each grid search parameter, in +general: + number_of_cores: '3 # The number of cores the search is parallelized over by default, + using Python multiprocessing.' + step_size: '0.1 # The default step size of each grid search parameter, in terms of unit values of the priors.' \ No newline at end of file diff --git a/test_autofit/conftest.py b/test_autofit/conftest.py index 4e0a49e65..8c057363a 100644 --- a/test_autofit/conftest.py +++ b/test_autofit/conftest.py @@ -1,125 +1,125 @@ -import multiprocessing -import os -import shutil -import sys -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -from matplotlib import pyplot - -from autonerves import conf -from autofit import database as db -from autofit import fixtures -from autofit.database.model import sa -from autofit.non_linear.search import abstract_search - -if sys.platform == "darwin": - multiprocessing.set_start_method("fork") - -directory = Path(__file__).parent - - -@pytest.fixture(autouse=True) -def turn_off_gc(monkeypatch): - monkeypatch.setattr(abstract_search, "gc", MagicMock()) - - -@pytest.fixture(name="remove_ids") -def make_remove_ids(): - def remove_ids(d): - if isinstance(d, dict): - return {k: remove_ids(v) for k, v in d.items() if k != "id"} - elif isinstance(d, list): - return [remove_ids(v) for v in d] - return d - - return remove_ids - - -@pytest.fixture(name="test_directory", scope="session") -def make_test_directory(): - return directory - - -@pytest.fixture(name="output_directory", scope="session") -def make_output_directory(test_directory): - return test_directory / "output" - - -@pytest.fixture(name="remove_output", scope="session") -def make_remove_output(output_directory): - def remove_output(): - try: - for item in os.listdir(output_directory): - if item != "non_linear": - item_path = output_directory / item - if item_path.is_dir(): - shutil.rmtree( - item_path, - ignore_errors=True, - ) - else: - os.remove(item_path) - except (FileExistsError, FileNotFoundError): - pass - - return remove_output - - -@pytest.fixture(autouse=True) -def do_remove_output(output_directory, remove_output): - yield - remove_output() - - -class PlotPatch: - def __init__(self): - self.paths = [] - - def __call__(self, path, *args, **kwargs): - self.paths.append(str(path)) - - -@pytest.fixture(name="plot_patch") -def make_plot_patch(monkeypatch): - plot_patch = PlotPatch() - monkeypatch.setattr(pyplot, "savefig", plot_patch) - return plot_patch - - -@pytest.fixture(name="session") -def make_session(): - engine = sa.create_engine("sqlite://") - session = sa.orm.sessionmaker(bind=engine)() - db.Base.metadata.create_all(engine) - yield session - session.close() - engine.dispose() - - -@pytest.fixture(autouse=True, scope="session") -def remove_logs(): - yield - for d, _, files in os.walk(directory): - for file in files: - if file.endswith(".log"): - os.remove(Path(d) / file) - - -@pytest.fixture(autouse=True) -def set_config_path(): - conf.instance.push( - new_path=str(directory / "config"), - output_path=str(directory / "output"), - ) - - -@pytest.fixture(name="model_gaussian_x1") -def make_model_gaussian_x1(): - return fixtures.make_model_gaussian_x1() - - -@pytest.fixture(name="samples_x5") -def make_samples_x5(): - return fixtures.make_samples_x5() +import multiprocessing +import os +import shutil +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from matplotlib import pyplot + +from autonerves import conf +from autofit import database as db +from autofit import fixtures +from autofit.database.model import sa +from autofit.non_linear.search import abstract_search + +if sys.platform == "darwin": + multiprocessing.set_start_method("fork") + +directory = Path(__file__).parent + + +@pytest.fixture(autouse=True) +def turn_off_gc(monkeypatch): + monkeypatch.setattr(abstract_search, "gc", MagicMock()) + + +@pytest.fixture(name="remove_ids") +def make_remove_ids(): + def remove_ids(d): + if isinstance(d, dict): + return {k: remove_ids(v) for k, v in d.items() if k != "id"} + elif isinstance(d, list): + return [remove_ids(v) for v in d] + return d + + return remove_ids + + +@pytest.fixture(name="test_directory", scope="session") +def make_test_directory(): + return directory + + +@pytest.fixture(name="output_directory", scope="session") +def make_output_directory(test_directory): + return test_directory / "output" + + +@pytest.fixture(name="remove_output", scope="session") +def make_remove_output(output_directory): + def remove_output(): + try: + for item in os.listdir(output_directory): + if item != "non_linear": + item_path = output_directory / item + if item_path.is_dir(): + shutil.rmtree( + item_path, + ignore_errors=True, + ) + else: + os.remove(item_path) + except (FileExistsError, FileNotFoundError): + pass + + return remove_output + + +@pytest.fixture(autouse=True) +def do_remove_output(output_directory, remove_output): + yield + remove_output() + + +class PlotPatch: + def __init__(self): + self.paths = [] + + def __call__(self, path, *args, **kwargs): + self.paths.append(str(path)) + + +@pytest.fixture(name="plot_patch") +def make_plot_patch(monkeypatch): + plot_patch = PlotPatch() + monkeypatch.setattr(pyplot, "savefig", plot_patch) + return plot_patch + + +@pytest.fixture(name="session") +def make_session(): + engine = sa.create_engine("sqlite://") + session = sa.orm.sessionmaker(bind=engine)() + db.Base.metadata.create_all(engine) + yield session + session.close() + engine.dispose() + + +@pytest.fixture(autouse=True, scope="session") +def remove_logs(): + yield + for d, _, files in os.walk(directory): + for file in files: + if file.endswith(".log"): + os.remove(Path(d) / file) + + +@pytest.fixture(autouse=True) +def set_config_path(): + conf.instance.push( + new_path=str(directory / "config"), + output_path=str(directory / "output"), + ) + + +@pytest.fixture(name="model_gaussian_x1") +def make_model_gaussian_x1(): + return fixtures.make_model_gaussian_x1() + + +@pytest.fixture(name="samples_x5") +def make_samples_x5(): + return fixtures.make_samples_x5() diff --git a/test_autofit/graphical/conftest.py b/test_autofit/graphical/conftest.py index df5531e03..16f361ec5 100644 --- a/test_autofit/graphical/conftest.py +++ b/test_autofit/graphical/conftest.py @@ -1,23 +1,23 @@ -import numpy as np -import pytest - -import autofit.mapper.variable -from autofit import graphical as graph - - -@pytest.fixture(autouse=True) -def set_seed(): - np.random.seed(0) - - -@pytest.fixture(name="x") -def make_x(): - return autofit.mapper.variable.Variable("x") - - -@pytest.fixture(name="probit_factor") -def make_probit_factor(x): - - from scipy import stats - - return graph.Factor(stats.norm(loc=0.0, scale=1.0).logcdf, x) +import numpy as np +import pytest + +import autofit.mapper.variable +from autofit import graphical as graph + + +@pytest.fixture(autouse=True) +def set_seed(): + np.random.seed(0) + + +@pytest.fixture(name="x") +def make_x(): + return autofit.mapper.variable.Variable("x") + + +@pytest.fixture(name="probit_factor") +def make_probit_factor(x): + + from scipy import stats + + return graph.Factor(stats.norm(loc=0.0, scale=1.0).logcdf, x) diff --git a/test_autofit/graphical/gaussian/model.py b/test_autofit/graphical/gaussian/model.py index 6472b0e0f..99cfba16c 100644 --- a/test_autofit/graphical/gaussian/model.py +++ b/test_autofit/graphical/gaussian/model.py @@ -1,97 +1,97 @@ -import numpy as np - -from scipy import stats - -import autofit as af - - -def _gaussian(x, centre, normalization, sigma): - return Gaussian(centre=centre, normalization=normalization, sigma=sigma)(x) - - -_norm = stats.norm(loc=0, scale=1.0) - - -# TODO: use autofit likelihood -def _likelihood(z, y): - return np.multiply(-0.5, np.square(np.subtract(z, y))) - - -class Profile: - def __init__(self, centre=0.0, normalization=0.01): - """Represents an Abstract 1D profile. - - Parameters - ---------- - centre - The x coordinate of the profile centre. - normalization - Overall normalization normalisation of the profile. - """ - self.centre = centre - self.normalization = normalization - - -class Gaussian(Profile): - def __init__( - self, - centre=0.0, # <- PyAutoFit recognises these constructor arguments - normalization=0.1, # <- are the Gaussian's model parameters. - sigma=0.01, - ): - """Represents a 1D Gaussian profile, which may be treated as a model-component of PyAutoFit the - parameters of which are fitted for by a non-linear search. - - Parameters - ---------- - centre - The x coordinate of the profile centre. - normalization - Overall normalization normalisation of the Gaussian profile. - sigma - The sigma value controlling the size of the Gaussian. - """ - super().__init__(centre=centre, normalization=normalization) - self.sigma = sigma # We still need to set sigma for the Gaussian, of course. - - def __call__(self, xvalues): - """ - Calculate the normalization of the profile on a line of Cartesian x coordinates. - - The input xvalues are translated to a coordinate system centred on the Gaussian, using its centre. - - Parameters - ---------- - xvalues - The x coordinates in the original reference frame of the grid. - """ - transformed_xvalues = np.subtract(xvalues, self.centre) - return np.multiply( - np.divide(self.normalization, self.sigma * np.sqrt(2.0 * np.pi)), - np.exp(-0.5 * np.square(np.divide(transformed_xvalues, self.sigma))), - ) - - -def make_data(gaussian, x): - model_line = gaussian(xvalues=x) - signal_to_noise_ratio = 25.0 - noise = np.random.normal(0.0, 1.0 / signal_to_noise_ratio, len(x)) - y = model_line + noise - return y - - -class Analysis(af.Analysis): - def __init__(self, x, y, sigma=0.04): - self.x = x - self.y = y - self.sigma = sigma - - super().__init__() - - def log_likelihood_function(self, instance: Gaussian) -> np.array: - """ - This function takes an instance created by the Model and computes the - likelihood that it fits the data. - """ - y_model = instance(self.x) - return np.sum(_likelihood(y_model, self.y) / self.sigma**2) +import numpy as np + +from scipy import stats + +import autofit as af + + +def _gaussian(x, centre, normalization, sigma): + return Gaussian(centre=centre, normalization=normalization, sigma=sigma)(x) + + +_norm = stats.norm(loc=0, scale=1.0) + + +# TODO: use autofit likelihood +def _likelihood(z, y): + return np.multiply(-0.5, np.square(np.subtract(z, y))) + + +class Profile: + def __init__(self, centre=0.0, normalization=0.01): + """Represents an Abstract 1D profile. + + Parameters + ---------- + centre + The x coordinate of the profile centre. + normalization + Overall normalization normalisation of the profile. + """ + self.centre = centre + self.normalization = normalization + + +class Gaussian(Profile): + def __init__( + self, + centre=0.0, # <- PyAutoFit recognises these constructor arguments + normalization=0.1, # <- are the Gaussian's model parameters. + sigma=0.01, + ): + """Represents a 1D Gaussian profile, which may be treated as a model-component of PyAutoFit the + parameters of which are fitted for by a non-linear search. + + Parameters + ---------- + centre + The x coordinate of the profile centre. + normalization + Overall normalization normalisation of the Gaussian profile. + sigma + The sigma value controlling the size of the Gaussian. + """ + super().__init__(centre=centre, normalization=normalization) + self.sigma = sigma # We still need to set sigma for the Gaussian, of course. + + def __call__(self, xvalues): + """ + Calculate the normalization of the profile on a line of Cartesian x coordinates. + + The input xvalues are translated to a coordinate system centred on the Gaussian, using its centre. + + Parameters + ---------- + xvalues + The x coordinates in the original reference frame of the grid. + """ + transformed_xvalues = np.subtract(xvalues, self.centre) + return np.multiply( + np.divide(self.normalization, self.sigma * np.sqrt(2.0 * np.pi)), + np.exp(-0.5 * np.square(np.divide(transformed_xvalues, self.sigma))), + ) + + +def make_data(gaussian, x): + model_line = gaussian(xvalues=x) + signal_to_noise_ratio = 25.0 + noise = np.random.normal(0.0, 1.0 / signal_to_noise_ratio, len(x)) + y = model_line + noise + return y + + +class Analysis(af.Analysis): + def __init__(self, x, y, sigma=0.04): + self.x = x + self.y = y + self.sigma = sigma + + super().__init__() + + def log_likelihood_function(self, instance: Gaussian) -> np.array: + """ + This function takes an instance created by the Model and computes the + likelihood that it fits the data. + """ + y_model = instance(self.x) + return np.sum(_likelihood(y_model, self.y) / self.sigma**2) diff --git a/test_autofit/graphical/gaussian/test_declarative.py b/test_autofit/graphical/gaussian/test_declarative.py index e07fcc6c2..ddb62c7a8 100644 --- a/test_autofit/graphical/gaussian/test_declarative.py +++ b/test_autofit/graphical/gaussian/test_declarative.py @@ -1,186 +1,186 @@ -import numpy as np -import pytest - -import autofit as af -import autofit.graphical as ep -from test_autofit.graphical.gaussian.model import Gaussian, make_data, Analysis - - -@pytest.fixture(name="make_model_factor") -def make_make_model_factor(normalization, normalization_prior, x): - def make_factor_model( - centre: float, sigma: float, optimiser=None - ) -> ep.AnalysisFactor: - """ - We'll make a LikelihoodModel for each Gaussian we're fitting. - - First we'll make the actual data to be fit. - - Note that the normalization value is shared. - """ - y = make_data( - Gaussian(centre=centre, normalization=normalization, sigma=sigma), x - ) - - """ - Next we need a prior model. - - Note that the normalization prior is shared. - """ - prior_model = af.Model( - Gaussian, - centre=af.GaussianPrior(mean=50, sigma=20), - normalization=normalization_prior, - sigma=af.GaussianPrior(mean=10, sigma=10), - ) - - """ - Finally we combine the likelihood function with the prior model to produce a likelihood - factor - this will be converted into a ModelFactor which is like any other factor in the - factor graph. - - We can also pass a custom optimiser in here that will be used to fit the factor instead - of the default optimiser. - """ - return ep.AnalysisFactor( - prior_model, analysis=Analysis(x=x, y=y), optimiser=optimiser - ) - - return make_factor_model - - -@pytest.fixture(name="normalization") -def make_normalization(): - return 25.0 - - -@pytest.fixture(name="normalization_prior") -def make_normalization_prior(): - return af.GaussianPrior(mean=25, sigma=10) - - -@pytest.fixture(name="factor_model") -def make_factor_model_collection(make_model_factor): - """ - Here's a good example in which we have two Gaussians fit with a shared variable - - We have a shared normalization value and a shared normalization prior - - Multiplying together multiple LikelihoodModels gives us a factor model. - - The factor model can compute all the variables and messages required as well as construct - a factor graph representing a fit on the ensemble. - """ - return ep.FactorGraphModel( - make_model_factor(centre=40, sigma=10), make_model_factor(centre=60, sigma=15) - ) - - -def test_custom_optimiser(make_model_factor): - other_optimiser = ep.LaplaceOptimiser() - - factor_1 = make_model_factor(centre=40, sigma=10, optimiser=other_optimiser) - factor_2 = make_model_factor(centre=60, sigma=15) - - factor_model = ep.FactorGraphModel(factor_1, factor_2) - - default_optimiser = ep.LaplaceOptimiser() - ep_optimiser = factor_model._make_ep_optimiser(default_optimiser) - - factor_optimisers = ep_optimiser.factor_optimisers - assert factor_optimisers[factor_1] is other_optimiser - assert factor_optimisers[factor_2] is default_optimiser - - -def test_factor_model_attributes(factor_model): - """ - There are: - - 5 messages - one for each prior - - 7 factors - one for each prior plus one for each likelihood - """ - assert len(factor_model.message_dict) == 5 - assert len(factor_model.graph.factors) == 7 - - -def _test_optimise_factor_model(factor_model): - """ - We optimise the model - """ - laplace = ep.LaplaceOptimiser() - - collection = factor_model.optimise( - laplace, - ) - - """ - And what we get back is actually a PriorModelCollection - """ - assert 25.0 == pytest.approx(collection[0].normalization.mean, rel=0.1) - assert collection[0].normalization is collection[1].normalization - - -def test_gaussian(): - n_observations = 100 - x = np.arange(n_observations) - y = make_data(Gaussian(centre=50.0, normalization=25.0, sigma=10.0), x) - - prior_model = af.Model( - Gaussian, - centre=af.GaussianPrior(mean=50, sigma=20), - normalization=af.GaussianPrior(mean=25, sigma=10), - sigma=af.GaussianPrior(mean=10, sigma=10), - ) - - factor_model = ep.AnalysisFactor(prior_model, analysis=Analysis(x=x, y=y)) - - laplace = ep.LaplaceOptimiser() - model = factor_model.optimise(laplace).model[0] - - assert model.centre.mean == pytest.approx(50, rel=0.1) - assert model.normalization.mean == pytest.approx(25, rel=0.1) - assert model.sigma.mean == pytest.approx(10, rel=0.1) - - -@pytest.fixture(name="prior_model") -def make_prior_model(): - return af.Model(Gaussian) - - -@pytest.fixture(name="likelihood_model") -def make_factor_model(prior_model): - class MockAnalysis(af.Analysis): - @staticmethod - def log_likelihood_function(*_): - return 1 - - return ep.AnalysisFactor(prior_model, analysis=af.m.MockAnalysis()) - - -def test_messages(likelihood_model): - assert len(likelihood_model.message_dict) == 3 - - -def test_graph(likelihood_model): - graph = likelihood_model.graph - assert len(graph.factors) == 4 - - -def test_prior_model_node(likelihood_model): - prior_model_node = likelihood_model.graph - - result = prior_model_node( - {variable: np.array([0.5]) for variable in prior_model_node.variables} - ) - - assert isinstance(result, ep.FactorValue) - - -# def test_pytrees( -# recreate, -# factor_model, -# make_model_factor, -# ): -# recreate(factor_model) -# -# model_factor = make_model_factor(centre=60, sigma=15) -# recreate(model_factor) +import numpy as np +import pytest + +import autofit as af +import autofit.graphical as ep +from test_autofit.graphical.gaussian.model import Gaussian, make_data, Analysis + + +@pytest.fixture(name="make_model_factor") +def make_make_model_factor(normalization, normalization_prior, x): + def make_factor_model( + centre: float, sigma: float, optimiser=None + ) -> ep.AnalysisFactor: + """ + We'll make a LikelihoodModel for each Gaussian we're fitting. + + First we'll make the actual data to be fit. + + Note that the normalization value is shared. + """ + y = make_data( + Gaussian(centre=centre, normalization=normalization, sigma=sigma), x + ) + + """ + Next we need a prior model. + + Note that the normalization prior is shared. + """ + prior_model = af.Model( + Gaussian, + centre=af.GaussianPrior(mean=50, sigma=20), + normalization=normalization_prior, + sigma=af.GaussianPrior(mean=10, sigma=10), + ) + + """ + Finally we combine the likelihood function with the prior model to produce a likelihood + factor - this will be converted into a ModelFactor which is like any other factor in the + factor graph. + + We can also pass a custom optimiser in here that will be used to fit the factor instead + of the default optimiser. + """ + return ep.AnalysisFactor( + prior_model, analysis=Analysis(x=x, y=y), optimiser=optimiser + ) + + return make_factor_model + + +@pytest.fixture(name="normalization") +def make_normalization(): + return 25.0 + + +@pytest.fixture(name="normalization_prior") +def make_normalization_prior(): + return af.GaussianPrior(mean=25, sigma=10) + + +@pytest.fixture(name="factor_model") +def make_factor_model_collection(make_model_factor): + """ + Here's a good example in which we have two Gaussians fit with a shared variable + + We have a shared normalization value and a shared normalization prior + + Multiplying together multiple LikelihoodModels gives us a factor model. + + The factor model can compute all the variables and messages required as well as construct + a factor graph representing a fit on the ensemble. + """ + return ep.FactorGraphModel( + make_model_factor(centre=40, sigma=10), make_model_factor(centre=60, sigma=15) + ) + + +def test_custom_optimiser(make_model_factor): + other_optimiser = ep.LaplaceOptimiser() + + factor_1 = make_model_factor(centre=40, sigma=10, optimiser=other_optimiser) + factor_2 = make_model_factor(centre=60, sigma=15) + + factor_model = ep.FactorGraphModel(factor_1, factor_2) + + default_optimiser = ep.LaplaceOptimiser() + ep_optimiser = factor_model._make_ep_optimiser(default_optimiser) + + factor_optimisers = ep_optimiser.factor_optimisers + assert factor_optimisers[factor_1] is other_optimiser + assert factor_optimisers[factor_2] is default_optimiser + + +def test_factor_model_attributes(factor_model): + """ + There are: + - 5 messages - one for each prior + - 7 factors - one for each prior plus one for each likelihood + """ + assert len(factor_model.message_dict) == 5 + assert len(factor_model.graph.factors) == 7 + + +def _test_optimise_factor_model(factor_model): + """ + We optimise the model + """ + laplace = ep.LaplaceOptimiser() + + collection = factor_model.optimise( + laplace, + ) + + """ + And what we get back is actually a PriorModelCollection + """ + assert 25.0 == pytest.approx(collection[0].normalization.mean, rel=0.1) + assert collection[0].normalization is collection[1].normalization + + +def test_gaussian(): + n_observations = 100 + x = np.arange(n_observations) + y = make_data(Gaussian(centre=50.0, normalization=25.0, sigma=10.0), x) + + prior_model = af.Model( + Gaussian, + centre=af.GaussianPrior(mean=50, sigma=20), + normalization=af.GaussianPrior(mean=25, sigma=10), + sigma=af.GaussianPrior(mean=10, sigma=10), + ) + + factor_model = ep.AnalysisFactor(prior_model, analysis=Analysis(x=x, y=y)) + + laplace = ep.LaplaceOptimiser() + model = factor_model.optimise(laplace).model[0] + + assert model.centre.mean == pytest.approx(50, rel=0.1) + assert model.normalization.mean == pytest.approx(25, rel=0.1) + assert model.sigma.mean == pytest.approx(10, rel=0.1) + + +@pytest.fixture(name="prior_model") +def make_prior_model(): + return af.Model(Gaussian) + + +@pytest.fixture(name="likelihood_model") +def make_factor_model(prior_model): + class MockAnalysis(af.Analysis): + @staticmethod + def log_likelihood_function(*_): + return 1 + + return ep.AnalysisFactor(prior_model, analysis=af.m.MockAnalysis()) + + +def test_messages(likelihood_model): + assert len(likelihood_model.message_dict) == 3 + + +def test_graph(likelihood_model): + graph = likelihood_model.graph + assert len(graph.factors) == 4 + + +def test_prior_model_node(likelihood_model): + prior_model_node = likelihood_model.graph + + result = prior_model_node( + {variable: np.array([0.5]) for variable in prior_model_node.variables} + ) + + assert isinstance(result, ep.FactorValue) + + +# def test_pytrees( +# recreate, +# factor_model, +# make_model_factor, +# ): +# recreate(factor_model) +# +# model_factor = make_model_factor(centre=60, sigma=15) +# recreate(model_factor) diff --git a/test_autofit/mapper/functionality/test_from_data_names.py b/test_autofit/mapper/functionality/test_from_data_names.py index 5f5f93769..1fc311670 100644 --- a/test_autofit/mapper/functionality/test_from_data_names.py +++ b/test_autofit/mapper/functionality/test_from_data_names.py @@ -1,26 +1,26 @@ -import pytest - -import autofit as af - -names = ["one", "two", "three"] - - -@pytest.fixture(name="collection") -def make_collection(): - return af.Collection({name: af.Model(af.m.MockClassx2) for name in names}) - - -def test_prior_count(collection): - assert collection.prior_count == 6 - - -@pytest.mark.parametrize("name", names) -def test_children(collection, name): - assert getattr(collection, name).prior_count == 2 - - -def test_replace(collection): - collection.one = af.Model(af.m.MockClassx4) - - assert collection.one.prior_count == 4 - assert collection.prior_count == 8 +import pytest + +import autofit as af + +names = ["one", "two", "three"] + + +@pytest.fixture(name="collection") +def make_collection(): + return af.Collection({name: af.Model(af.m.MockClassx2) for name in names}) + + +def test_prior_count(collection): + assert collection.prior_count == 6 + + +@pytest.mark.parametrize("name", names) +def test_children(collection, name): + assert getattr(collection, name).prior_count == 2 + + +def test_replace(collection): + collection.one = af.Model(af.m.MockClassx4) + + assert collection.one.prior_count == 4 + assert collection.prior_count == 8 diff --git a/test_autofit/mapper/functionality/test_take_attributes.py b/test_autofit/mapper/functionality/test_take_attributes.py index 1297704c6..b9fa34290 100644 --- a/test_autofit/mapper/functionality/test_take_attributes.py +++ b/test_autofit/mapper/functionality/test_take_attributes.py @@ -1,290 +1,290 @@ -import pytest - -import autofit as af - - -@pytest.fixture( - name="target_gaussian" -) -def make_target_gaussian(): - return af.Model( - af.ex.Gaussian - ) - - -@pytest.fixture( - name="prior" -) -def make_prior(): - return af.UniformPrior() - - -@pytest.fixture( - name="source_gaussian" -) -def make_source_gaussian(prior): - return af.Model( - af.ex.Gaussian, - centre=prior - ) - - -def test_simple( - source_gaussian, - target_gaussian, - prior -): - target_gaussian.take_attributes( - source_gaussian - ) - - assert target_gaussian.centre == prior - - -def test_assertions( - source_gaussian, - target_gaussian -): - target_gaussian.add_assertion( - target_gaussian.centre <= target_gaussian.normalization - ) - - with pytest.raises(AssertionError): - target_gaussian.take_attributes( - source_gaussian - ) - - -def test_assertions_collection( - source_gaussian, - target_gaussian -): - target_gaussian.add_assertion( - target_gaussian.centre <= target_gaussian.normalization - ) - - target_collection = af.Collection( - gaussian=target_gaussian - ) - source_collection = af.Collection( - gaussian=source_gaussian - ) - - with pytest.raises(AssertionError): - target_collection.take_attributes( - source_collection - ) - - -def test_in_collection( - source_gaussian, - target_gaussian, - prior -): - target = af.Collection( - gaussian=target_gaussian - ) - source = af.Collection( - gaussian=source_gaussian - ) - target.take_attributes( - source - ) - - assert target.gaussian.centre == prior - - -def test_tuple( - source_gaussian, - target_gaussian, - prior -): - source_gaussian.centre = (prior, 1.0) - target_gaussian.take_attributes( - source_gaussian - ) - - assert target_gaussian.centre == (prior, 1.0) - - -def test_tuple_prior( - source_gaussian, - target_gaussian, - prior -): - source_gaussian.centre = (prior, 1.0) - target_gaussian.centre = af.TuplePrior() - target_gaussian.take_attributes( - source_gaussian - ) - - assert target_gaussian.centre == (prior, 1.0) - - -def test_tuple_in_instance( - target_gaussian, - prior -): - # noinspection PyTypeChecker - source_gaussian = af.ex.Gaussian( - centre=(prior, 1.0) - ) - target_gaussian.take_attributes( - source_gaussian - ) - - assert target_gaussian.centre == (prior, 1.0) - - -def test_tuple_in_collection( - source_gaussian, - target_gaussian, - prior -): - source_gaussian.centre = (prior, 1.0) - - source = af.Collection( - gaussian=source_gaussian - ) - target = af.Collection( - gaussian=target_gaussian - ) - - target.take_attributes(source) - assert target.gaussian.centre == (prior, 1.0) - - -def test_tuple_in_instance_in_collection( - target_gaussian, - prior -): - # noinspection PyTypeChecker - source_gaussian = af.ex.Gaussian( - centre=(prior, 1.0) - ) - - source = af.Collection( - gaussian=source_gaussian - ) - target = af.Collection( - gaussian=target_gaussian - ) - - target.take_attributes(source) - assert target.gaussian.centre == (prior, 1.0) - - -def test_source_is_dict( - source_gaussian, - target_gaussian, - prior -): - source = dict( - gaussian=source_gaussian - ) - target = af.Collection( - gaussian=target_gaussian - ) - target.take_attributes(source) - - assert target.gaussian.centre == prior - - -def test_target_is_dict( - source_gaussian, - target_gaussian, - prior -): - source = af.Collection( - collection=af.Collection( - gaussian=source_gaussian - ) - ) - target = af.Collection( - collection=dict( - gaussian=target_gaussian - ) - ) - target.take_attributes(source) - - assert target.collection.gaussian.centre == prior - - -def test_missing_from_source( - target_gaussian, - prior -): - target_gaussian.centre = prior - - target_gaussian.take_attributes( - af.Collection() - ) - assert target_gaussian.centre == prior - - -def test_unlabelled_in_collection( - source_gaussian, - target_gaussian, - prior -): - target = af.Collection( - [target_gaussian] - ) - source = af.Collection( - [source_gaussian] - ) - target.take_attributes( - source - ) - - assert target[0].centre == prior - - -def test_passing_float( - source_gaussian, - target_gaussian -): - source_gaussian.centre = 2.0 - target_gaussian.take_attributes( - source_gaussian - ) - - assert target_gaussian.centre == 2.0 - - -def test_missing_from_origin( - target_gaussian -): - target_gaussian.take_attributes( - af.Collection() - ) - - -def test_limits( - source_gaussian, - target_gaussian -): - source_gaussian.centre = af.TruncatedGaussianPrior( - mean=0, - sigma=1, - lower_limit=-1, - upper_limit=1 - ) - target_gaussian.take_attributes( - source_gaussian - ) - assert target_gaussian.centre.lower_limit == -1 - assert target_gaussian.centre.upper_limit == 1 - - -def test_tuples(): - centre = (0.0, 1.0) - source = af.Model( - af.ex.Gaussian, - centre=centre - ) - target = af.Model( - af.ex.Gaussian - ) - target.take_attributes(source) - assert target.centre == centre +import pytest + +import autofit as af + + +@pytest.fixture( + name="target_gaussian" +) +def make_target_gaussian(): + return af.Model( + af.ex.Gaussian + ) + + +@pytest.fixture( + name="prior" +) +def make_prior(): + return af.UniformPrior() + + +@pytest.fixture( + name="source_gaussian" +) +def make_source_gaussian(prior): + return af.Model( + af.ex.Gaussian, + centre=prior + ) + + +def test_simple( + source_gaussian, + target_gaussian, + prior +): + target_gaussian.take_attributes( + source_gaussian + ) + + assert target_gaussian.centre == prior + + +def test_assertions( + source_gaussian, + target_gaussian +): + target_gaussian.add_assertion( + target_gaussian.centre <= target_gaussian.normalization + ) + + with pytest.raises(AssertionError): + target_gaussian.take_attributes( + source_gaussian + ) + + +def test_assertions_collection( + source_gaussian, + target_gaussian +): + target_gaussian.add_assertion( + target_gaussian.centre <= target_gaussian.normalization + ) + + target_collection = af.Collection( + gaussian=target_gaussian + ) + source_collection = af.Collection( + gaussian=source_gaussian + ) + + with pytest.raises(AssertionError): + target_collection.take_attributes( + source_collection + ) + + +def test_in_collection( + source_gaussian, + target_gaussian, + prior +): + target = af.Collection( + gaussian=target_gaussian + ) + source = af.Collection( + gaussian=source_gaussian + ) + target.take_attributes( + source + ) + + assert target.gaussian.centre == prior + + +def test_tuple( + source_gaussian, + target_gaussian, + prior +): + source_gaussian.centre = (prior, 1.0) + target_gaussian.take_attributes( + source_gaussian + ) + + assert target_gaussian.centre == (prior, 1.0) + + +def test_tuple_prior( + source_gaussian, + target_gaussian, + prior +): + source_gaussian.centre = (prior, 1.0) + target_gaussian.centre = af.TuplePrior() + target_gaussian.take_attributes( + source_gaussian + ) + + assert target_gaussian.centre == (prior, 1.0) + + +def test_tuple_in_instance( + target_gaussian, + prior +): + # noinspection PyTypeChecker + source_gaussian = af.ex.Gaussian( + centre=(prior, 1.0) + ) + target_gaussian.take_attributes( + source_gaussian + ) + + assert target_gaussian.centre == (prior, 1.0) + + +def test_tuple_in_collection( + source_gaussian, + target_gaussian, + prior +): + source_gaussian.centre = (prior, 1.0) + + source = af.Collection( + gaussian=source_gaussian + ) + target = af.Collection( + gaussian=target_gaussian + ) + + target.take_attributes(source) + assert target.gaussian.centre == (prior, 1.0) + + +def test_tuple_in_instance_in_collection( + target_gaussian, + prior +): + # noinspection PyTypeChecker + source_gaussian = af.ex.Gaussian( + centre=(prior, 1.0) + ) + + source = af.Collection( + gaussian=source_gaussian + ) + target = af.Collection( + gaussian=target_gaussian + ) + + target.take_attributes(source) + assert target.gaussian.centre == (prior, 1.0) + + +def test_source_is_dict( + source_gaussian, + target_gaussian, + prior +): + source = dict( + gaussian=source_gaussian + ) + target = af.Collection( + gaussian=target_gaussian + ) + target.take_attributes(source) + + assert target.gaussian.centre == prior + + +def test_target_is_dict( + source_gaussian, + target_gaussian, + prior +): + source = af.Collection( + collection=af.Collection( + gaussian=source_gaussian + ) + ) + target = af.Collection( + collection=dict( + gaussian=target_gaussian + ) + ) + target.take_attributes(source) + + assert target.collection.gaussian.centre == prior + + +def test_missing_from_source( + target_gaussian, + prior +): + target_gaussian.centre = prior + + target_gaussian.take_attributes( + af.Collection() + ) + assert target_gaussian.centre == prior + + +def test_unlabelled_in_collection( + source_gaussian, + target_gaussian, + prior +): + target = af.Collection( + [target_gaussian] + ) + source = af.Collection( + [source_gaussian] + ) + target.take_attributes( + source + ) + + assert target[0].centre == prior + + +def test_passing_float( + source_gaussian, + target_gaussian +): + source_gaussian.centre = 2.0 + target_gaussian.take_attributes( + source_gaussian + ) + + assert target_gaussian.centre == 2.0 + + +def test_missing_from_origin( + target_gaussian +): + target_gaussian.take_attributes( + af.Collection() + ) + + +def test_limits( + source_gaussian, + target_gaussian +): + source_gaussian.centre = af.TruncatedGaussianPrior( + mean=0, + sigma=1, + lower_limit=-1, + upper_limit=1 + ) + target_gaussian.take_attributes( + source_gaussian + ) + assert target_gaussian.centre.lower_limit == -1 + assert target_gaussian.centre.upper_limit == 1 + + +def test_tuples(): + centre = (0.0, 1.0) + source = af.Model( + af.ex.Gaussian, + centre=centre + ) + target = af.Model( + af.ex.Gaussian + ) + target.take_attributes(source) + assert target.centre == centre diff --git a/test_autofit/mapper/model/test_model_instance.py b/test_autofit/mapper/model/test_model_instance.py index 62e21179e..4c2839672 100644 --- a/test_autofit/mapper/model/test_model_instance.py +++ b/test_autofit/mapper/model/test_model_instance.py @@ -1,205 +1,205 @@ -import pytest - -import autofit as af - - -@pytest.fixture(name="mock_components_1") -def make_mock_components_1(): - return af.m.MockComponents() - - -@pytest.fixture(name="mock_components_2") -def make_mock_components_2(): - return af.m.MockComponents() - - -@pytest.fixture(name="instance") -def make_instance(mock_components_1, mock_components_2): - sub = af.ModelInstance() - - instance = af.ModelInstance() - sub.mock_components_1 = mock_components_1 - - instance.mock_components_2 = mock_components_2 - instance.sub = sub - - sub_2 = af.ModelInstance() - sub_2.mock_components_1 = mock_components_1 - - instance.sub.sub = sub_2 - - return instance - - -class TestModelInstance: - def test_iterable(self, instance): - assert len(list(instance)) == 2 - - def test_as_model(self, instance): - model = instance.as_model() - assert isinstance(model, af.ModelMapper) - assert isinstance(model.mock_components_2, af.Model) - assert model.mock_components_2.cls == af.m.MockComponents - - def test_object_for_path(self, instance, mock_components_1, mock_components_2): - assert instance.object_for_path(("mock_components_2",)) is mock_components_2 - assert ( - instance.object_for_path(("sub", "mock_components_1")) is mock_components_1 - ) - assert ( - instance.object_for_path(("sub", "sub", "mock_components_1")) - is mock_components_1 - ) - setattr( - instance.object_for_path(("mock_components_2",)), - "mock_components", - mock_components_1, - ) - assert mock_components_2.mock_components is mock_components_1 - - def test_path_instance_tuples_for_class( - self, instance, mock_components_1, mock_components_2 - ): - result = instance.path_instance_tuples_for_class(af.m.MockComponents) - assert result[0] == (("mock_components_2",), mock_components_2) - assert result[1] == (("sub", "mock_components_1"), mock_components_1) - assert result[2] == (("sub", "sub", "mock_components_1"), mock_components_1) - - def test_simple_model(self): - mapper = af.ModelMapper() - - mapper.mock_class = af.m.MockClassx2 - - model_map = mapper.instance_from_unit_vector( - [1.0, 1.0] - ) - - assert isinstance(model_map.mock_class, af.m.MockClassx2) - assert model_map.mock_class.one == 1.0 - assert model_map.mock_class.two == 2.0 - - def test_two_object_model(self): - mapper = af.ModelMapper() - - mapper.mock_class_1 = af.m.MockClassx2 - mapper.mock_class_2 = af.m.MockClassx2 - - model_map = mapper.instance_from_unit_vector( - [1.0, 0.0, 0.0, 1.0] - ) - - assert isinstance(model_map.mock_class_1, af.m.MockClassx2) - assert isinstance(model_map.mock_class_2, af.m.MockClassx2) - - assert model_map.mock_class_1.one == 1.0 - assert model_map.mock_class_1.two == 0.0 - - assert model_map.mock_class_2.one == 0.0 - assert model_map.mock_class_2.two == 2.0 - - def test_swapped_prior_construction(self): - mapper = af.ModelMapper() - - mapper.mock_class_1 = af.m.MockClassx2 - mapper.mock_class_2 = af.m.MockClassx2 - - # noinspection PyUnresolvedReferences - mapper.mock_class_2.one = mapper.mock_class_1.one - - model_map = mapper.instance_from_unit_vector([1.0, 0.0, 0.0]) - - assert isinstance(model_map.mock_class_1, af.m.MockClassx2) - assert isinstance(model_map.mock_class_2, af.m.MockClassx2) - - assert model_map.mock_class_1.one == 1.0 - assert model_map.mock_class_1.two == 0.0 - - assert model_map.mock_class_2.one == 1.0 - assert model_map.mock_class_2.two == 0.0 - - def test_prior_replacement(self): - mapper = af.ModelMapper() - - mapper.mock_class = af.m.MockClassx2 - - mapper.mock_class.one = af.UniformPrior(100, 200) - - model_map = mapper.instance_from_unit_vector([0.0, 0.0]) - - assert model_map.mock_class.one == 100.0 - - def test_tuple_arg(self): - mapper = af.ModelMapper() - - mapper.mock_profile = af.m.MockClassx3TupleFloat - - model_map = mapper.instance_from_unit_vector([1.0, 0.0, 0.0]) - - assert model_map.mock_profile.one_tuple == (1.0, 0.0) - assert model_map.mock_profile.two == 0.0 - - def test_modify_tuple(self): - mapper = af.ModelMapper() - - mapper.mock_profile = af.m.MockClassx3TupleFloat - - # noinspection PyUnresolvedReferences - mapper.mock_profile.one_tuple.one_tuple_0 = af.UniformPrior(1.0, 10.0) - - model_map = mapper.instance_from_unit_vector([1.0, 1.0, 1.0]) - - assert model_map.mock_profile.one_tuple == (10.0, 2.0) - - def test_match_tuple(self): - mapper = af.ModelMapper() - - mapper.mock_profile = af.m.MockClassx3TupleFloat - - # noinspection PyUnresolvedReferences - mapper.mock_profile.one_tuple.one_tuple_1 = ( - mapper.mock_profile.one_tuple.one_tuple_0 - ) - - model_map = mapper.instance_from_unit_vector([1.0, 0.0]) - - assert model_map.mock_profile.one_tuple == (1.0, 1.0) - assert model_map.mock_profile.two == 0.0 - - -class Child(af.ex.Gaussian): - pass - - -class Child2(af.ex.Gaussian): - pass - - -@pytest.fixture(name="exclude_instance") -def make_excluded_instance(): - return af.ModelInstance( - {"child": Child(), "gaussian": af.ex.Gaussian(), "child2": Child2(),} - ) - - -def test_single_argument(exclude_instance): - model = exclude_instance.as_model(af.ex.Gaussian) - - assert isinstance(model.gaussian, af.Model) - assert isinstance(model.child, af.Model) - assert isinstance(model.child2, af.Model) - - -def test_filter_child(exclude_instance): - model = exclude_instance.as_model(af.ex.Gaussian, excluded_classes=Child) - - assert isinstance(model.gaussian, af.Model) - assert not isinstance(model.child, af.Model) - assert isinstance(model.child2, af.Model) - - -def test_filter_multiple(exclude_instance): - model = exclude_instance.as_model(af.ex.Gaussian, excluded_classes=(Child, Child2),) - - assert isinstance(model.gaussian, af.Model) - assert not isinstance(model.child, af.Model) - assert not isinstance(model.child2, af.Model) +import pytest + +import autofit as af + + +@pytest.fixture(name="mock_components_1") +def make_mock_components_1(): + return af.m.MockComponents() + + +@pytest.fixture(name="mock_components_2") +def make_mock_components_2(): + return af.m.MockComponents() + + +@pytest.fixture(name="instance") +def make_instance(mock_components_1, mock_components_2): + sub = af.ModelInstance() + + instance = af.ModelInstance() + sub.mock_components_1 = mock_components_1 + + instance.mock_components_2 = mock_components_2 + instance.sub = sub + + sub_2 = af.ModelInstance() + sub_2.mock_components_1 = mock_components_1 + + instance.sub.sub = sub_2 + + return instance + + +class TestModelInstance: + def test_iterable(self, instance): + assert len(list(instance)) == 2 + + def test_as_model(self, instance): + model = instance.as_model() + assert isinstance(model, af.ModelMapper) + assert isinstance(model.mock_components_2, af.Model) + assert model.mock_components_2.cls == af.m.MockComponents + + def test_object_for_path(self, instance, mock_components_1, mock_components_2): + assert instance.object_for_path(("mock_components_2",)) is mock_components_2 + assert ( + instance.object_for_path(("sub", "mock_components_1")) is mock_components_1 + ) + assert ( + instance.object_for_path(("sub", "sub", "mock_components_1")) + is mock_components_1 + ) + setattr( + instance.object_for_path(("mock_components_2",)), + "mock_components", + mock_components_1, + ) + assert mock_components_2.mock_components is mock_components_1 + + def test_path_instance_tuples_for_class( + self, instance, mock_components_1, mock_components_2 + ): + result = instance.path_instance_tuples_for_class(af.m.MockComponents) + assert result[0] == (("mock_components_2",), mock_components_2) + assert result[1] == (("sub", "mock_components_1"), mock_components_1) + assert result[2] == (("sub", "sub", "mock_components_1"), mock_components_1) + + def test_simple_model(self): + mapper = af.ModelMapper() + + mapper.mock_class = af.m.MockClassx2 + + model_map = mapper.instance_from_unit_vector( + [1.0, 1.0] + ) + + assert isinstance(model_map.mock_class, af.m.MockClassx2) + assert model_map.mock_class.one == 1.0 + assert model_map.mock_class.two == 2.0 + + def test_two_object_model(self): + mapper = af.ModelMapper() + + mapper.mock_class_1 = af.m.MockClassx2 + mapper.mock_class_2 = af.m.MockClassx2 + + model_map = mapper.instance_from_unit_vector( + [1.0, 0.0, 0.0, 1.0] + ) + + assert isinstance(model_map.mock_class_1, af.m.MockClassx2) + assert isinstance(model_map.mock_class_2, af.m.MockClassx2) + + assert model_map.mock_class_1.one == 1.0 + assert model_map.mock_class_1.two == 0.0 + + assert model_map.mock_class_2.one == 0.0 + assert model_map.mock_class_2.two == 2.0 + + def test_swapped_prior_construction(self): + mapper = af.ModelMapper() + + mapper.mock_class_1 = af.m.MockClassx2 + mapper.mock_class_2 = af.m.MockClassx2 + + # noinspection PyUnresolvedReferences + mapper.mock_class_2.one = mapper.mock_class_1.one + + model_map = mapper.instance_from_unit_vector([1.0, 0.0, 0.0]) + + assert isinstance(model_map.mock_class_1, af.m.MockClassx2) + assert isinstance(model_map.mock_class_2, af.m.MockClassx2) + + assert model_map.mock_class_1.one == 1.0 + assert model_map.mock_class_1.two == 0.0 + + assert model_map.mock_class_2.one == 1.0 + assert model_map.mock_class_2.two == 0.0 + + def test_prior_replacement(self): + mapper = af.ModelMapper() + + mapper.mock_class = af.m.MockClassx2 + + mapper.mock_class.one = af.UniformPrior(100, 200) + + model_map = mapper.instance_from_unit_vector([0.0, 0.0]) + + assert model_map.mock_class.one == 100.0 + + def test_tuple_arg(self): + mapper = af.ModelMapper() + + mapper.mock_profile = af.m.MockClassx3TupleFloat + + model_map = mapper.instance_from_unit_vector([1.0, 0.0, 0.0]) + + assert model_map.mock_profile.one_tuple == (1.0, 0.0) + assert model_map.mock_profile.two == 0.0 + + def test_modify_tuple(self): + mapper = af.ModelMapper() + + mapper.mock_profile = af.m.MockClassx3TupleFloat + + # noinspection PyUnresolvedReferences + mapper.mock_profile.one_tuple.one_tuple_0 = af.UniformPrior(1.0, 10.0) + + model_map = mapper.instance_from_unit_vector([1.0, 1.0, 1.0]) + + assert model_map.mock_profile.one_tuple == (10.0, 2.0) + + def test_match_tuple(self): + mapper = af.ModelMapper() + + mapper.mock_profile = af.m.MockClassx3TupleFloat + + # noinspection PyUnresolvedReferences + mapper.mock_profile.one_tuple.one_tuple_1 = ( + mapper.mock_profile.one_tuple.one_tuple_0 + ) + + model_map = mapper.instance_from_unit_vector([1.0, 0.0]) + + assert model_map.mock_profile.one_tuple == (1.0, 1.0) + assert model_map.mock_profile.two == 0.0 + + +class Child(af.ex.Gaussian): + pass + + +class Child2(af.ex.Gaussian): + pass + + +@pytest.fixture(name="exclude_instance") +def make_excluded_instance(): + return af.ModelInstance( + {"child": Child(), "gaussian": af.ex.Gaussian(), "child2": Child2(),} + ) + + +def test_single_argument(exclude_instance): + model = exclude_instance.as_model(af.ex.Gaussian) + + assert isinstance(model.gaussian, af.Model) + assert isinstance(model.child, af.Model) + assert isinstance(model.child2, af.Model) + + +def test_filter_child(exclude_instance): + model = exclude_instance.as_model(af.ex.Gaussian, excluded_classes=Child) + + assert isinstance(model.gaussian, af.Model) + assert not isinstance(model.child, af.Model) + assert isinstance(model.child2, af.Model) + + +def test_filter_multiple(exclude_instance): + model = exclude_instance.as_model(af.ex.Gaussian, excluded_classes=(Child, Child2),) + + assert isinstance(model.gaussian, af.Model) + assert not isinstance(model.child, af.Model) + assert not isinstance(model.child2, af.Model) diff --git a/test_autofit/mapper/model/test_model_mapper.py b/test_autofit/mapper/model/test_model_mapper.py index b602ddfdb..3a7433545 100644 --- a/test_autofit/mapper/model/test_model_mapper.py +++ b/test_autofit/mapper/model/test_model_mapper.py @@ -1,797 +1,797 @@ -import random - -import numpy as np -import pytest - -import autofit as af - - -@pytest.fixture(name="initial_model") -def make_initial_model(): - return af.Model(af.m.MockClassx2) - - -class TestParamNames: - def test_has_prior(self): - prior_model = af.Model(af.m.MockClassx2) - assert "one" == prior_model.name_for_prior(prior_model.one) - - -class ExtendedMockClass(af.m.MockClassx2): - def __init__(self, one, two, three): - super().__init__(one, two) - self.three = three - - -# noinspection PyUnresolvedReferences -class TestRegression: - def test_set_tuple_instance(self): - mm = af.ModelMapper() - mm.mock_cls = af.m.MockChildTuplex2 - - assert mm.prior_count == 4 - - mm.mock_cls.tup_0 = 0.0 - mm.mock_cls.tup_1 = 0.0 - - assert mm.prior_count == 2 - - def test_get_tuple_instances(self): - mm = af.ModelMapper() - mm.mock_cls = af.m.MockChildTuplex2 - - assert isinstance(mm.mock_cls.tup_0, af.Prior) - assert isinstance(mm.mock_cls.tup_1, af.Prior) - - def test_tuple_parameter(self, mapper): - mapper.with_float = af.m.MockWithFloat - mapper.with_tuple = af.m.MockWithTuple - - assert mapper.prior_count == 3 - - mapper.with_tuple.tup_0 = mapper.with_float.value - - assert mapper.prior_count == 2 - - def test_parameter_name_ordering(self): - mm = af.ModelMapper() - mm.one = af.m.MockClassRelativeWidth - mm.two = af.m.MockClassRelativeWidth - - mm.one.one.id = mm.two.three.id + 1 - - assert mm.model_component_and_parameter_names == [ - "one.two", - "one.three", - "two.one", - "two.two", - "two.three", - "one.one", - ] - - def test_parameter_name_list(self): - mm = af.ModelMapper() - mm.one = af.m.MockClassRelativeWidth - mm.two = af.m.MockClassRelativeWidth - - assert mm.parameter_names == ["one", "two", "three", "one", "two", "three"] - - def test_parameter_name_distinction(self): - mm = af.ModelMapper() - mm.ls = af.Collection( - [ - af.Model(af.m.MockClassRelativeWidth), - af.Model(af.m.MockClassRelativeWidth), - ] - ) - assert mm.model_component_and_parameter_names == [ - "ls.0.one", - "ls.0.two", - "ls.0.three", - "ls.1.one", - "ls.1.two", - "ls.1.three", - ] - - def test__parameter_labels(self): - mm = af.ModelMapper() - mm.one = af.m.MockClassRelativeWidth - mm.two = af.m.MockClassx2 - - assert mm.parameter_labels == [ - "one_label", - "two_label", - "three_label", - "one_label", - "two_label", - ] - - def test__superscripts(self): - mm = af.ModelMapper() - mm.one = af.m.MockClassRelativeWidth - mm.two = af.m.MockClassx2NoSuperScript - - assert mm.superscripts == ["r", "r", "r", "two", "two"] - - model = af.Collection(group=mm) - - assert model.superscripts == ["r", "r", "r", "two", "two"] - - def test__superscript_overwrite_via_config(self): - mm = af.ModelMapper() - mm.one = af.m.MockClassRelativeWidth - mm.two = af.m.MockClassx2NoSuperScript - mm.three = af.m.MockClassx3 - - assert mm.superscripts_overwrite_via_config == [ - "r", - "r", - "r", - "", - "", - "", - "", - "", - ] - - def test__parameter_labels_with_superscripts_latex(self): - mm = af.ModelMapper() - mm.one = af.m.MockClassRelativeWidth - mm.two = af.m.MockClassx2NoSuperScript - - assert mm.parameter_labels_with_superscripts == [ - r"one_label^{\rm r}", - r"two_label^{\rm r}", - r"three_label^{\rm r}", - r"one_label^{\rm two}", - r"two_label^{\rm two}", - ] - - assert mm.parameter_labels_with_superscripts_latex == [ - r"$one_label^{\rm r}$", - r"$two_label^{\rm r}$", - r"$three_label^{\rm r}$", - r"$one_label^{\rm two}$", - r"$two_label^{\rm two}$", - ] - - def test_name_for_prior(self): - ls = af.Collection( - [ - af.m.MockClassRelativeWidth(1, 2, 3), - af.Model(af.m.MockClassRelativeWidth), - ] - ) - assert ls.name_for_prior(ls[1].one) == "1_one" - - def test_tuple_parameter_float(self, mapper): - mapper.with_float = af.m.MockWithFloat - mapper.with_tuple = af.m.MockWithTuple - - mapper.with_float.value = 1.0 - - assert mapper.prior_count == 2 - - mapper.with_tuple.tup_0 = mapper.with_float.value - - assert mapper.prior_count == 1 - - instance = mapper.instance_from_unit_vector([0.0]) - - assert instance.with_float.value == 1 - assert instance.with_tuple.tup == (1.0, 0.0) - - -class TestModelingMapper: - def test__argument_extraction(self): - mapper = af.ModelMapper() - mapper.mock_class = af.m.MockClassx2 - assert 1 == len(mapper.prior_model_tuples) - - assert len(mapper.prior_tuples_ordered_by_id) == 2 - - def test_attribution(self): - mapper = af.ModelMapper() - - mapper.mock_class = af.m.MockClassx2 - - assert hasattr(mapper, "mock_class") - assert hasattr(mapper.mock_class, "one") - - def test_tuple_arg(self): - mapper = af.ModelMapper() - - mapper.mock_profile = af.m.MockClassx3TupleFloat - - assert 3 == len(mapper.prior_tuples_ordered_by_id) - - -class TestInstances: - def test_attribute(self): - mm = af.ModelMapper() - mm.cls_1 = af.m.MockClassx2 - - assert 1 == len(mm.prior_model_tuples) - assert isinstance(mm.cls_1, af.Model) - - def test__instance_from_unit_vector(self): - mapper = af.ModelMapper(mock_cls=af.m.MockClassx2Tuple) - - model_map = mapper.instance_from_unit_vector([1.0, 1.0]) - - assert model_map.mock_cls.one_tuple == (1.0, 2.0) - - def test__instance_from_vector(self): - mapper = af.ModelMapper(mock_cls=af.m.MockClassx2Tuple) - - model_map = mapper.instance_from_vector([1.0, 0.5]) - - assert model_map.mock_cls.one_tuple == (1.0, 0.5) - - def test_inheritance(self): - mapper = af.ModelMapper(mock_cls=af.m.MockChildTuplex2) - - model_map = mapper.instance_from_unit_vector([1.0, 1.0, 1.0, 1.0]) - - assert model_map.mock_cls.tup == (1.0, 1.0) - - def test__multiple_classes(self): - mapper = af.ModelMapper( - mock_child_cls_0=af.m.MockChildTuplex3, - mock_child_cls_1=af.m.MockChildTuplex2, - mock_child_cls_2=af.m.MockChildTuplex2, - mock_child_tuple=af.m.MockChildTuple, - mock_child_cls_3=af.m.MockChildTuplex3, - ) - - model_map = mapper.instance_from_unit_vector( - [0.5 for _ in range(len(mapper.prior_tuples_ordered_by_id))] - ) - - assert isinstance(model_map.mock_child_cls_1, af.m.MockChildTuplex2) - assert isinstance(model_map.mock_child_cls_2, af.m.MockChildTuplex2) - assert isinstance(model_map.mock_child_tuple, af.m.MockChildTuple) - - assert isinstance(model_map.mock_child_cls_0, af.m.MockChildTuplex3) - assert isinstance(model_map.mock_child_cls_3, af.m.MockChildTuplex3) - - def test__in_order_of_class_constructor(self): - mapper = af.ModelMapper(mock_cls_0=af.m.MockChildTuplex2) - - model_map = mapper.instance_from_unit_vector([0.25, 0.5, 0.75, 1.0]) - - assert model_map.mock_cls_0.tup == (0.25, 0.5) - assert model_map.mock_cls_0.one == 1.5 - assert model_map.mock_cls_0.two == 2.0 - - mapper = af.ModelMapper( - mock_cls_0=af.m.MockChildTuplex2, - mock_cls_1=af.m.MockChildTuple, - mock_cls_2=af.m.MockChildTuplex2, - ) - - model_map = mapper.instance_from_unit_vector( - [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] - ) - - assert model_map.mock_cls_0.tup == pytest.approx((0.1, 0.2)) - assert model_map.mock_cls_0.one == pytest.approx(0.6) - assert model_map.mock_cls_0.two == pytest.approx(0.8) - - assert model_map.mock_cls_1.tup == pytest.approx((0.5, 0.6)) - - assert model_map.mock_cls_2.tup == pytest.approx((0.7, 0.8)) - assert model_map.mock_cls_2.one == pytest.approx(1.8) - assert model_map.mock_cls_2.two == pytest.approx(2.0) - - def test__check_order_for_different_unit_values(self): - mapper = af.ModelMapper( - mock_cls_0=af.m.MockChildTuplex2, - mock_cls_1=af.m.MockChildTuple, - mock_cls_2=af.m.MockChildTuplex2, - ) - - mapper.mock_cls_0.tup.tup_0 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_0.tup.tup_1 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_0.one = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_0.two = af.UniformPrior(0.0, 1.0) - - mapper.mock_cls_1.tup.tup_0 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_1.tup.tup_1 = af.UniformPrior(0.0, 1.0) - - mapper.mock_cls_2.tup.tup_0 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_2.tup.tup_1 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_2.one = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_2.two = af.UniformPrior(0.0, 1.0) - - model_map = mapper.instance_from_unit_vector( - [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] - ) - - assert model_map.mock_cls_0.tup == pytest.approx((0.1, 0.2)) - assert model_map.mock_cls_0.one == pytest.approx(0.3) - assert model_map.mock_cls_0.two == pytest.approx(0.4) - - assert model_map.mock_cls_1.tup == pytest.approx((0.5, 0.6)) - - assert model_map.mock_cls_2.tup == pytest.approx((0.7, 0.8)) - assert model_map.mock_cls_2.one == pytest.approx(0.9) - assert model_map.mock_cls_2.two == pytest.approx(1.0) - - def test__check_order_for_different_unit_values_and_set_priors_equal_to_one_another( - self, - ): - mapper = af.ModelMapper( - mock_cls_0=af.m.MockChildTuplex2, - mock_cls_1=af.m.MockChildTuple, - mock_cls_2=af.m.MockChildTuplex2, - ) - - mapper.mock_cls_0.tup.tup_0 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_0.tup.tup_1 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_0.one = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_0.two = af.UniformPrior(0.0, 1.0) - - mapper.mock_cls_1.tup.tup_0 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_1.tup.tup_1 = af.UniformPrior(0.0, 1.0) - - mapper.mock_cls_2.tup.tup_0 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_2.tup.tup_1 = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_2.one = af.UniformPrior(0.0, 1.0) - mapper.mock_cls_2.two = af.UniformPrior(0.0, 1.0) - - mapper.mock_cls_0.one = mapper.mock_cls_0.two - mapper.mock_cls_2.tup.tup_1 = mapper.mock_cls_1.tup.tup_1 - - model_map = mapper.instance_from_unit_vector( - [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] - ) - - assert model_map.mock_cls_0.tup == pytest.approx((0.2, 0.3)) - assert model_map.mock_cls_0.one == pytest.approx(0.4) - assert model_map.mock_cls_0.two == pytest.approx(0.4) - - assert model_map.mock_cls_1.tup == pytest.approx((0.5, 0.6)) - - assert model_map.mock_cls_2.tup == pytest.approx((0.7, 0.6)) - assert model_map.mock_cls_2.one == pytest.approx(0.8) - assert model_map.mock_cls_2.two == pytest.approx(0.9) - - def test__instance_from_vector__check_order(self): - mapper = af.ModelMapper( - mock_cls_0=af.m.MockChildTuplex2, - mock_cls_1=af.m.MockChildTuple, - mock_cls_2=af.m.MockChildTuplex2, - ) - - model_map = mapper.instance_from_vector( - [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] - ) - - assert model_map.mock_cls_0.tup == (0.1, 0.2) - assert model_map.mock_cls_0.one == 0.3 - assert model_map.mock_cls_0.two == 0.4 - - assert model_map.mock_cls_1.tup == (0.5, 0.6) - - assert model_map.mock_cls_2.tup == (0.7, 0.8) - assert model_map.mock_cls_2.one == 0.9 - assert model_map.mock_cls_2.two == 1.0 - - def test__instance_from_prior_medians(self): - mapper = af.ModelMapper(mock_cls_0=af.m.MockChildTuplex2) - - model_map = mapper.instance_from_prior_medians() - - model_2 = mapper.instance_from_unit_vector([0.5, 0.5, 0.5, 0.5]) - - assert model_map.mock_cls_0.tup == model_2.mock_cls_0.tup == (0.5, 0.5) - assert model_map.mock_cls_0.one == model_2.mock_cls_0.one == 1.0 - assert model_map.mock_cls_0.two == model_2.mock_cls_0.two == 1.0 - - mapper = af.ModelMapper( - mock_cls_0=af.m.MockChildTuplex2, - mock_cls_1=af.m.MockChildTuple, - mock_cls_2=af.m.MockChildTuplex2, - ) - - model_map = mapper.instance_from_prior_medians() - - model_2 = mapper.instance_from_unit_vector( - [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] - ) - - assert model_map.mock_cls_0.tup == model_2.mock_cls_0.tup == (0.5, 0.5) - assert model_map.mock_cls_0.one == model_2.mock_cls_0.one == 1.0 - assert model_map.mock_cls_0.two == model_2.mock_cls_0.two == 1.0 - - assert model_map.mock_cls_1.tup == model_2.mock_cls_1.tup == (0.5, 0.5) - - assert model_map.mock_cls_2.tup == model_2.mock_cls_2.tup == (0.5, 0.5) - assert model_map.mock_cls_2.one == model_2.mock_cls_2.one == 1.0 - assert model_map.mock_cls_2.two == model_2.mock_cls_2.two == 1.0 - - def test__from_prior_medians__one_model__set_one_parameter_to_another(self): - mapper = af.ModelMapper(mock_cls_0=af.m.MockChildTuplex2) - - mapper.mock_cls_0.one = mapper.mock_cls_0.two - - model_map = mapper.instance_from_prior_medians() - - model_2 = mapper.instance_from_unit_vector([0.5, 0.5, 0.5]) - - assert model_map.mock_cls_0.tup == model_2.mock_cls_0.tup == (0.5, 0.5) - assert model_map.mock_cls_0.one == model_2.mock_cls_0.one == 1.0 - assert model_map.mock_cls_0.two == model_2.mock_cls_0.two == 1.0 - - def test_log_prior_list_from_vector(self): - mapper = af.ModelMapper() - mapper.mock_class = af.Model(af.m.MockClassx2) - mapper.mock_class.one = af.GaussianPrior(mean=1.0, sigma=2.0) - mapper.mock_class.two = af.LogUniformPrior(lower_limit=1e-8, upper_limit=10.0) - - log_prior_list = mapper.log_prior_list_from_vector(vector=[0.0, 5.0]) - - # Density-form log-priors (PyAutoLabs/PyAutoFit#1266): - # Gaussian(mean=1, sigma=2) at value=0: -(0-1)**2 / (2*4) = -0.125 - # LogUniform(...) at value=5: -log(5) - assert log_prior_list[0] == -0.125 - assert log_prior_list[1] == pytest.approx(-np.log(5.0), 1.0e-12) - - def test_random_unit_vector_within_limits(self): - mapper = af.ModelMapper() - mapper.mock_class = af.Model(af.m.MockClassx2) - - random.seed(1) - - assert mapper.random_unit_vector_within_limits( - lower_limit=0.0, upper_limit=1.0 - ) == pytest.approx([0.13436, 0.84743], 1.0e-4) - - assert mapper.random_unit_vector_within_limits( - lower_limit=0.2, upper_limit=0.8 - ) == pytest.approx([0.65826, 0.35304], 1.0e-4) - - def test_random_vector_from_prior_within_limits(self): - random.seed(1) - - mapper = af.ModelMapper() - mapper.mock_class = af.Model(af.m.MockClassx2) - - vector = mapper.random_vector_from_priors_within_limits( - lower_limit=0.499999, upper_limit=0.500001 - ) - - assert vector == pytest.approx([0.5, 1.0], 1.0e-3) - - vector = mapper.random_vector_from_priors_within_limits( - lower_limit=0.899999, upper_limit=0.900001 - ) - - assert vector == pytest.approx([0.9, 1.8], 1.0e-3) - - vector = mapper.random_vector_from_priors_within_limits( - lower_limit=0.2, upper_limit=0.3 - ) - - assert vector == pytest.approx([0.268, 0.402], abs=0.1) - - def test_random_vector_from_prior(self): - mapper = af.Collection(mock_class=af.Model(af.m.MockClassx2)) - - random.seed(1) - - assert mapper.random_vector_from_priors == pytest.approx( - [0.1343, 1.6948], 1.0e-2 - ) - assert mapper.random_vector_from_priors == pytest.approx( - [0.7637, 0.5101], 1.0e-2 - ) - - # By default, this seeded random will draw a value < -0.15, which is below the lower limit below. This - # test ensures that this value is resampled to the next draw, which is above 0.15 - - mapper = af.Collection( - mock_class=af.Model( - af.m.MockClassx2, one=af.UniformPrior(lower_limit=0.15, upper_limit=1.0) - ) - ) - - mapper.mock_class.one.lower_limit = 0.15 - - assert mapper.random_vector_from_priors == pytest.approx( - [0.5711, 0.8989], 1.0e-2 - ) - - def test_vector_from_prior_medians(self): - mapper = af.ModelMapper() - mapper.mock_class = af.Model(af.m.MockClassx2) - - assert mapper.physical_values_from_prior_medians == [0.5, 1.0] - - -class TestUtility: - def test_prior_prior_model_dict(self): - mapper = af.ModelMapper(mock_class=af.m.MockClassx2) - - assert len(mapper.prior_prior_model_dict) == 2 - assert ( - mapper.prior_prior_model_dict[mapper.prior_tuples_ordered_by_id[0][1]].cls - == af.m.MockClassx2 - ) - assert ( - mapper.prior_prior_model_dict[mapper.prior_tuples_ordered_by_id[1][1]].cls - == af.m.MockClassx2 - ) - - def test_name_for_prior(self): - mapper = af.ModelMapper(mock_class=af.m.MockClassx2) - - assert mapper.name_for_prior(mapper.priors[0]) == "mock_class_one" - assert mapper.name_for_prior(mapper.priors[1]) == "mock_class_two" - - -class TestPriorReplacement: - def test_prior_replacement(self): - mapper = af.ModelMapper(mock_class=af.m.MockClassx2) - result = mapper.mapper_from_prior_means([10, 5]) - - assert isinstance(result.mock_class.one, af.TruncatedGaussianPrior) - assert {prior.id for prior in mapper.priors} == { - prior.id for prior in result.priors - } - - def test_replace_priors_with_gaussians_from_tuples(self): - mapper = af.ModelMapper(mock_class=af.m.MockClassx2) - result = mapper.mapper_from_prior_means([10, 5]) - - assert isinstance(result.mock_class.one, af.TruncatedGaussianPrior) - - def test_replacing_priors_for_profile(self): - mapper = af.ModelMapper(mock_class=af.m.MockClassx3TupleFloat) - result = mapper.mapper_from_prior_means([10, 5, 5]) - - assert isinstance( - result.mock_class.one_tuple.unique_prior_tuples[0][1], af.TruncatedGaussianPrior - ) - assert isinstance( - result.mock_class.one_tuple.unique_prior_tuples[1][1], af.TruncatedGaussianPrior - ) - assert isinstance(result.mock_class.two, af.TruncatedGaussianPrior) - - def test_replace_priors_for_two_classes(self): - mapper = af.ModelMapper(one=af.m.MockClassx2, two=af.m.MockClassx2) - - result = mapper.mapper_from_prior_means([1, 2, 3, 4]) - - assert result.one.one.mean == 1 - assert result.one.two.mean == 2 - assert result.two.one.mean == 3 - assert result.two.two.mean == 4 - - def test__mapper_from_uniform_floats(self): - mapper = af.ModelMapper(mock_class=af.m.MockClassx2) - result = mapper.mapper_from_uniform_floats([10, 5], b=1.0) - - assert isinstance(result.mock_class.one, af.UniformPrior) - assert {prior.id for prior in mapper.priors} == { - prior.id for prior in result.priors - } - - -class TestArguments: - def test_same_argument_name(self): - mapper = af.ModelMapper() - - mapper.one = af.Model(af.m.MockClassx2) - mapper.two = af.Model(af.m.MockClassx2) - - instance = mapper.instance_from_vector([0.1, 0.2, 0.3, 0.4]) - - assert instance.one.one == 0.1 - assert instance.one.two == 0.2 - assert instance.two.one == 0.3 - assert instance.two.two == 0.4 - - -class TestIndependentPriorModel: - def test_associate_prior_model(self): - prior_model = af.Model(af.m.MockClassx2) - - mapper = af.ModelMapper() - - mapper.prior_model = prior_model - - assert len(mapper.prior_model_tuples) == 1 - - instance = mapper.instance_from_vector([0.1, 0.2]) - - assert instance.prior_model.one == 0.1 - assert instance.prior_model.two == 0.2 - - -@pytest.fixture(name="list_prior_model") -def make_list_prior_model(): - return af.Collection([af.Model(af.m.MockClassx2), af.Model(af.m.MockClassx2)]) - - -class TestListPriorModel: - def test_instance_from_vector(self, list_prior_model): - mapper = af.ModelMapper() - mapper.list = list_prior_model - - instance = mapper.instance_from_vector([0.1, 0.2, 0.3, 0.4]) - - assert isinstance(instance.list, af.ModelInstance) - print(instance.list.items) - assert len(instance.list) == 2 - assert instance.list[0].one == 0.1 - assert instance.list[0].two == 0.2 - assert instance.list[1].one == 0.3 - assert instance.list[1].two == 0.4 - - def test_prior_results_for_gaussian_tuples(self, list_prior_model): - mapper = af.ModelMapper() - mapper.list = list_prior_model - - gaussian_mapper = mapper.mapper_from_prior_means( - [1, 2, 3, 4] - ) - - assert len(gaussian_mapper.list) == 2 - assert gaussian_mapper.list[0].one.mean == 1 - assert gaussian_mapper.list[0].two.mean == 2 - assert gaussian_mapper.list[1].one.mean == 3 - assert gaussian_mapper.list[1].two.mean == 4 - assert gaussian_mapper.list[0].one.sigma == 1 - assert gaussian_mapper.list[0].two.sigma == 2 - assert gaussian_mapper.list[1].one.sigma == 1 - assert gaussian_mapper.list[1].two.sigma == 2 - - def test_prior_results_for_gaussian_tuples__include_override_from_width_file( - self, list_prior_model - ): - mapper = af.ModelMapper() - mapper.list = list_prior_model - - gaussian_mapper = mapper.mapper_from_prior_means( - [1, 2, 3, 4] - ) - - assert len(gaussian_mapper.list) == 2 - assert gaussian_mapper.list[0].one.mean == 1 - assert gaussian_mapper.list[0].two.mean == 2 - assert gaussian_mapper.list[1].one.mean == 3 - assert gaussian_mapper.list[1].two.mean == 4 - assert gaussian_mapper.list[0].one.sigma == 1 - assert gaussian_mapper.list[0].two.sigma == 2 - assert gaussian_mapper.list[1].one.sigma == 1 - assert gaussian_mapper.list[1].two.sigma == 2 - - def test_automatic_boxing(self): - mapper = af.ModelMapper() - mapper.list = [af.Model(af.m.MockClassx2), af.Model(af.m.MockClassx2)] - - assert isinstance(mapper.list, af.Collection) - - -@pytest.fixture(name="mock_with_instance") -def make_mock_with_instance(): - mock_with_instance = af.Model(af.m.MockClassx2) - mock_with_instance.one = 3.0 - return mock_with_instance - - -class Testinstance: - def test__instance_prior_count(self, mock_with_instance): - mapper = af.ModelMapper() - mapper.mock_class = mock_with_instance - - assert len(mapper.unique_prior_tuples) == 1 - - def test__retrieve_instances(self, mock_with_instance): - assert len(mock_with_instance.instance_tuples) == 1 - - def test_instance_prior_reconstruction(self, mock_with_instance): - mapper = af.ModelMapper() - mapper.mock_class = mock_with_instance - - instance = mapper.instance_for_arguments({mock_with_instance.two: 0.5}) - - assert instance.mock_class.one == 3 - assert instance.mock_class.two == 0.5 - - def test__instance_in_config(self): - mapper = af.ModelMapper() - - mock_with_instance = af.Model(af.m.MockClassx2Instance, one=3) - - mapper.mock_class = mock_with_instance - - instance = mapper.instance_for_arguments({mock_with_instance.two: 0.5}) - - assert instance.mock_class.one == 3 - assert instance.mock_class.two == 0.5 - - def test__set_float(self): - prior_model = af.Model(af.m.MockClassx2) - prior_model.one = 3 - prior_model.two = 4.0 - assert prior_model.one == 3 - assert prior_model.two == 4.0 - - def test__list_prior_model_instances(self, mapper): - prior_model = af.Model(af.m.MockClassx2) - prior_model.one = 3.0 - prior_model.two = 4.0 - - mapper.mock_list = [prior_model] - assert isinstance(mapper.mock_list, af.Collection) - assert len(mapper.instance_tuples) == 2 - - def test__set_for_tuple_prior(self): - prior_model = af.Model(af.m.MockChildTuplex3) - prior_model.tup_0 = 1.0 - prior_model.tup_1 = 2.0 - prior_model.one = 1.0 - prior_model.two = 1.0 - prior_model.three = 1.0 - instance = prior_model.instance_for_arguments({}) - assert instance.tup == (1.0, 2.0) - - -@pytest.fixture(name="mock_config") -def make_mock_config(): - return - - -@pytest.fixture(name="mapper_with_one") -def make_mapper_with_one(): - mapper = af.ModelMapper() - mapper.one = af.Model(af.m.MockClassx2) - return mapper - - -@pytest.fixture(name="mapper_with_list") -def make_mapper_with_list(): - mapper = af.ModelMapper() - mapper.list = [af.Model(af.m.MockClassx2), af.Model(af.m.MockClassx2)] - return mapper - - -class TestGaussianWidthConfig: - def test_relative_widths(self, mapper): - mapper.relative_width = af.m.MockClassRelativeWidth - new_mapper = mapper.mapper_from_prior_means([1, 1, 1]) - - assert new_mapper.relative_width.one.mean == 1.0 - assert new_mapper.relative_width.one.sigma == 0.1 - - assert new_mapper.relative_width.two.mean == 1.0 - assert new_mapper.relative_width.two.sigma == 0.5 - - assert new_mapper.relative_width.three.mean == 1.0 - assert new_mapper.relative_width.three.sigma == 1.0 - - def test_prior_classes(self, mapper_with_one): - assert mapper_with_one.prior_class_dict == { - mapper_with_one.one.one: af.m.MockClassx2, - mapper_with_one.one.two: af.m.MockClassx2, - } - - def test_prior_classes_list(self, mapper_with_list): - assert mapper_with_list.prior_class_dict == { - mapper_with_list.list[0].one: af.m.MockClassx2, - mapper_with_list.list[0].two: af.m.MockClassx2, - mapper_with_list.list[1].one: af.m.MockClassx2, - mapper_with_list.list[1].two: af.m.MockClassx2, - } - - def test_no_override(self): - mapper = af.ModelMapper() - - mapper.one = af.Model(af.m.MockClassx2) - - af.ModelMapper() - - assert mapper.one is not None +import random + +import numpy as np +import pytest + +import autofit as af + + +@pytest.fixture(name="initial_model") +def make_initial_model(): + return af.Model(af.m.MockClassx2) + + +class TestParamNames: + def test_has_prior(self): + prior_model = af.Model(af.m.MockClassx2) + assert "one" == prior_model.name_for_prior(prior_model.one) + + +class ExtendedMockClass(af.m.MockClassx2): + def __init__(self, one, two, three): + super().__init__(one, two) + self.three = three + + +# noinspection PyUnresolvedReferences +class TestRegression: + def test_set_tuple_instance(self): + mm = af.ModelMapper() + mm.mock_cls = af.m.MockChildTuplex2 + + assert mm.prior_count == 4 + + mm.mock_cls.tup_0 = 0.0 + mm.mock_cls.tup_1 = 0.0 + + assert mm.prior_count == 2 + + def test_get_tuple_instances(self): + mm = af.ModelMapper() + mm.mock_cls = af.m.MockChildTuplex2 + + assert isinstance(mm.mock_cls.tup_0, af.Prior) + assert isinstance(mm.mock_cls.tup_1, af.Prior) + + def test_tuple_parameter(self, mapper): + mapper.with_float = af.m.MockWithFloat + mapper.with_tuple = af.m.MockWithTuple + + assert mapper.prior_count == 3 + + mapper.with_tuple.tup_0 = mapper.with_float.value + + assert mapper.prior_count == 2 + + def test_parameter_name_ordering(self): + mm = af.ModelMapper() + mm.one = af.m.MockClassRelativeWidth + mm.two = af.m.MockClassRelativeWidth + + mm.one.one.id = mm.two.three.id + 1 + + assert mm.model_component_and_parameter_names == [ + "one.two", + "one.three", + "two.one", + "two.two", + "two.three", + "one.one", + ] + + def test_parameter_name_list(self): + mm = af.ModelMapper() + mm.one = af.m.MockClassRelativeWidth + mm.two = af.m.MockClassRelativeWidth + + assert mm.parameter_names == ["one", "two", "three", "one", "two", "three"] + + def test_parameter_name_distinction(self): + mm = af.ModelMapper() + mm.ls = af.Collection( + [ + af.Model(af.m.MockClassRelativeWidth), + af.Model(af.m.MockClassRelativeWidth), + ] + ) + assert mm.model_component_and_parameter_names == [ + "ls.0.one", + "ls.0.two", + "ls.0.three", + "ls.1.one", + "ls.1.two", + "ls.1.three", + ] + + def test__parameter_labels(self): + mm = af.ModelMapper() + mm.one = af.m.MockClassRelativeWidth + mm.two = af.m.MockClassx2 + + assert mm.parameter_labels == [ + "one_label", + "two_label", + "three_label", + "one_label", + "two_label", + ] + + def test__superscripts(self): + mm = af.ModelMapper() + mm.one = af.m.MockClassRelativeWidth + mm.two = af.m.MockClassx2NoSuperScript + + assert mm.superscripts == ["r", "r", "r", "two", "two"] + + model = af.Collection(group=mm) + + assert model.superscripts == ["r", "r", "r", "two", "two"] + + def test__superscript_overwrite_via_config(self): + mm = af.ModelMapper() + mm.one = af.m.MockClassRelativeWidth + mm.two = af.m.MockClassx2NoSuperScript + mm.three = af.m.MockClassx3 + + assert mm.superscripts_overwrite_via_config == [ + "r", + "r", + "r", + "", + "", + "", + "", + "", + ] + + def test__parameter_labels_with_superscripts_latex(self): + mm = af.ModelMapper() + mm.one = af.m.MockClassRelativeWidth + mm.two = af.m.MockClassx2NoSuperScript + + assert mm.parameter_labels_with_superscripts == [ + r"one_label^{\rm r}", + r"two_label^{\rm r}", + r"three_label^{\rm r}", + r"one_label^{\rm two}", + r"two_label^{\rm two}", + ] + + assert mm.parameter_labels_with_superscripts_latex == [ + r"$one_label^{\rm r}$", + r"$two_label^{\rm r}$", + r"$three_label^{\rm r}$", + r"$one_label^{\rm two}$", + r"$two_label^{\rm two}$", + ] + + def test_name_for_prior(self): + ls = af.Collection( + [ + af.m.MockClassRelativeWidth(1, 2, 3), + af.Model(af.m.MockClassRelativeWidth), + ] + ) + assert ls.name_for_prior(ls[1].one) == "1_one" + + def test_tuple_parameter_float(self, mapper): + mapper.with_float = af.m.MockWithFloat + mapper.with_tuple = af.m.MockWithTuple + + mapper.with_float.value = 1.0 + + assert mapper.prior_count == 2 + + mapper.with_tuple.tup_0 = mapper.with_float.value + + assert mapper.prior_count == 1 + + instance = mapper.instance_from_unit_vector([0.0]) + + assert instance.with_float.value == 1 + assert instance.with_tuple.tup == (1.0, 0.0) + + +class TestModelingMapper: + def test__argument_extraction(self): + mapper = af.ModelMapper() + mapper.mock_class = af.m.MockClassx2 + assert 1 == len(mapper.prior_model_tuples) + + assert len(mapper.prior_tuples_ordered_by_id) == 2 + + def test_attribution(self): + mapper = af.ModelMapper() + + mapper.mock_class = af.m.MockClassx2 + + assert hasattr(mapper, "mock_class") + assert hasattr(mapper.mock_class, "one") + + def test_tuple_arg(self): + mapper = af.ModelMapper() + + mapper.mock_profile = af.m.MockClassx3TupleFloat + + assert 3 == len(mapper.prior_tuples_ordered_by_id) + + +class TestInstances: + def test_attribute(self): + mm = af.ModelMapper() + mm.cls_1 = af.m.MockClassx2 + + assert 1 == len(mm.prior_model_tuples) + assert isinstance(mm.cls_1, af.Model) + + def test__instance_from_unit_vector(self): + mapper = af.ModelMapper(mock_cls=af.m.MockClassx2Tuple) + + model_map = mapper.instance_from_unit_vector([1.0, 1.0]) + + assert model_map.mock_cls.one_tuple == (1.0, 2.0) + + def test__instance_from_vector(self): + mapper = af.ModelMapper(mock_cls=af.m.MockClassx2Tuple) + + model_map = mapper.instance_from_vector([1.0, 0.5]) + + assert model_map.mock_cls.one_tuple == (1.0, 0.5) + + def test_inheritance(self): + mapper = af.ModelMapper(mock_cls=af.m.MockChildTuplex2) + + model_map = mapper.instance_from_unit_vector([1.0, 1.0, 1.0, 1.0]) + + assert model_map.mock_cls.tup == (1.0, 1.0) + + def test__multiple_classes(self): + mapper = af.ModelMapper( + mock_child_cls_0=af.m.MockChildTuplex3, + mock_child_cls_1=af.m.MockChildTuplex2, + mock_child_cls_2=af.m.MockChildTuplex2, + mock_child_tuple=af.m.MockChildTuple, + mock_child_cls_3=af.m.MockChildTuplex3, + ) + + model_map = mapper.instance_from_unit_vector( + [0.5 for _ in range(len(mapper.prior_tuples_ordered_by_id))] + ) + + assert isinstance(model_map.mock_child_cls_1, af.m.MockChildTuplex2) + assert isinstance(model_map.mock_child_cls_2, af.m.MockChildTuplex2) + assert isinstance(model_map.mock_child_tuple, af.m.MockChildTuple) + + assert isinstance(model_map.mock_child_cls_0, af.m.MockChildTuplex3) + assert isinstance(model_map.mock_child_cls_3, af.m.MockChildTuplex3) + + def test__in_order_of_class_constructor(self): + mapper = af.ModelMapper(mock_cls_0=af.m.MockChildTuplex2) + + model_map = mapper.instance_from_unit_vector([0.25, 0.5, 0.75, 1.0]) + + assert model_map.mock_cls_0.tup == (0.25, 0.5) + assert model_map.mock_cls_0.one == 1.5 + assert model_map.mock_cls_0.two == 2.0 + + mapper = af.ModelMapper( + mock_cls_0=af.m.MockChildTuplex2, + mock_cls_1=af.m.MockChildTuple, + mock_cls_2=af.m.MockChildTuplex2, + ) + + model_map = mapper.instance_from_unit_vector( + [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + ) + + assert model_map.mock_cls_0.tup == pytest.approx((0.1, 0.2)) + assert model_map.mock_cls_0.one == pytest.approx(0.6) + assert model_map.mock_cls_0.two == pytest.approx(0.8) + + assert model_map.mock_cls_1.tup == pytest.approx((0.5, 0.6)) + + assert model_map.mock_cls_2.tup == pytest.approx((0.7, 0.8)) + assert model_map.mock_cls_2.one == pytest.approx(1.8) + assert model_map.mock_cls_2.two == pytest.approx(2.0) + + def test__check_order_for_different_unit_values(self): + mapper = af.ModelMapper( + mock_cls_0=af.m.MockChildTuplex2, + mock_cls_1=af.m.MockChildTuple, + mock_cls_2=af.m.MockChildTuplex2, + ) + + mapper.mock_cls_0.tup.tup_0 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_0.tup.tup_1 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_0.one = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_0.two = af.UniformPrior(0.0, 1.0) + + mapper.mock_cls_1.tup.tup_0 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_1.tup.tup_1 = af.UniformPrior(0.0, 1.0) + + mapper.mock_cls_2.tup.tup_0 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_2.tup.tup_1 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_2.one = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_2.two = af.UniformPrior(0.0, 1.0) + + model_map = mapper.instance_from_unit_vector( + [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + ) + + assert model_map.mock_cls_0.tup == pytest.approx((0.1, 0.2)) + assert model_map.mock_cls_0.one == pytest.approx(0.3) + assert model_map.mock_cls_0.two == pytest.approx(0.4) + + assert model_map.mock_cls_1.tup == pytest.approx((0.5, 0.6)) + + assert model_map.mock_cls_2.tup == pytest.approx((0.7, 0.8)) + assert model_map.mock_cls_2.one == pytest.approx(0.9) + assert model_map.mock_cls_2.two == pytest.approx(1.0) + + def test__check_order_for_different_unit_values_and_set_priors_equal_to_one_another( + self, + ): + mapper = af.ModelMapper( + mock_cls_0=af.m.MockChildTuplex2, + mock_cls_1=af.m.MockChildTuple, + mock_cls_2=af.m.MockChildTuplex2, + ) + + mapper.mock_cls_0.tup.tup_0 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_0.tup.tup_1 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_0.one = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_0.two = af.UniformPrior(0.0, 1.0) + + mapper.mock_cls_1.tup.tup_0 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_1.tup.tup_1 = af.UniformPrior(0.0, 1.0) + + mapper.mock_cls_2.tup.tup_0 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_2.tup.tup_1 = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_2.one = af.UniformPrior(0.0, 1.0) + mapper.mock_cls_2.two = af.UniformPrior(0.0, 1.0) + + mapper.mock_cls_0.one = mapper.mock_cls_0.two + mapper.mock_cls_2.tup.tup_1 = mapper.mock_cls_1.tup.tup_1 + + model_map = mapper.instance_from_unit_vector( + [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] + ) + + assert model_map.mock_cls_0.tup == pytest.approx((0.2, 0.3)) + assert model_map.mock_cls_0.one == pytest.approx(0.4) + assert model_map.mock_cls_0.two == pytest.approx(0.4) + + assert model_map.mock_cls_1.tup == pytest.approx((0.5, 0.6)) + + assert model_map.mock_cls_2.tup == pytest.approx((0.7, 0.6)) + assert model_map.mock_cls_2.one == pytest.approx(0.8) + assert model_map.mock_cls_2.two == pytest.approx(0.9) + + def test__instance_from_vector__check_order(self): + mapper = af.ModelMapper( + mock_cls_0=af.m.MockChildTuplex2, + mock_cls_1=af.m.MockChildTuple, + mock_cls_2=af.m.MockChildTuplex2, + ) + + model_map = mapper.instance_from_vector( + [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + ) + + assert model_map.mock_cls_0.tup == (0.1, 0.2) + assert model_map.mock_cls_0.one == 0.3 + assert model_map.mock_cls_0.two == 0.4 + + assert model_map.mock_cls_1.tup == (0.5, 0.6) + + assert model_map.mock_cls_2.tup == (0.7, 0.8) + assert model_map.mock_cls_2.one == 0.9 + assert model_map.mock_cls_2.two == 1.0 + + def test__instance_from_prior_medians(self): + mapper = af.ModelMapper(mock_cls_0=af.m.MockChildTuplex2) + + model_map = mapper.instance_from_prior_medians() + + model_2 = mapper.instance_from_unit_vector([0.5, 0.5, 0.5, 0.5]) + + assert model_map.mock_cls_0.tup == model_2.mock_cls_0.tup == (0.5, 0.5) + assert model_map.mock_cls_0.one == model_2.mock_cls_0.one == 1.0 + assert model_map.mock_cls_0.two == model_2.mock_cls_0.two == 1.0 + + mapper = af.ModelMapper( + mock_cls_0=af.m.MockChildTuplex2, + mock_cls_1=af.m.MockChildTuple, + mock_cls_2=af.m.MockChildTuplex2, + ) + + model_map = mapper.instance_from_prior_medians() + + model_2 = mapper.instance_from_unit_vector( + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + ) + + assert model_map.mock_cls_0.tup == model_2.mock_cls_0.tup == (0.5, 0.5) + assert model_map.mock_cls_0.one == model_2.mock_cls_0.one == 1.0 + assert model_map.mock_cls_0.two == model_2.mock_cls_0.two == 1.0 + + assert model_map.mock_cls_1.tup == model_2.mock_cls_1.tup == (0.5, 0.5) + + assert model_map.mock_cls_2.tup == model_2.mock_cls_2.tup == (0.5, 0.5) + assert model_map.mock_cls_2.one == model_2.mock_cls_2.one == 1.0 + assert model_map.mock_cls_2.two == model_2.mock_cls_2.two == 1.0 + + def test__from_prior_medians__one_model__set_one_parameter_to_another(self): + mapper = af.ModelMapper(mock_cls_0=af.m.MockChildTuplex2) + + mapper.mock_cls_0.one = mapper.mock_cls_0.two + + model_map = mapper.instance_from_prior_medians() + + model_2 = mapper.instance_from_unit_vector([0.5, 0.5, 0.5]) + + assert model_map.mock_cls_0.tup == model_2.mock_cls_0.tup == (0.5, 0.5) + assert model_map.mock_cls_0.one == model_2.mock_cls_0.one == 1.0 + assert model_map.mock_cls_0.two == model_2.mock_cls_0.two == 1.0 + + def test_log_prior_list_from_vector(self): + mapper = af.ModelMapper() + mapper.mock_class = af.Model(af.m.MockClassx2) + mapper.mock_class.one = af.GaussianPrior(mean=1.0, sigma=2.0) + mapper.mock_class.two = af.LogUniformPrior(lower_limit=1e-8, upper_limit=10.0) + + log_prior_list = mapper.log_prior_list_from_vector(vector=[0.0, 5.0]) + + # Density-form log-priors (PyAutoLabs/PyAutoFit#1266): + # Gaussian(mean=1, sigma=2) at value=0: -(0-1)**2 / (2*4) = -0.125 + # LogUniform(...) at value=5: -log(5) + assert log_prior_list[0] == -0.125 + assert log_prior_list[1] == pytest.approx(-np.log(5.0), 1.0e-12) + + def test_random_unit_vector_within_limits(self): + mapper = af.ModelMapper() + mapper.mock_class = af.Model(af.m.MockClassx2) + + random.seed(1) + + assert mapper.random_unit_vector_within_limits( + lower_limit=0.0, upper_limit=1.0 + ) == pytest.approx([0.13436, 0.84743], 1.0e-4) + + assert mapper.random_unit_vector_within_limits( + lower_limit=0.2, upper_limit=0.8 + ) == pytest.approx([0.65826, 0.35304], 1.0e-4) + + def test_random_vector_from_prior_within_limits(self): + random.seed(1) + + mapper = af.ModelMapper() + mapper.mock_class = af.Model(af.m.MockClassx2) + + vector = mapper.random_vector_from_priors_within_limits( + lower_limit=0.499999, upper_limit=0.500001 + ) + + assert vector == pytest.approx([0.5, 1.0], 1.0e-3) + + vector = mapper.random_vector_from_priors_within_limits( + lower_limit=0.899999, upper_limit=0.900001 + ) + + assert vector == pytest.approx([0.9, 1.8], 1.0e-3) + + vector = mapper.random_vector_from_priors_within_limits( + lower_limit=0.2, upper_limit=0.3 + ) + + assert vector == pytest.approx([0.268, 0.402], abs=0.1) + + def test_random_vector_from_prior(self): + mapper = af.Collection(mock_class=af.Model(af.m.MockClassx2)) + + random.seed(1) + + assert mapper.random_vector_from_priors == pytest.approx( + [0.1343, 1.6948], 1.0e-2 + ) + assert mapper.random_vector_from_priors == pytest.approx( + [0.7637, 0.5101], 1.0e-2 + ) + + # By default, this seeded random will draw a value < -0.15, which is below the lower limit below. This + # test ensures that this value is resampled to the next draw, which is above 0.15 + + mapper = af.Collection( + mock_class=af.Model( + af.m.MockClassx2, one=af.UniformPrior(lower_limit=0.15, upper_limit=1.0) + ) + ) + + mapper.mock_class.one.lower_limit = 0.15 + + assert mapper.random_vector_from_priors == pytest.approx( + [0.5711, 0.8989], 1.0e-2 + ) + + def test_vector_from_prior_medians(self): + mapper = af.ModelMapper() + mapper.mock_class = af.Model(af.m.MockClassx2) + + assert mapper.physical_values_from_prior_medians == [0.5, 1.0] + + +class TestUtility: + def test_prior_prior_model_dict(self): + mapper = af.ModelMapper(mock_class=af.m.MockClassx2) + + assert len(mapper.prior_prior_model_dict) == 2 + assert ( + mapper.prior_prior_model_dict[mapper.prior_tuples_ordered_by_id[0][1]].cls + == af.m.MockClassx2 + ) + assert ( + mapper.prior_prior_model_dict[mapper.prior_tuples_ordered_by_id[1][1]].cls + == af.m.MockClassx2 + ) + + def test_name_for_prior(self): + mapper = af.ModelMapper(mock_class=af.m.MockClassx2) + + assert mapper.name_for_prior(mapper.priors[0]) == "mock_class_one" + assert mapper.name_for_prior(mapper.priors[1]) == "mock_class_two" + + +class TestPriorReplacement: + def test_prior_replacement(self): + mapper = af.ModelMapper(mock_class=af.m.MockClassx2) + result = mapper.mapper_from_prior_means([10, 5]) + + assert isinstance(result.mock_class.one, af.TruncatedGaussianPrior) + assert {prior.id for prior in mapper.priors} == { + prior.id for prior in result.priors + } + + def test_replace_priors_with_gaussians_from_tuples(self): + mapper = af.ModelMapper(mock_class=af.m.MockClassx2) + result = mapper.mapper_from_prior_means([10, 5]) + + assert isinstance(result.mock_class.one, af.TruncatedGaussianPrior) + + def test_replacing_priors_for_profile(self): + mapper = af.ModelMapper(mock_class=af.m.MockClassx3TupleFloat) + result = mapper.mapper_from_prior_means([10, 5, 5]) + + assert isinstance( + result.mock_class.one_tuple.unique_prior_tuples[0][1], af.TruncatedGaussianPrior + ) + assert isinstance( + result.mock_class.one_tuple.unique_prior_tuples[1][1], af.TruncatedGaussianPrior + ) + assert isinstance(result.mock_class.two, af.TruncatedGaussianPrior) + + def test_replace_priors_for_two_classes(self): + mapper = af.ModelMapper(one=af.m.MockClassx2, two=af.m.MockClassx2) + + result = mapper.mapper_from_prior_means([1, 2, 3, 4]) + + assert result.one.one.mean == 1 + assert result.one.two.mean == 2 + assert result.two.one.mean == 3 + assert result.two.two.mean == 4 + + def test__mapper_from_uniform_floats(self): + mapper = af.ModelMapper(mock_class=af.m.MockClassx2) + result = mapper.mapper_from_uniform_floats([10, 5], b=1.0) + + assert isinstance(result.mock_class.one, af.UniformPrior) + assert {prior.id for prior in mapper.priors} == { + prior.id for prior in result.priors + } + + +class TestArguments: + def test_same_argument_name(self): + mapper = af.ModelMapper() + + mapper.one = af.Model(af.m.MockClassx2) + mapper.two = af.Model(af.m.MockClassx2) + + instance = mapper.instance_from_vector([0.1, 0.2, 0.3, 0.4]) + + assert instance.one.one == 0.1 + assert instance.one.two == 0.2 + assert instance.two.one == 0.3 + assert instance.two.two == 0.4 + + +class TestIndependentPriorModel: + def test_associate_prior_model(self): + prior_model = af.Model(af.m.MockClassx2) + + mapper = af.ModelMapper() + + mapper.prior_model = prior_model + + assert len(mapper.prior_model_tuples) == 1 + + instance = mapper.instance_from_vector([0.1, 0.2]) + + assert instance.prior_model.one == 0.1 + assert instance.prior_model.two == 0.2 + + +@pytest.fixture(name="list_prior_model") +def make_list_prior_model(): + return af.Collection([af.Model(af.m.MockClassx2), af.Model(af.m.MockClassx2)]) + + +class TestListPriorModel: + def test_instance_from_vector(self, list_prior_model): + mapper = af.ModelMapper() + mapper.list = list_prior_model + + instance = mapper.instance_from_vector([0.1, 0.2, 0.3, 0.4]) + + assert isinstance(instance.list, af.ModelInstance) + print(instance.list.items) + assert len(instance.list) == 2 + assert instance.list[0].one == 0.1 + assert instance.list[0].two == 0.2 + assert instance.list[1].one == 0.3 + assert instance.list[1].two == 0.4 + + def test_prior_results_for_gaussian_tuples(self, list_prior_model): + mapper = af.ModelMapper() + mapper.list = list_prior_model + + gaussian_mapper = mapper.mapper_from_prior_means( + [1, 2, 3, 4] + ) + + assert len(gaussian_mapper.list) == 2 + assert gaussian_mapper.list[0].one.mean == 1 + assert gaussian_mapper.list[0].two.mean == 2 + assert gaussian_mapper.list[1].one.mean == 3 + assert gaussian_mapper.list[1].two.mean == 4 + assert gaussian_mapper.list[0].one.sigma == 1 + assert gaussian_mapper.list[0].two.sigma == 2 + assert gaussian_mapper.list[1].one.sigma == 1 + assert gaussian_mapper.list[1].two.sigma == 2 + + def test_prior_results_for_gaussian_tuples__include_override_from_width_file( + self, list_prior_model + ): + mapper = af.ModelMapper() + mapper.list = list_prior_model + + gaussian_mapper = mapper.mapper_from_prior_means( + [1, 2, 3, 4] + ) + + assert len(gaussian_mapper.list) == 2 + assert gaussian_mapper.list[0].one.mean == 1 + assert gaussian_mapper.list[0].two.mean == 2 + assert gaussian_mapper.list[1].one.mean == 3 + assert gaussian_mapper.list[1].two.mean == 4 + assert gaussian_mapper.list[0].one.sigma == 1 + assert gaussian_mapper.list[0].two.sigma == 2 + assert gaussian_mapper.list[1].one.sigma == 1 + assert gaussian_mapper.list[1].two.sigma == 2 + + def test_automatic_boxing(self): + mapper = af.ModelMapper() + mapper.list = [af.Model(af.m.MockClassx2), af.Model(af.m.MockClassx2)] + + assert isinstance(mapper.list, af.Collection) + + +@pytest.fixture(name="mock_with_instance") +def make_mock_with_instance(): + mock_with_instance = af.Model(af.m.MockClassx2) + mock_with_instance.one = 3.0 + return mock_with_instance + + +class Testinstance: + def test__instance_prior_count(self, mock_with_instance): + mapper = af.ModelMapper() + mapper.mock_class = mock_with_instance + + assert len(mapper.unique_prior_tuples) == 1 + + def test__retrieve_instances(self, mock_with_instance): + assert len(mock_with_instance.instance_tuples) == 1 + + def test_instance_prior_reconstruction(self, mock_with_instance): + mapper = af.ModelMapper() + mapper.mock_class = mock_with_instance + + instance = mapper.instance_for_arguments({mock_with_instance.two: 0.5}) + + assert instance.mock_class.one == 3 + assert instance.mock_class.two == 0.5 + + def test__instance_in_config(self): + mapper = af.ModelMapper() + + mock_with_instance = af.Model(af.m.MockClassx2Instance, one=3) + + mapper.mock_class = mock_with_instance + + instance = mapper.instance_for_arguments({mock_with_instance.two: 0.5}) + + assert instance.mock_class.one == 3 + assert instance.mock_class.two == 0.5 + + def test__set_float(self): + prior_model = af.Model(af.m.MockClassx2) + prior_model.one = 3 + prior_model.two = 4.0 + assert prior_model.one == 3 + assert prior_model.two == 4.0 + + def test__list_prior_model_instances(self, mapper): + prior_model = af.Model(af.m.MockClassx2) + prior_model.one = 3.0 + prior_model.two = 4.0 + + mapper.mock_list = [prior_model] + assert isinstance(mapper.mock_list, af.Collection) + assert len(mapper.instance_tuples) == 2 + + def test__set_for_tuple_prior(self): + prior_model = af.Model(af.m.MockChildTuplex3) + prior_model.tup_0 = 1.0 + prior_model.tup_1 = 2.0 + prior_model.one = 1.0 + prior_model.two = 1.0 + prior_model.three = 1.0 + instance = prior_model.instance_for_arguments({}) + assert instance.tup == (1.0, 2.0) + + +@pytest.fixture(name="mock_config") +def make_mock_config(): + return + + +@pytest.fixture(name="mapper_with_one") +def make_mapper_with_one(): + mapper = af.ModelMapper() + mapper.one = af.Model(af.m.MockClassx2) + return mapper + + +@pytest.fixture(name="mapper_with_list") +def make_mapper_with_list(): + mapper = af.ModelMapper() + mapper.list = [af.Model(af.m.MockClassx2), af.Model(af.m.MockClassx2)] + return mapper + + +class TestGaussianWidthConfig: + def test_relative_widths(self, mapper): + mapper.relative_width = af.m.MockClassRelativeWidth + new_mapper = mapper.mapper_from_prior_means([1, 1, 1]) + + assert new_mapper.relative_width.one.mean == 1.0 + assert new_mapper.relative_width.one.sigma == 0.1 + + assert new_mapper.relative_width.two.mean == 1.0 + assert new_mapper.relative_width.two.sigma == 0.5 + + assert new_mapper.relative_width.three.mean == 1.0 + assert new_mapper.relative_width.three.sigma == 1.0 + + def test_prior_classes(self, mapper_with_one): + assert mapper_with_one.prior_class_dict == { + mapper_with_one.one.one: af.m.MockClassx2, + mapper_with_one.one.two: af.m.MockClassx2, + } + + def test_prior_classes_list(self, mapper_with_list): + assert mapper_with_list.prior_class_dict == { + mapper_with_list.list[0].one: af.m.MockClassx2, + mapper_with_list.list[0].two: af.m.MockClassx2, + mapper_with_list.list[1].one: af.m.MockClassx2, + mapper_with_list.list[1].two: af.m.MockClassx2, + } + + def test_no_override(self): + mapper = af.ModelMapper() + + mapper.one = af.Model(af.m.MockClassx2) + + af.ModelMapper() + + assert mapper.one is not None diff --git a/test_autofit/mapper/model/test_overloading.py b/test_autofit/mapper/model/test_overloading.py index 31af52da6..8448beee4 100644 --- a/test_autofit/mapper/model/test_overloading.py +++ b/test_autofit/mapper/model/test_overloading.py @@ -1,22 +1,22 @@ -import autofit as af - -def test_constructor(): - prior_model = af.Model(af.m.MockOverload) - - assert prior_model.prior_count == 1 - - instance = prior_model.instance_from_prior_medians() - - assert instance.one == 1.0 - assert instance.two == 2 - - -def test_alternative(): - prior_model = af.Model(af.m.MockOverload.with_two) - - assert prior_model.prior_count == 1 - - instance = prior_model.instance_from_prior_medians() - - assert instance.two == 1.0 - assert instance.one == 1.0 / 2 +import autofit as af + +def test_constructor(): + prior_model = af.Model(af.m.MockOverload) + + assert prior_model.prior_count == 1 + + instance = prior_model.instance_from_prior_medians() + + assert instance.one == 1.0 + assert instance.two == 2 + + +def test_alternative(): + prior_model = af.Model(af.m.MockOverload.with_two) + + assert prior_model.prior_count == 1 + + instance = prior_model.instance_from_prior_medians() + + assert instance.two == 1.0 + assert instance.one == 1.0 / 2 diff --git a/test_autofit/mapper/model/test_prior_model.py b/test_autofit/mapper/model/test_prior_model.py index ba4924fad..6bf45b803 100644 --- a/test_autofit/mapper/model/test_prior_model.py +++ b/test_autofit/mapper/model/test_prior_model.py @@ -1,439 +1,439 @@ -import copy - -import pytest - -import autofit as af - - -@pytest.fixture(name="instance_prior_model") -def make_instance_prior_model(): - instance = af.m.MockClassx2(1.0, 2.0) - return af.AbstractPriorModel.from_instance(instance) - - -@pytest.fixture(name="list_prior_model") -def make_list_prior_model(): - instance = [af.m.MockClassx2(1.0, 2.0)] - return af.AbstractPriorModel.from_instance(instance) - - -@pytest.fixture(name="complex_prior_model") -def make_complex_prior_model(): - instance = af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)) - return af.AbstractPriorModel.from_instance(instance) - - -def test_class_assertion(): - with pytest.raises(AssertionError): - af.Model("Hello") - - -class TestAsModel: - def test_instance(self, instance_prior_model): - model = instance_prior_model.as_model() - assert model.prior_count == 2 - - def test_complex(self, complex_prior_model): - assert complex_prior_model.prior_count == 0 - model = complex_prior_model.as_model() - assert model.prior_count == 2 - assert model.simple.prior_count == 2 - - def test_galaxies(self): - galaxies = af.ModelInstance() - galaxies.one = af.m.MockComponents() - instance = af.ModelInstance() - instance.galaxies = galaxies - model = instance.as_model(model_classes=(af.m.MockComponents,)) - assert model.prior_count == 1 - - -class TestFromInstance: - def test_model_mapper(self): - instance = af.ModelInstance() - instance.simple = af.m.MockClassx2(1.0, 2.0) - - result = af.AbstractPriorModel.from_instance(instance) - - assert isinstance(result, af.ModelMapper) - - def test_with_model_classes(self): - instance = af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)) - model = af.AbstractPriorModel.from_instance( - instance, model_classes=(af.m.MockClassx2,) - ) - assert model.prior_count == 2 - - def test_list_with_model_classes(self): - instance = [ - af.m.MockClassx2(1.0, 2.0), - af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)), - ] - model = af.AbstractPriorModel.from_instance( - instance, model_classes=(af.m.MockComplexClass,) - ) - - assert model.prior_count == 2 - assert model[0].prior_count == 0 - assert model[1].prior_count == 2 - - def test_dict_with_model_classes(self): - instance = { - "one": af.m.MockClassx2(1.0, 2.0), - "two": af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)), - } - model = af.AbstractPriorModel.from_instance( - instance, model_classes=(af.m.MockComplexClass,) - ) - - assert model.prior_count == 2 - assert model[0].prior_count == 0 - assert model[1].prior_count == 2 - - assert model.one.one == 1.0 - assert model.one.two == 2.0 - - assert isinstance(model.two.simple.one, af.Prior) - assert isinstance(model.two.simple.two, af.Prior) - - def test_instance(self, instance_prior_model): - assert instance_prior_model.cls == af.m.MockClassx2 - assert instance_prior_model.prior_count == 0 - assert instance_prior_model.one == 1.0 - assert instance_prior_model.two == 2.0 - - new_instance = instance_prior_model.instance_for_arguments({}) - assert isinstance(new_instance, af.m.MockClassx2) - assert new_instance.one == 1.0 - assert new_instance.two == 2.0 - - def test_complex(self, complex_prior_model): - assert complex_prior_model.cls == af.m.MockComplexClass - assert complex_prior_model.prior_count == 0 - assert isinstance(complex_prior_model.simple, af.Model) - assert complex_prior_model.simple.cls == af.m.MockClassx2 - assert complex_prior_model.simple.one == 1.0 - - new_instance = complex_prior_model.instance_for_arguments({}) - assert isinstance(new_instance, af.m.MockComplexClass) - assert isinstance(new_instance.simple, af.m.MockClassx2) - assert new_instance.simple.one == 1.0 - - def test_list(self, list_prior_model): - assert isinstance(list_prior_model, af.Collection) - assert isinstance(list_prior_model[0], af.Model) - assert list_prior_model[0].one == 1.0 - - def test_dict(self): - instance = {"simple": af.m.MockClassx2(1.0, 2.0)} - prior_model = af.AbstractPriorModel.from_instance(instance) - assert isinstance(prior_model, af.Collection) - assert isinstance(prior_model.simple, af.Model) - assert prior_model.simple.one == 1.0 - - new_instance = prior_model.instance_for_arguments({}) - assert isinstance(new_instance.simple, af.m.MockClassx2) - - prior_model = af.AbstractPriorModel.from_instance(new_instance) - assert isinstance(prior_model, af.Collection) - assert isinstance(prior_model.simple, af.Model) - assert prior_model.simple.one == 1.0 - - -class TestSum: - def test_add_prior_models(self): - mock_cls_0 = af.Model(af.m.MockChildTuplex2) - mock_cls_1 = af.Model(af.m.MockChildTuplex2) - - mock_cls_0.one = 1.0 - mock_cls_1.two = 0.0 - - result = mock_cls_0 + mock_cls_1 - - assert isinstance(result, af.Model) - assert result.cls == af.m.MockChildTuplex2 - assert isinstance(result.one, af.Prior) - assert isinstance(result.two, af.Prior) - - def test_fail_for_mismatch(self): - mock_cls_0 = af.Model(af.m.MockChildTuplex2) - mock_cls_1 = af.Model(af.m.MockChildTuplex3) - - with pytest.raises(TypeError): - mock_cls_0 + mock_cls_1 - - def test_add_children(self): - mock_components_1 = af.Model( - af.m.MockComponents, - components_0=af.Collection(mock_cls_0=af.m.MockChildTuplex2), - components_1=af.Collection(mock_cls_2=af.m.MockChildTuplex3), - ) - mock_components_2 = af.Model( - af.m.MockComponents, - components_0=af.Collection(mock_cls_1=af.m.MockChildTuplex2), - components_1=af.Collection(mock_cls_3=af.m.MockChildTuplex3), - ) - - result = mock_components_1 + mock_components_2 - - assert ( - result.components_0.mock_cls_0 == mock_components_1.components_0.mock_cls_0 - ) - assert ( - result.components_0.mock_cls_1 == mock_components_2.components_0.mock_cls_1 - ) - - assert ( - result.components_1.mock_cls_2 == mock_components_1.components_1.mock_cls_2 - ) - assert ( - result.components_1.mock_cls_3 == mock_components_2.components_1.mock_cls_3 - ) - - def test_prior_model_override(self): - mock_components_1 = af.Model( - af.m.MockComponents, - components_0=af.Collection(light=af.m.MockChildTuplex2()), - components_1=af.Collection(mass=af.m.MockChildTuplex3), - ) - mock_components_2 = af.Model( - af.m.MockComponents, - components_0=af.Collection(light=af.m.MockChildTuplex2), - components_1=af.Collection(mass=af.m.MockChildTuplex3()), - ) - - result = mock_components_1 + mock_components_2 - - assert result.components_1.mass == mock_components_1.components_1.mass - assert result.components_0.light == mock_components_2.components_0.light - - -class TestFloatAnnotation: - # noinspection PyUnresolvedReferences - def test_prior_linking(self): - mapper = af.ModelMapper() - mapper.a = af.m.MockClassx2 - mapper.b = af.m.MockClassx2 - - assert mapper.prior_count == 4 - - mapper.a.one = mapper.b.one - - assert mapper.prior_count == 3 - - mapper.a.two = mapper.b.two - - assert mapper.prior_count == 2 - - mapper.a.one = mapper.a.two - mapper.b.one = mapper.b.two - - assert mapper.prior_count == 1 - - -class TestHashing: - def test_is_hashable(self): - assert hash(af.AbstractPriorModel()) is not None - assert hash(af.Model(af.m.MockClassx2)) is not None - assert ( - hash(af.AnnotationPriorModel(af.m.MockClassx2, af.m.MockClassx2, "one")) - is not None - ) - - -class StringDefault: - def __init__(self, value="a string"): - self.value = value - - -class TestStringArguments: - def test_string_default(self): - prior_model = af.Model(StringDefault) - assert prior_model.prior_count == 0 - - assert prior_model.instance_for_arguments({}).value == "a string" - - -class TestPriorModelArguments: - def test_list_arguments(self): - prior_model = af.Model(af.m.MockListClass) - - assert prior_model.prior_count == 0 - - prior_model = af.Model(af.m.MockListClass, ls=[af.m.MockClassx2]) - - assert prior_model.prior_count == 2 - - prior_model = af.Model( - af.m.MockListClass, ls=[af.m.MockClassx2, af.m.MockClassx2] - ) - - assert prior_model.prior_count == 4 - - def test_float_argument(self): - prior = af.UniformPrior(0.5, 2.0) - prior_model = af.Model(af.m.MockComponents, parameter=prior) - - assert prior_model.prior_count == 1 - assert prior_model.priors[0] is prior - - prior_model = af.Model(af.m.MockComponents, parameter=4.0) - assert prior_model.prior_count == 0 - assert prior_model.parameter == 4.0 - - instance = prior_model.instance_for_arguments({}) - assert instance.parameter == 4.0 - - def test_arbitrary_keyword_arguments(self): - prior_model = af.Model( - af.m.MockComponents, - mock_cls_0=af.m.MockChildTuplex2, - mock_cls_1=af.m.MockChildTuplex3, - ) - assert prior_model.prior_count == 10 - instance = prior_model.instance_from_unit_vector( - [0.5] * prior_model.prior_count - ) - assert isinstance(instance.mock_cls_0, af.m.MockChildTuplex2) - assert isinstance(instance.mock_cls_1, af.m.MockChildTuplex3) - - -class TestCase: - def test_complex_class(self): - prior_model = af.Model(af.m.MockComplexClass) - - assert hasattr(prior_model, "simple") - assert prior_model.simple.prior_count == 2 - assert prior_model.prior_count == 2 - - def test_create_instance(self): - mapper = af.ModelMapper() - mapper.complex = af.m.MockComplexClass - - instance = mapper.instance_from_unit_vector([1.0, 0.0]) - - assert instance.complex.simple.one == 1.0 - assert instance.complex.simple.two == 0.0 - - def test_instantiate_with_list_arguments(self): - mapper = af.ModelMapper() - mapper.list_object = af.Model( - af.m.MockListClass, ls=[af.m.MockClassx2, af.m.MockClassx2] - ) - - assert len(mapper.list_object.ls) == 2 - - assert mapper.list_object.prior_count == 4 - assert mapper.prior_count == 4 - - instance = mapper.instance_from_unit_vector([0.1, 0.2, 0.3, 0.4]) - - assert len(instance.list_object.ls) == 2 - assert instance.list_object.ls[0].one == pytest.approx(0.1) - assert instance.list_object.ls[0].two == pytest.approx(0.4) - assert instance.list_object.ls[1].one == pytest.approx(0.3) - assert instance.list_object.ls[1].two == pytest.approx(0.8) - - def test_mix_instances_and_models(self): - mapper = af.ModelMapper() - mapper.list_object = af.Model( - af.m.MockListClass, ls=[af.m.MockClassx2, af.m.MockClassx2(1, 2)] - ) - - assert mapper.prior_count == 2 - - instance = mapper.instance_from_unit_vector([0.1, 0.2]) - - assert len(instance.list_object.ls) == 2 - assert instance.list_object.ls[0].one == pytest.approx(0.1) - assert instance.list_object.ls[0].two == pytest.approx(0.4) - assert instance.list_object.ls[1].one == pytest.approx(1) - assert instance.list_object.ls[1].two == pytest.approx(2) - - -class TestCollectionPriorModel: - def test_keyword_arguments(self): - prior_model = af.Collection(one=af.m.MockClassx2, two=af.m.MockClassx2(1, 2)) - - assert len(prior_model.direct_prior_model_tuples) == 1 - assert len(prior_model) == 2 - - instance = prior_model.instance_for_arguments( - {prior_model.one.one: 0.1, prior_model.one.two: 0.2} - ) - - assert instance.one.one == 0.1 - assert instance.one.two == 0.2 - - assert instance.two.one == 1 - assert instance.two.two == 2 - - def test_mix_instances_in_grouped_list_prior_model(self): - prior_model = af.Collection([af.m.MockClassx2, af.m.MockClassx2(1, 2)]) - - assert len(prior_model.direct_prior_model_tuples) == 1 - assert prior_model.prior_count == 2 - - mapper = af.ModelMapper() - mapper.ls = prior_model - - instance = mapper.instance_from_unit_vector([0.1, 0.2]) - - assert len(instance.ls) == 2 - - assert instance.ls[0].one == pytest.approx(0.1) - assert instance.ls[0].two == pytest.approx(0.4) - assert instance.ls[1].one == pytest.approx(1) - assert instance.ls[1].two == pytest.approx(2) - - assert len(prior_model.prior_class_dict) == 2 - - def test_list_in_grouped_list_prior_model(self): - prior_model = af.Collection([[af.m.MockClassx2]]) - - assert len(prior_model.direct_prior_model_tuples) == 1 - assert prior_model.prior_count == 2 - - def test_list_prior_model_with_dictionary(self, simple_model): - assert isinstance(simple_model.simple, af.Model) - - def test_override_with_instance(self, simple_model): - simple_instance = af.m.MockClassx2(1, 2) - - simple_model.simple = simple_instance - - assert len(simple_model) == 1 - assert simple_model.simple == simple_instance - - def test_names_of_priors(self): - collection = af.Collection([af.UniformPrior(), af.UniformPrior()]) - assert collection.name_for_prior(collection[0]) == "0" - - -@pytest.fixture(name="simple_model") -def make_simple_model(): - return af.Collection({"simple": af.m.MockClassx2}) - - -class TestCopy: - def test_simple(self, simple_model): - assert simple_model.prior_count > 0 - assert copy.deepcopy(simple_model).prior_count == simple_model.prior_count - - def test_embedded(self, simple_model): - model = af.Collection(simple=simple_model) - assert copy.deepcopy(model).prior_count == model.prior_count - - def test_circular(self): - one = af.Model(af.m.MockClassx2) - - one.one = af.Model(af.m.MockClassx2) - one.one.one = one - - # noinspection PyUnresolvedReferences - assert one.prior_count == one.one.prior_count - assert copy.deepcopy(one).prior_count == one.prior_count - - -def test_composition(simple_model): - assert simple_model.composition == ["simple.one", "simple.two"] +import copy + +import pytest + +import autofit as af + + +@pytest.fixture(name="instance_prior_model") +def make_instance_prior_model(): + instance = af.m.MockClassx2(1.0, 2.0) + return af.AbstractPriorModel.from_instance(instance) + + +@pytest.fixture(name="list_prior_model") +def make_list_prior_model(): + instance = [af.m.MockClassx2(1.0, 2.0)] + return af.AbstractPriorModel.from_instance(instance) + + +@pytest.fixture(name="complex_prior_model") +def make_complex_prior_model(): + instance = af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)) + return af.AbstractPriorModel.from_instance(instance) + + +def test_class_assertion(): + with pytest.raises(AssertionError): + af.Model("Hello") + + +class TestAsModel: + def test_instance(self, instance_prior_model): + model = instance_prior_model.as_model() + assert model.prior_count == 2 + + def test_complex(self, complex_prior_model): + assert complex_prior_model.prior_count == 0 + model = complex_prior_model.as_model() + assert model.prior_count == 2 + assert model.simple.prior_count == 2 + + def test_galaxies(self): + galaxies = af.ModelInstance() + galaxies.one = af.m.MockComponents() + instance = af.ModelInstance() + instance.galaxies = galaxies + model = instance.as_model(model_classes=(af.m.MockComponents,)) + assert model.prior_count == 1 + + +class TestFromInstance: + def test_model_mapper(self): + instance = af.ModelInstance() + instance.simple = af.m.MockClassx2(1.0, 2.0) + + result = af.AbstractPriorModel.from_instance(instance) + + assert isinstance(result, af.ModelMapper) + + def test_with_model_classes(self): + instance = af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)) + model = af.AbstractPriorModel.from_instance( + instance, model_classes=(af.m.MockClassx2,) + ) + assert model.prior_count == 2 + + def test_list_with_model_classes(self): + instance = [ + af.m.MockClassx2(1.0, 2.0), + af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)), + ] + model = af.AbstractPriorModel.from_instance( + instance, model_classes=(af.m.MockComplexClass,) + ) + + assert model.prior_count == 2 + assert model[0].prior_count == 0 + assert model[1].prior_count == 2 + + def test_dict_with_model_classes(self): + instance = { + "one": af.m.MockClassx2(1.0, 2.0), + "two": af.m.MockComplexClass(af.m.MockClassx2(1.0, 2.0)), + } + model = af.AbstractPriorModel.from_instance( + instance, model_classes=(af.m.MockComplexClass,) + ) + + assert model.prior_count == 2 + assert model[0].prior_count == 0 + assert model[1].prior_count == 2 + + assert model.one.one == 1.0 + assert model.one.two == 2.0 + + assert isinstance(model.two.simple.one, af.Prior) + assert isinstance(model.two.simple.two, af.Prior) + + def test_instance(self, instance_prior_model): + assert instance_prior_model.cls == af.m.MockClassx2 + assert instance_prior_model.prior_count == 0 + assert instance_prior_model.one == 1.0 + assert instance_prior_model.two == 2.0 + + new_instance = instance_prior_model.instance_for_arguments({}) + assert isinstance(new_instance, af.m.MockClassx2) + assert new_instance.one == 1.0 + assert new_instance.two == 2.0 + + def test_complex(self, complex_prior_model): + assert complex_prior_model.cls == af.m.MockComplexClass + assert complex_prior_model.prior_count == 0 + assert isinstance(complex_prior_model.simple, af.Model) + assert complex_prior_model.simple.cls == af.m.MockClassx2 + assert complex_prior_model.simple.one == 1.0 + + new_instance = complex_prior_model.instance_for_arguments({}) + assert isinstance(new_instance, af.m.MockComplexClass) + assert isinstance(new_instance.simple, af.m.MockClassx2) + assert new_instance.simple.one == 1.0 + + def test_list(self, list_prior_model): + assert isinstance(list_prior_model, af.Collection) + assert isinstance(list_prior_model[0], af.Model) + assert list_prior_model[0].one == 1.0 + + def test_dict(self): + instance = {"simple": af.m.MockClassx2(1.0, 2.0)} + prior_model = af.AbstractPriorModel.from_instance(instance) + assert isinstance(prior_model, af.Collection) + assert isinstance(prior_model.simple, af.Model) + assert prior_model.simple.one == 1.0 + + new_instance = prior_model.instance_for_arguments({}) + assert isinstance(new_instance.simple, af.m.MockClassx2) + + prior_model = af.AbstractPriorModel.from_instance(new_instance) + assert isinstance(prior_model, af.Collection) + assert isinstance(prior_model.simple, af.Model) + assert prior_model.simple.one == 1.0 + + +class TestSum: + def test_add_prior_models(self): + mock_cls_0 = af.Model(af.m.MockChildTuplex2) + mock_cls_1 = af.Model(af.m.MockChildTuplex2) + + mock_cls_0.one = 1.0 + mock_cls_1.two = 0.0 + + result = mock_cls_0 + mock_cls_1 + + assert isinstance(result, af.Model) + assert result.cls == af.m.MockChildTuplex2 + assert isinstance(result.one, af.Prior) + assert isinstance(result.two, af.Prior) + + def test_fail_for_mismatch(self): + mock_cls_0 = af.Model(af.m.MockChildTuplex2) + mock_cls_1 = af.Model(af.m.MockChildTuplex3) + + with pytest.raises(TypeError): + mock_cls_0 + mock_cls_1 + + def test_add_children(self): + mock_components_1 = af.Model( + af.m.MockComponents, + components_0=af.Collection(mock_cls_0=af.m.MockChildTuplex2), + components_1=af.Collection(mock_cls_2=af.m.MockChildTuplex3), + ) + mock_components_2 = af.Model( + af.m.MockComponents, + components_0=af.Collection(mock_cls_1=af.m.MockChildTuplex2), + components_1=af.Collection(mock_cls_3=af.m.MockChildTuplex3), + ) + + result = mock_components_1 + mock_components_2 + + assert ( + result.components_0.mock_cls_0 == mock_components_1.components_0.mock_cls_0 + ) + assert ( + result.components_0.mock_cls_1 == mock_components_2.components_0.mock_cls_1 + ) + + assert ( + result.components_1.mock_cls_2 == mock_components_1.components_1.mock_cls_2 + ) + assert ( + result.components_1.mock_cls_3 == mock_components_2.components_1.mock_cls_3 + ) + + def test_prior_model_override(self): + mock_components_1 = af.Model( + af.m.MockComponents, + components_0=af.Collection(light=af.m.MockChildTuplex2()), + components_1=af.Collection(mass=af.m.MockChildTuplex3), + ) + mock_components_2 = af.Model( + af.m.MockComponents, + components_0=af.Collection(light=af.m.MockChildTuplex2), + components_1=af.Collection(mass=af.m.MockChildTuplex3()), + ) + + result = mock_components_1 + mock_components_2 + + assert result.components_1.mass == mock_components_1.components_1.mass + assert result.components_0.light == mock_components_2.components_0.light + + +class TestFloatAnnotation: + # noinspection PyUnresolvedReferences + def test_prior_linking(self): + mapper = af.ModelMapper() + mapper.a = af.m.MockClassx2 + mapper.b = af.m.MockClassx2 + + assert mapper.prior_count == 4 + + mapper.a.one = mapper.b.one + + assert mapper.prior_count == 3 + + mapper.a.two = mapper.b.two + + assert mapper.prior_count == 2 + + mapper.a.one = mapper.a.two + mapper.b.one = mapper.b.two + + assert mapper.prior_count == 1 + + +class TestHashing: + def test_is_hashable(self): + assert hash(af.AbstractPriorModel()) is not None + assert hash(af.Model(af.m.MockClassx2)) is not None + assert ( + hash(af.AnnotationPriorModel(af.m.MockClassx2, af.m.MockClassx2, "one")) + is not None + ) + + +class StringDefault: + def __init__(self, value="a string"): + self.value = value + + +class TestStringArguments: + def test_string_default(self): + prior_model = af.Model(StringDefault) + assert prior_model.prior_count == 0 + + assert prior_model.instance_for_arguments({}).value == "a string" + + +class TestPriorModelArguments: + def test_list_arguments(self): + prior_model = af.Model(af.m.MockListClass) + + assert prior_model.prior_count == 0 + + prior_model = af.Model(af.m.MockListClass, ls=[af.m.MockClassx2]) + + assert prior_model.prior_count == 2 + + prior_model = af.Model( + af.m.MockListClass, ls=[af.m.MockClassx2, af.m.MockClassx2] + ) + + assert prior_model.prior_count == 4 + + def test_float_argument(self): + prior = af.UniformPrior(0.5, 2.0) + prior_model = af.Model(af.m.MockComponents, parameter=prior) + + assert prior_model.prior_count == 1 + assert prior_model.priors[0] is prior + + prior_model = af.Model(af.m.MockComponents, parameter=4.0) + assert prior_model.prior_count == 0 + assert prior_model.parameter == 4.0 + + instance = prior_model.instance_for_arguments({}) + assert instance.parameter == 4.0 + + def test_arbitrary_keyword_arguments(self): + prior_model = af.Model( + af.m.MockComponents, + mock_cls_0=af.m.MockChildTuplex2, + mock_cls_1=af.m.MockChildTuplex3, + ) + assert prior_model.prior_count == 10 + instance = prior_model.instance_from_unit_vector( + [0.5] * prior_model.prior_count + ) + assert isinstance(instance.mock_cls_0, af.m.MockChildTuplex2) + assert isinstance(instance.mock_cls_1, af.m.MockChildTuplex3) + + +class TestCase: + def test_complex_class(self): + prior_model = af.Model(af.m.MockComplexClass) + + assert hasattr(prior_model, "simple") + assert prior_model.simple.prior_count == 2 + assert prior_model.prior_count == 2 + + def test_create_instance(self): + mapper = af.ModelMapper() + mapper.complex = af.m.MockComplexClass + + instance = mapper.instance_from_unit_vector([1.0, 0.0]) + + assert instance.complex.simple.one == 1.0 + assert instance.complex.simple.two == 0.0 + + def test_instantiate_with_list_arguments(self): + mapper = af.ModelMapper() + mapper.list_object = af.Model( + af.m.MockListClass, ls=[af.m.MockClassx2, af.m.MockClassx2] + ) + + assert len(mapper.list_object.ls) == 2 + + assert mapper.list_object.prior_count == 4 + assert mapper.prior_count == 4 + + instance = mapper.instance_from_unit_vector([0.1, 0.2, 0.3, 0.4]) + + assert len(instance.list_object.ls) == 2 + assert instance.list_object.ls[0].one == pytest.approx(0.1) + assert instance.list_object.ls[0].two == pytest.approx(0.4) + assert instance.list_object.ls[1].one == pytest.approx(0.3) + assert instance.list_object.ls[1].two == pytest.approx(0.8) + + def test_mix_instances_and_models(self): + mapper = af.ModelMapper() + mapper.list_object = af.Model( + af.m.MockListClass, ls=[af.m.MockClassx2, af.m.MockClassx2(1, 2)] + ) + + assert mapper.prior_count == 2 + + instance = mapper.instance_from_unit_vector([0.1, 0.2]) + + assert len(instance.list_object.ls) == 2 + assert instance.list_object.ls[0].one == pytest.approx(0.1) + assert instance.list_object.ls[0].two == pytest.approx(0.4) + assert instance.list_object.ls[1].one == pytest.approx(1) + assert instance.list_object.ls[1].two == pytest.approx(2) + + +class TestCollectionPriorModel: + def test_keyword_arguments(self): + prior_model = af.Collection(one=af.m.MockClassx2, two=af.m.MockClassx2(1, 2)) + + assert len(prior_model.direct_prior_model_tuples) == 1 + assert len(prior_model) == 2 + + instance = prior_model.instance_for_arguments( + {prior_model.one.one: 0.1, prior_model.one.two: 0.2} + ) + + assert instance.one.one == 0.1 + assert instance.one.two == 0.2 + + assert instance.two.one == 1 + assert instance.two.two == 2 + + def test_mix_instances_in_grouped_list_prior_model(self): + prior_model = af.Collection([af.m.MockClassx2, af.m.MockClassx2(1, 2)]) + + assert len(prior_model.direct_prior_model_tuples) == 1 + assert prior_model.prior_count == 2 + + mapper = af.ModelMapper() + mapper.ls = prior_model + + instance = mapper.instance_from_unit_vector([0.1, 0.2]) + + assert len(instance.ls) == 2 + + assert instance.ls[0].one == pytest.approx(0.1) + assert instance.ls[0].two == pytest.approx(0.4) + assert instance.ls[1].one == pytest.approx(1) + assert instance.ls[1].two == pytest.approx(2) + + assert len(prior_model.prior_class_dict) == 2 + + def test_list_in_grouped_list_prior_model(self): + prior_model = af.Collection([[af.m.MockClassx2]]) + + assert len(prior_model.direct_prior_model_tuples) == 1 + assert prior_model.prior_count == 2 + + def test_list_prior_model_with_dictionary(self, simple_model): + assert isinstance(simple_model.simple, af.Model) + + def test_override_with_instance(self, simple_model): + simple_instance = af.m.MockClassx2(1, 2) + + simple_model.simple = simple_instance + + assert len(simple_model) == 1 + assert simple_model.simple == simple_instance + + def test_names_of_priors(self): + collection = af.Collection([af.UniformPrior(), af.UniformPrior()]) + assert collection.name_for_prior(collection[0]) == "0" + + +@pytest.fixture(name="simple_model") +def make_simple_model(): + return af.Collection({"simple": af.m.MockClassx2}) + + +class TestCopy: + def test_simple(self, simple_model): + assert simple_model.prior_count > 0 + assert copy.deepcopy(simple_model).prior_count == simple_model.prior_count + + def test_embedded(self, simple_model): + model = af.Collection(simple=simple_model) + assert copy.deepcopy(model).prior_count == model.prior_count + + def test_circular(self): + one = af.Model(af.m.MockClassx2) + + one.one = af.Model(af.m.MockClassx2) + one.one.one = one + + # noinspection PyUnresolvedReferences + assert one.prior_count == one.one.prior_count + assert copy.deepcopy(one).prior_count == one.prior_count + + +def test_composition(simple_model): + assert simple_model.composition == ["simple.one", "simple.two"] diff --git a/test_autofit/mapper/prior/test_arithmetic.py b/test_autofit/mapper/prior/test_arithmetic.py index ea77bd534..bef34f2d4 100644 --- a/test_autofit/mapper/prior/test_arithmetic.py +++ b/test_autofit/mapper/prior/test_arithmetic.py @@ -1,177 +1,177 @@ -import math - -import pytest - -import autofit as af -from autofit.mapper.prior.arithmetic.compound import SumPrior - - -@pytest.fixture(name="prior") -def make_prior(): - return af.UniformPrior() - - -class TestAddition: - def test_prior_plus_prior(self, prior): - sum_prior = prior + prior - assert sum_prior.instance_from_unit_vector([1.0]) == 2.0 - - def test_negative_prior(self, prior): - negative = -prior - assert negative.instance_from_unit_vector([1.0]) == -1.0 - - def test_prior_minus_prior(self, prior): - sum_prior = prior - prior - assert sum_prior.instance_from_unit_vector([1.0]) == 0.0 - - def test_prior_plus_float(self, prior): - sum_prior = prior + 1.0 - assert sum_prior.instance_from_unit_vector([1.0]) == 2.0 - - def test_float_plus_prior(self, prior): - sum_prior = 1.0 + prior - assert sum_prior.instance_from_unit_vector([1.0]) == 2.0 - - -class TestMultiplication: - def test_prior_times_prior(self, prior): - multiple_prior = (prior + prior) * (prior + prior) - assert multiple_prior.instance_from_unit_vector([1.0]) == 4 - - def test_prior_times_float(self, prior): - multiple_prior = prior * 2.0 - assert multiple_prior.instance_from_unit_vector([1.0]) == 2.0 - - def test_float_times_prior(self, prior): - multiple_prior = 2.0 * prior - assert multiple_prior.instance_from_unit_vector([1.0]) == 2.0 - - -class TestDivision: - def test_prior_over_prior(self, prior): - division_prior = prior / prior - assert ( - division_prior.instance_from_unit_vector([0.5]) - == 1 - ) - - def test_prior_over_float(self, prior): - division_prior = prior / 2 - assert division_prior.instance_from_unit_vector([1.0]) == 0.5 - - def test_float_over_prior(self, prior): - division_prior = 4.0 / prior - assert division_prior.instance_from_unit_vector([0.5]) == 8.0 - - -@pytest.fixture(name="ten_prior") -def make_ten_prior(): - return af.UniformPrior(lower_limit=0.0, upper_limit=10.0) - - -class TestFloorDiv: - def test_prior_over_int(self, ten_prior): - division_prior = ten_prior // 2 - assert ( - division_prior.instance_from_unit_vector([0.5]) - == 2.0 - ) - - def test_int_over_prior(self, ten_prior): - division_prior = 3 // ten_prior - assert ( - division_prior.instance_from_unit_vector([0.2]) - == 1.0 - ) - - -class TestMod: - def test_prior_mod_int(self, ten_prior): - mod_prior = ten_prior % 3 - assert ( - mod_prior.instance_from_unit_vector([0.5]) == 2.0 - ) - - def test_int_mod_prior(self, ten_prior): - mod_prior = 5.0 % ten_prior - assert mod_prior.instance_from_unit_vector( - [0.3] - ) == pytest.approx(2.0) - - -def test_abs(prior): - prior = af.UniformPrior(-1, 0) - assert prior.value_for(0.0) == -1 - prior = abs(prior) - assert prior.instance_from_unit_vector([0.0]) == 1.0 - - -class TestPowers: - def test_prior_to_prior(self, ten_prior): - power_prior = ten_prior ** ten_prior - assert power_prior.instance_from_unit_vector( - [0.2] - ) == pytest.approx(4.0) - - def test_prior_to_float(self, ten_prior): - power_prior = ten_prior ** 3 - assert power_prior.instance_from_unit_vector( - [0.2] - ) == pytest.approx(8.0) - - def test_float_to_prior(self, ten_prior): - power_prior = 3.0 ** ten_prior - assert power_prior.instance_from_unit_vector( - [0.2] - ) == pytest.approx(9.0) - - -class TestInequality: - def test_prior_lt_prior(self, prior): - inequality_prior = (prior * prior) < prior - result = inequality_prior.instance_from_unit_vector( - [0.5] - ) - assert result - inequality_prior = (prior * prior) > prior - assert not ( - inequality_prior.instance_from_unit_vector([0.5]) - ) - - -@pytest.mark.parametrize("multiplier, value", [(math.e, 1), (math.e ** 2, 2), (1, 0)]) -def test_log(multiplier, value, prior): - assert af.Log(multiplier * prior).instance_from_unit_vector([1.0]) == pytest.approx( - value - ) - - -@pytest.mark.parametrize("multiplier, value", [(10, 1), (1, 0), (100, 2), (1000, 3),]) -def test_log_10(multiplier, value, prior): - assert af.Log10(multiplier * prior).instance_from_unit_vector( - [1.0] - ) == pytest.approx(value) - - -@pytest.fixture(name="sum_prior") -def make_sum_prior(prior): - return prior + prior - - -@pytest.fixture(name="int_minus_prior") -def make_int_minus_prior(sum_prior): - return 2 - sum_prior - - -def test_int_minus(int_minus_prior): - assert isinstance(int_minus_prior, SumPrior) - assert int_minus_prior.instance_from_prior_medians() == 1.0 - - -def test_class_prior_dict(int_minus_prior, prior): - collection = af.Collection(int_minus_prior) - assert collection.prior_class_dict == {prior: float} - - -def test_int_divide(sum_prior): - assert (2 / sum_prior).instance_from_prior_medians() == 2.0 +import math + +import pytest + +import autofit as af +from autofit.mapper.prior.arithmetic.compound import SumPrior + + +@pytest.fixture(name="prior") +def make_prior(): + return af.UniformPrior() + + +class TestAddition: + def test_prior_plus_prior(self, prior): + sum_prior = prior + prior + assert sum_prior.instance_from_unit_vector([1.0]) == 2.0 + + def test_negative_prior(self, prior): + negative = -prior + assert negative.instance_from_unit_vector([1.0]) == -1.0 + + def test_prior_minus_prior(self, prior): + sum_prior = prior - prior + assert sum_prior.instance_from_unit_vector([1.0]) == 0.0 + + def test_prior_plus_float(self, prior): + sum_prior = prior + 1.0 + assert sum_prior.instance_from_unit_vector([1.0]) == 2.0 + + def test_float_plus_prior(self, prior): + sum_prior = 1.0 + prior + assert sum_prior.instance_from_unit_vector([1.0]) == 2.0 + + +class TestMultiplication: + def test_prior_times_prior(self, prior): + multiple_prior = (prior + prior) * (prior + prior) + assert multiple_prior.instance_from_unit_vector([1.0]) == 4 + + def test_prior_times_float(self, prior): + multiple_prior = prior * 2.0 + assert multiple_prior.instance_from_unit_vector([1.0]) == 2.0 + + def test_float_times_prior(self, prior): + multiple_prior = 2.0 * prior + assert multiple_prior.instance_from_unit_vector([1.0]) == 2.0 + + +class TestDivision: + def test_prior_over_prior(self, prior): + division_prior = prior / prior + assert ( + division_prior.instance_from_unit_vector([0.5]) + == 1 + ) + + def test_prior_over_float(self, prior): + division_prior = prior / 2 + assert division_prior.instance_from_unit_vector([1.0]) == 0.5 + + def test_float_over_prior(self, prior): + division_prior = 4.0 / prior + assert division_prior.instance_from_unit_vector([0.5]) == 8.0 + + +@pytest.fixture(name="ten_prior") +def make_ten_prior(): + return af.UniformPrior(lower_limit=0.0, upper_limit=10.0) + + +class TestFloorDiv: + def test_prior_over_int(self, ten_prior): + division_prior = ten_prior // 2 + assert ( + division_prior.instance_from_unit_vector([0.5]) + == 2.0 + ) + + def test_int_over_prior(self, ten_prior): + division_prior = 3 // ten_prior + assert ( + division_prior.instance_from_unit_vector([0.2]) + == 1.0 + ) + + +class TestMod: + def test_prior_mod_int(self, ten_prior): + mod_prior = ten_prior % 3 + assert ( + mod_prior.instance_from_unit_vector([0.5]) == 2.0 + ) + + def test_int_mod_prior(self, ten_prior): + mod_prior = 5.0 % ten_prior + assert mod_prior.instance_from_unit_vector( + [0.3] + ) == pytest.approx(2.0) + + +def test_abs(prior): + prior = af.UniformPrior(-1, 0) + assert prior.value_for(0.0) == -1 + prior = abs(prior) + assert prior.instance_from_unit_vector([0.0]) == 1.0 + + +class TestPowers: + def test_prior_to_prior(self, ten_prior): + power_prior = ten_prior ** ten_prior + assert power_prior.instance_from_unit_vector( + [0.2] + ) == pytest.approx(4.0) + + def test_prior_to_float(self, ten_prior): + power_prior = ten_prior ** 3 + assert power_prior.instance_from_unit_vector( + [0.2] + ) == pytest.approx(8.0) + + def test_float_to_prior(self, ten_prior): + power_prior = 3.0 ** ten_prior + assert power_prior.instance_from_unit_vector( + [0.2] + ) == pytest.approx(9.0) + + +class TestInequality: + def test_prior_lt_prior(self, prior): + inequality_prior = (prior * prior) < prior + result = inequality_prior.instance_from_unit_vector( + [0.5] + ) + assert result + inequality_prior = (prior * prior) > prior + assert not ( + inequality_prior.instance_from_unit_vector([0.5]) + ) + + +@pytest.mark.parametrize("multiplier, value", [(math.e, 1), (math.e ** 2, 2), (1, 0)]) +def test_log(multiplier, value, prior): + assert af.Log(multiplier * prior).instance_from_unit_vector([1.0]) == pytest.approx( + value + ) + + +@pytest.mark.parametrize("multiplier, value", [(10, 1), (1, 0), (100, 2), (1000, 3),]) +def test_log_10(multiplier, value, prior): + assert af.Log10(multiplier * prior).instance_from_unit_vector( + [1.0] + ) == pytest.approx(value) + + +@pytest.fixture(name="sum_prior") +def make_sum_prior(prior): + return prior + prior + + +@pytest.fixture(name="int_minus_prior") +def make_int_minus_prior(sum_prior): + return 2 - sum_prior + + +def test_int_minus(int_minus_prior): + assert isinstance(int_minus_prior, SumPrior) + assert int_minus_prior.instance_from_prior_medians() == 1.0 + + +def test_class_prior_dict(int_minus_prior, prior): + collection = af.Collection(int_minus_prior) + assert collection.prior_class_dict == {prior: float} + + +def test_int_divide(sum_prior): + assert (2 / sum_prior).instance_from_prior_medians() == 2.0 diff --git a/test_autofit/mapper/prior/test_assertion.py b/test_autofit/mapper/prior/test_assertion.py index dd96ce1ff..95dc61f10 100644 --- a/test_autofit/mapper/prior/test_assertion.py +++ b/test_autofit/mapper/prior/test_assertion.py @@ -1,120 +1,120 @@ -import pytest - -import autofit as af -from autofit import exc - - -@pytest.fixture(name="prior_1") -def make_prior_1(): - return af.UniformPrior() - - -@pytest.fixture(name="prior_2") -def make_prior_2(): - return af.UniformPrior() - - -@pytest.fixture(name="lower_assertion") -def make_lower_assertion(prior_1, prior_2): - return prior_1 < prior_2 - - -@pytest.fixture(name="greater_assertion") -def make_greater_assertion(prior_1, prior_2): - return prior_1 > prior_2 - - -def test_as_argument(prior_1, prior_2): - model = af.Collection(truth=prior_1 < prior_2) - - result = model.instance_for_arguments({prior_1: 0, prior_2: 1}) - assert result.truth is True - - result = model.instance_for_arguments({prior_1: 1, prior_2: 0}) - assert result.truth is False - - -class TestAssertion: - def test_lower_equal_assertion(self, prior_1, prior_2): - assertion = prior_1 <= prior_2 - assert assertion.instance_for_arguments({prior_1: 0.4, prior_2: 0.5}) is True - assert assertion.instance_for_arguments({prior_1: 0.5, prior_2: 0.5}) is True - assert assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) is False - - def test_greater_equal_assertion(self, prior_1, prior_2): - assertion = prior_1 >= prior_2 - assert assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) is True - assert assertion.instance_for_arguments({prior_1: 0.5, prior_2: 0.5}) is True - - assert assertion.instance_for_arguments({prior_1: 0.4, prior_2: 0.5}) is False - - def test_assert_on_arguments_lower(self, lower_assertion, prior_1, prior_2): - assert ( - lower_assertion.instance_for_arguments({prior_1: 0.3, prior_2: 0.5}) is True - ) - assert ( - lower_assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) - is False - ) - - def test_assert_on_arguments_greater(self, greater_assertion, prior_1, prior_2): - assert ( - greater_assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) - is True - ) - assert ( - greater_assertion.instance_for_arguments({prior_1: 0.3, prior_2: 0.5}) - is False - ) - - def test_numerical_assertion(self, prior_1): - assertion = prior_1 < 0.5 - - assert assertion.instance_for_arguments({prior_1: 0.4}) is True - assert assertion.instance_for_arguments({prior_1: 0.6}) is False - - def test_numerical_assertion_left(self, prior_1): - assertion = 0.5 < prior_1 - - assert assertion.instance_for_arguments({prior_1: 0.6}) is True - assert assertion.instance_for_arguments({prior_1: 0.4}) is False - assert assertion.instance_for_arguments({prior_1: 0.5}) is False - - def test_compound_assertion(self, prior_1): - assertion = (0.2 < prior_1) < 0.5 - assert assertion.instance_for_arguments({prior_1: 0.3}) is True - assert assertion.instance_for_arguments({prior_1: 0.1}) is False - assert assertion.instance_for_arguments({prior_1: 0.6}) is False - - -@pytest.fixture(name="promise_model") -def make_promise_model(phase): - return phase.result.model.one.component - - -@pytest.fixture(name="model") -def make_model(collection): - return collection.last.model.one.component - - -class TestModel: - def test_assertion_in_model(self, prior_1, prior_2): - model = af.ModelMapper() - model.one = prior_1 - model.two = prior_2 - - model.add_assertion(prior_1 < prior_2) - - model.instance_from_unit_vector([0.1, 0.2]) - with pytest.raises(af.exc.FitException): - model.instance_from_unit_vector([0.2, 0.1]) - - def test_numerical(self): - model = af.ModelMapper() - model.add_assertion(True) - model.instance_from_unit_vector([]) - - model = af.ModelMapper() - model.add_assertion(False) - with pytest.raises(exc.FitException): - model.instance_from_unit_vector([]) +import pytest + +import autofit as af +from autofit import exc + + +@pytest.fixture(name="prior_1") +def make_prior_1(): + return af.UniformPrior() + + +@pytest.fixture(name="prior_2") +def make_prior_2(): + return af.UniformPrior() + + +@pytest.fixture(name="lower_assertion") +def make_lower_assertion(prior_1, prior_2): + return prior_1 < prior_2 + + +@pytest.fixture(name="greater_assertion") +def make_greater_assertion(prior_1, prior_2): + return prior_1 > prior_2 + + +def test_as_argument(prior_1, prior_2): + model = af.Collection(truth=prior_1 < prior_2) + + result = model.instance_for_arguments({prior_1: 0, prior_2: 1}) + assert result.truth is True + + result = model.instance_for_arguments({prior_1: 1, prior_2: 0}) + assert result.truth is False + + +class TestAssertion: + def test_lower_equal_assertion(self, prior_1, prior_2): + assertion = prior_1 <= prior_2 + assert assertion.instance_for_arguments({prior_1: 0.4, prior_2: 0.5}) is True + assert assertion.instance_for_arguments({prior_1: 0.5, prior_2: 0.5}) is True + assert assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) is False + + def test_greater_equal_assertion(self, prior_1, prior_2): + assertion = prior_1 >= prior_2 + assert assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) is True + assert assertion.instance_for_arguments({prior_1: 0.5, prior_2: 0.5}) is True + + assert assertion.instance_for_arguments({prior_1: 0.4, prior_2: 0.5}) is False + + def test_assert_on_arguments_lower(self, lower_assertion, prior_1, prior_2): + assert ( + lower_assertion.instance_for_arguments({prior_1: 0.3, prior_2: 0.5}) is True + ) + assert ( + lower_assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) + is False + ) + + def test_assert_on_arguments_greater(self, greater_assertion, prior_1, prior_2): + assert ( + greater_assertion.instance_for_arguments({prior_1: 0.6, prior_2: 0.5}) + is True + ) + assert ( + greater_assertion.instance_for_arguments({prior_1: 0.3, prior_2: 0.5}) + is False + ) + + def test_numerical_assertion(self, prior_1): + assertion = prior_1 < 0.5 + + assert assertion.instance_for_arguments({prior_1: 0.4}) is True + assert assertion.instance_for_arguments({prior_1: 0.6}) is False + + def test_numerical_assertion_left(self, prior_1): + assertion = 0.5 < prior_1 + + assert assertion.instance_for_arguments({prior_1: 0.6}) is True + assert assertion.instance_for_arguments({prior_1: 0.4}) is False + assert assertion.instance_for_arguments({prior_1: 0.5}) is False + + def test_compound_assertion(self, prior_1): + assertion = (0.2 < prior_1) < 0.5 + assert assertion.instance_for_arguments({prior_1: 0.3}) is True + assert assertion.instance_for_arguments({prior_1: 0.1}) is False + assert assertion.instance_for_arguments({prior_1: 0.6}) is False + + +@pytest.fixture(name="promise_model") +def make_promise_model(phase): + return phase.result.model.one.component + + +@pytest.fixture(name="model") +def make_model(collection): + return collection.last.model.one.component + + +class TestModel: + def test_assertion_in_model(self, prior_1, prior_2): + model = af.ModelMapper() + model.one = prior_1 + model.two = prior_2 + + model.add_assertion(prior_1 < prior_2) + + model.instance_from_unit_vector([0.1, 0.2]) + with pytest.raises(af.exc.FitException): + model.instance_from_unit_vector([0.2, 0.1]) + + def test_numerical(self): + model = af.ModelMapper() + model.add_assertion(True) + model.instance_from_unit_vector([]) + + model = af.ModelMapper() + model.add_assertion(False) + with pytest.raises(exc.FitException): + model.instance_from_unit_vector([]) diff --git a/test_autofit/mapper/prior/test_prior.py b/test_autofit/mapper/prior/test_prior.py index 730a95073..64f9702c1 100644 --- a/test_autofit/mapper/prior/test_prior.py +++ b/test_autofit/mapper/prior/test_prior.py @@ -1,300 +1,300 @@ -import math -import warnings - -import numpy as np -import pytest - -import autofit as af -from autofit import exc - - -class TestPriorLimits: - def test_out_of_order_prior_limits(self): - with pytest.raises(af.exc.PriorException): - af.UniformPrior(1.0, 0) - - def test_prior_creation(self): - mapper = af.ModelMapper() - mapper.component = af.m.MockClassx2 - - prior_tuples = mapper.prior_tuples_ordered_by_id - - assert prior_tuples[0].prior.lower_limit == 0 - assert prior_tuples[0].prior.upper_limit == 1 - - assert prior_tuples[1].prior.lower_limit == 0 - assert prior_tuples[1].prior.upper_limit == 2 - - def test_inf(self): - mm = af.ModelMapper() - mm.mock_class_inf = af.m.MockClassInf - - prior_tuples = mm.prior_tuples_ordered_by_id - - assert prior_tuples[0].prior.lower_limit == float("-inf") - assert prior_tuples[0].prior.upper_limit == 0 - - assert prior_tuples[1].prior.lower_limit == 0 - assert prior_tuples[1].prior.upper_limit == float("inf") - - assert mm.instance_from_vector([-10000, 10000]) is not None - - def test_preserve_limits_tuples(self): - mm = af.ModelMapper() - mm.mock_class_gaussian = af.m.MockClassx2 - - new_mapper = mm.mapper_from_prior_means( - means=[0.0, 0.0], - ) - - prior_tuples = new_mapper.prior_tuples_ordered_by_id - - assert prior_tuples[0].prior.lower_limit == 0 - assert prior_tuples[0].prior.upper_limit == 1 - - assert prior_tuples[1].prior.lower_limit == 0 - assert prior_tuples[1].prior.upper_limit == 2 - - def test__only_use_widths_to_pass_priors(self): - mm = af.ModelMapper() - mm.mock_class_gaussian = af.m.MockClassx2 - - new_mapper = mm.mapper_from_prior_means( - means=[5.0, 5.0], - ) - - prior_tuples = new_mapper.prior_tuples_ordered_by_id - - assert prior_tuples[0].prior.mean == 5.0 - assert prior_tuples[0].prior.sigma == 1.0 - - assert prior_tuples[1].prior.mean == 5.0 - assert prior_tuples[1].prior.sigma == 2.0 - - def test_from_gaussian_no_limits(self): - mm = af.ModelMapper() - mm.mock_class_gaussian = af.m.MockClassx2 - - new_mapper = mm.mapper_from_prior_means( - [(0.0, 0.5), (0.0, 1)], no_limits=True - ) - - priors = new_mapper.priors - assert priors[0].lower_limit == float("-inf") - assert priors[0].upper_limit == float("inf") - assert priors[1].lower_limit == float("-inf") - assert priors[1].upper_limit == float("inf") - - -class TestPriorMean: - def test_simple(self): - uniform_prior = af.UniformPrior(0.0, 1.0) - assert uniform_prior.mean == 0.5 - - def test_higher(self): - uniform_prior = af.UniformPrior(1.0, 2.0) - assert uniform_prior.mean == 1.5 - - -class TestAddition: - def test_abstract_plus_abstract(self): - one = af.AbstractModel() - two = af.AbstractModel() - one.a = "a" - two.b = "b" - - three = one + two - - assert three.a == "a" - assert three.b == "b" - - def test_list_properties(self): - one = af.AbstractModel() - two = af.AbstractModel() - one.a = ["a"] - two.a = ["b"] - - three = one + two - - assert three.a == ["a", "b"] - - def test_instance_plus_instance(self): - one = af.ModelInstance() - two = af.ModelInstance() - one.a = "a" - two.b = "b" - - three = one + two - - assert three.a == "a" - assert three.b == "b" - - def test_mapper_plus_mapper(self): - one = af.ModelMapper() - two = af.ModelMapper() - one.a = af.Model(af.m.MockClassx2) - two.b = af.Model(af.m.MockClassx2) - - three = one + two - - assert three.prior_count == 4 - - -class TestUniformPrior: - def test__simple_assumptions(self): - uniform_simple = af.UniformPrior(lower_limit=0.0, upper_limit=1.0) - - assert uniform_simple.value_for(0.0) == 0.0 - assert uniform_simple.value_for(1.0) == 1.0 - assert uniform_simple.value_for(0.5) == 0.5 - - def test__non_zero_lower_limit(self): - uniform_half = af.UniformPrior(lower_limit=0.5, upper_limit=1.0) - - assert uniform_half.value_for(0.0) == 0.5 - assert uniform_half.value_for(1.0) == 1.0 - assert uniform_half.value_for(0.5) == 0.75 - - def test_width(self): - assert af.UniformPrior(2, 5).width == 3 - - def test_negative_range(self): - prior = af.UniformPrior(-1, 0) - assert prior.width == 1 - assert prior.value_for(0.0) == -1 - assert prior.value_for(1.0) == 0.0 - - def test__log_prior_from_value(self): - gaussian_simple = af.UniformPrior(lower_limit=-40, upper_limit=70) - - log_prior = gaussian_simple.log_prior_from_value(value=0.0) - - assert log_prior == 0.0 - - log_prior = gaussian_simple.log_prior_from_value(value=11.0) - - assert log_prior == 0.0 - - -class TestLogUniformPrior: - def test__simple_assumptions(self): - log_uniform_simple = af.LogUniformPrior(lower_limit=1.0e-8, upper_limit=1.0) - - assert log_uniform_simple.value_for(0.0) == 1.0e-8 - assert log_uniform_simple.value_for(1.0) == 1.0 - assert log_uniform_simple.value_for(0.5) == pytest.approx(0.0001, abs=0.000001) - - def test__non_zero_lower_limit(self): - log_uniform_half = af.LogUniformPrior(lower_limit=0.5, upper_limit=1.0) - - assert log_uniform_half.value_for(0.0) == 0.5 - assert log_uniform_half.value_for(1.0) == 1.0 - assert log_uniform_half.value_for(0.5) == pytest.approx(0.70710678118, 1.0e-4) - - def test__log_prior_from_value(self): - # LogUniformPrior log-density: -log(value), dropping the normalisation - # constant -log(log(upper / lower)). Consistent with UniformPrior's - # convention of returning 0.0 (dropping -log(b - a)). - log_uniform = af.LogUniformPrior(lower_limit=1e-8, upper_limit=1.0) - - assert log_uniform.log_prior_from_value(value=1.0) == 0.0 - assert log_uniform.log_prior_from_value(value=2.0) == pytest.approx( - -np.log(2.0), 1.0e-12 - ) - assert log_uniform.log_prior_from_value(value=4.0) == pytest.approx( - -np.log(4.0), 1.0e-12 - ) - - # The normalisation constant being dropped means the returned values - # do NOT depend on the (lower_limit, upper_limit) pair — only on `value`. - log_uniform = af.LogUniformPrior(lower_limit=50.0, upper_limit=100.0) - - assert log_uniform.log_prior_from_value(value=1.0) == 0.0 - assert log_uniform.log_prior_from_value(value=2.0) == pytest.approx( - -np.log(2.0), 1.0e-12 - ) - assert log_uniform.log_prior_from_value(value=4.0) == pytest.approx( - -np.log(4.0), 1.0e-12 - ) - - def test__log_prior_from_value__non_positive_returns_neg_inf(self): - # Regression (PyAutoHeart #27 / release run 28784914443): Emcee's stretch - # move proposes physical values that can go non-positive for a LogUniform - # parameter. `-log(value)` of a non-positive value is NaN, which propagated - # into the summed figure-of-merit and crashed the search with - # "ValueError: Probability function returned NaN". Non-positive values must - # return -inf (zero density -> the move is rejected), and evaluating the - # log-prior must not emit a NumPy "invalid value in log" RuntimeWarning. - log_uniform = af.LogUniformPrior(lower_limit=1e-3, upper_limit=1e3) - - with warnings.catch_warnings(): - warnings.simplefilter("error", category=RuntimeWarning) - assert log_uniform.log_prior_from_value(value=-1.0) == float("-inf") - assert log_uniform.log_prior_from_value(value=0.0) == float("-inf") - - # Positive values are unchanged: the NumPy path stays unnormalised and - # unbounded, returning -log(value) regardless of (lower_limit, upper_limit). - assert log_uniform.log_prior_from_value(value=10.0) == pytest.approx( - -np.log(10.0), 1.0e-12 - ) - assert log_uniform.log_prior_from_value(value=1.0e4) == pytest.approx( - -np.log(1.0e4), 1.0e-12 - ) - - def test__lower_limit_zero_or_below_raises_error(self): - with pytest.raises(exc.PriorException): - af.LogUniformPrior(lower_limit=-1.0, upper_limit=1.0) - - with pytest.raises(exc.PriorException): - af.LogUniformPrior(lower_limit=0.0, upper_limit=1.0) - - -class TestGaussianPrior: - def test__simple_assumptions(self): - gaussian_simple = af.GaussianPrior(mean=0.0, sigma=1.0) - - assert gaussian_simple.value_for(0.1) == pytest.approx(-1.281551, 1.0e-4) - assert gaussian_simple.value_for(0.9) == pytest.approx(1.281551, 1.0e-4) - assert gaussian_simple.value_for(0.5) == 0.0 - - def test__non_zero_mean(self): - gaussian_half = af.GaussianPrior(mean=0.5, sigma=2.0) - - assert gaussian_half.value_for(0.1) == pytest.approx(-2.0631031, 1.0e-4) - assert gaussian_half.value_for(0.9) == pytest.approx(3.0631031, 1.0e-4) - assert gaussian_half.value_for(0.5) == 0.5 - - @pytest.mark.parametrize( - "mean, sigma, value, expected", - [ - # Density-form log-prior: -(value - mean)**2 / (2 * sigma**2), with - # the -log(sigma * sqrt(2 * pi)) normalisation constant dropped. - # Maximum at value == mean (returns 0), negative elsewhere. - (0.0, 1.0, 0.0, 0.0), - (0.0, 1.0, 1.0, -0.5), - (0.0, 1.0, 2.0, -2.0), - (1.0, 2.0, 0.0, -0.125), - (1.0, 2.0, 1.0, 0.0), - (1.0, 2.0, 2.0, -0.125), - (30.0, 60.0, 2.0, pytest.approx(-0.108888, 1.0e-4)), - ], - ) - def test__log_prior_from_value(self, mean, sigma, value, expected): - gaussian = af.GaussianPrior(mean=mean, sigma=sigma) - log_prior = gaussian.log_prior_from_value(value=value) - assert log_prior == expected - - -def test_log_gaussian_prior_log_prior_from_value(): - log_gaussian_prior = af.LogGaussianPrior( - mean=0.0, sigma=1.0, - ) - - assert log_gaussian_prior.log_prior_from_value(value=0.0) == float("-inf") - # Density form: -(log(value) - mean)**2 / (2 * sigma**2) - log(value), - # where the second term is the Jacobian of the log-space transform. - log_half = math.log(0.5) - expected = -(log_half ** 2) / 2.0 - log_half - assert log_gaussian_prior.log_prior_from_value(value=0.5) == pytest.approx( - expected, 1.0e-12 - ) +import math +import warnings + +import numpy as np +import pytest + +import autofit as af +from autofit import exc + + +class TestPriorLimits: + def test_out_of_order_prior_limits(self): + with pytest.raises(af.exc.PriorException): + af.UniformPrior(1.0, 0) + + def test_prior_creation(self): + mapper = af.ModelMapper() + mapper.component = af.m.MockClassx2 + + prior_tuples = mapper.prior_tuples_ordered_by_id + + assert prior_tuples[0].prior.lower_limit == 0 + assert prior_tuples[0].prior.upper_limit == 1 + + assert prior_tuples[1].prior.lower_limit == 0 + assert prior_tuples[1].prior.upper_limit == 2 + + def test_inf(self): + mm = af.ModelMapper() + mm.mock_class_inf = af.m.MockClassInf + + prior_tuples = mm.prior_tuples_ordered_by_id + + assert prior_tuples[0].prior.lower_limit == float("-inf") + assert prior_tuples[0].prior.upper_limit == 0 + + assert prior_tuples[1].prior.lower_limit == 0 + assert prior_tuples[1].prior.upper_limit == float("inf") + + assert mm.instance_from_vector([-10000, 10000]) is not None + + def test_preserve_limits_tuples(self): + mm = af.ModelMapper() + mm.mock_class_gaussian = af.m.MockClassx2 + + new_mapper = mm.mapper_from_prior_means( + means=[0.0, 0.0], + ) + + prior_tuples = new_mapper.prior_tuples_ordered_by_id + + assert prior_tuples[0].prior.lower_limit == 0 + assert prior_tuples[0].prior.upper_limit == 1 + + assert prior_tuples[1].prior.lower_limit == 0 + assert prior_tuples[1].prior.upper_limit == 2 + + def test__only_use_widths_to_pass_priors(self): + mm = af.ModelMapper() + mm.mock_class_gaussian = af.m.MockClassx2 + + new_mapper = mm.mapper_from_prior_means( + means=[5.0, 5.0], + ) + + prior_tuples = new_mapper.prior_tuples_ordered_by_id + + assert prior_tuples[0].prior.mean == 5.0 + assert prior_tuples[0].prior.sigma == 1.0 + + assert prior_tuples[1].prior.mean == 5.0 + assert prior_tuples[1].prior.sigma == 2.0 + + def test_from_gaussian_no_limits(self): + mm = af.ModelMapper() + mm.mock_class_gaussian = af.m.MockClassx2 + + new_mapper = mm.mapper_from_prior_means( + [(0.0, 0.5), (0.0, 1)], no_limits=True + ) + + priors = new_mapper.priors + assert priors[0].lower_limit == float("-inf") + assert priors[0].upper_limit == float("inf") + assert priors[1].lower_limit == float("-inf") + assert priors[1].upper_limit == float("inf") + + +class TestPriorMean: + def test_simple(self): + uniform_prior = af.UniformPrior(0.0, 1.0) + assert uniform_prior.mean == 0.5 + + def test_higher(self): + uniform_prior = af.UniformPrior(1.0, 2.0) + assert uniform_prior.mean == 1.5 + + +class TestAddition: + def test_abstract_plus_abstract(self): + one = af.AbstractModel() + two = af.AbstractModel() + one.a = "a" + two.b = "b" + + three = one + two + + assert three.a == "a" + assert three.b == "b" + + def test_list_properties(self): + one = af.AbstractModel() + two = af.AbstractModel() + one.a = ["a"] + two.a = ["b"] + + three = one + two + + assert three.a == ["a", "b"] + + def test_instance_plus_instance(self): + one = af.ModelInstance() + two = af.ModelInstance() + one.a = "a" + two.b = "b" + + three = one + two + + assert three.a == "a" + assert three.b == "b" + + def test_mapper_plus_mapper(self): + one = af.ModelMapper() + two = af.ModelMapper() + one.a = af.Model(af.m.MockClassx2) + two.b = af.Model(af.m.MockClassx2) + + three = one + two + + assert three.prior_count == 4 + + +class TestUniformPrior: + def test__simple_assumptions(self): + uniform_simple = af.UniformPrior(lower_limit=0.0, upper_limit=1.0) + + assert uniform_simple.value_for(0.0) == 0.0 + assert uniform_simple.value_for(1.0) == 1.0 + assert uniform_simple.value_for(0.5) == 0.5 + + def test__non_zero_lower_limit(self): + uniform_half = af.UniformPrior(lower_limit=0.5, upper_limit=1.0) + + assert uniform_half.value_for(0.0) == 0.5 + assert uniform_half.value_for(1.0) == 1.0 + assert uniform_half.value_for(0.5) == 0.75 + + def test_width(self): + assert af.UniformPrior(2, 5).width == 3 + + def test_negative_range(self): + prior = af.UniformPrior(-1, 0) + assert prior.width == 1 + assert prior.value_for(0.0) == -1 + assert prior.value_for(1.0) == 0.0 + + def test__log_prior_from_value(self): + gaussian_simple = af.UniformPrior(lower_limit=-40, upper_limit=70) + + log_prior = gaussian_simple.log_prior_from_value(value=0.0) + + assert log_prior == 0.0 + + log_prior = gaussian_simple.log_prior_from_value(value=11.0) + + assert log_prior == 0.0 + + +class TestLogUniformPrior: + def test__simple_assumptions(self): + log_uniform_simple = af.LogUniformPrior(lower_limit=1.0e-8, upper_limit=1.0) + + assert log_uniform_simple.value_for(0.0) == 1.0e-8 + assert log_uniform_simple.value_for(1.0) == 1.0 + assert log_uniform_simple.value_for(0.5) == pytest.approx(0.0001, abs=0.000001) + + def test__non_zero_lower_limit(self): + log_uniform_half = af.LogUniformPrior(lower_limit=0.5, upper_limit=1.0) + + assert log_uniform_half.value_for(0.0) == 0.5 + assert log_uniform_half.value_for(1.0) == 1.0 + assert log_uniform_half.value_for(0.5) == pytest.approx(0.70710678118, 1.0e-4) + + def test__log_prior_from_value(self): + # LogUniformPrior log-density: -log(value), dropping the normalisation + # constant -log(log(upper / lower)). Consistent with UniformPrior's + # convention of returning 0.0 (dropping -log(b - a)). + log_uniform = af.LogUniformPrior(lower_limit=1e-8, upper_limit=1.0) + + assert log_uniform.log_prior_from_value(value=1.0) == 0.0 + assert log_uniform.log_prior_from_value(value=2.0) == pytest.approx( + -np.log(2.0), 1.0e-12 + ) + assert log_uniform.log_prior_from_value(value=4.0) == pytest.approx( + -np.log(4.0), 1.0e-12 + ) + + # The normalisation constant being dropped means the returned values + # do NOT depend on the (lower_limit, upper_limit) pair — only on `value`. + log_uniform = af.LogUniformPrior(lower_limit=50.0, upper_limit=100.0) + + assert log_uniform.log_prior_from_value(value=1.0) == 0.0 + assert log_uniform.log_prior_from_value(value=2.0) == pytest.approx( + -np.log(2.0), 1.0e-12 + ) + assert log_uniform.log_prior_from_value(value=4.0) == pytest.approx( + -np.log(4.0), 1.0e-12 + ) + + def test__log_prior_from_value__non_positive_returns_neg_inf(self): + # Regression (PyAutoHeart #27 / release run 28784914443): Emcee's stretch + # move proposes physical values that can go non-positive for a LogUniform + # parameter. `-log(value)` of a non-positive value is NaN, which propagated + # into the summed figure-of-merit and crashed the search with + # "ValueError: Probability function returned NaN". Non-positive values must + # return -inf (zero density -> the move is rejected), and evaluating the + # log-prior must not emit a NumPy "invalid value in log" RuntimeWarning. + log_uniform = af.LogUniformPrior(lower_limit=1e-3, upper_limit=1e3) + + with warnings.catch_warnings(): + warnings.simplefilter("error", category=RuntimeWarning) + assert log_uniform.log_prior_from_value(value=-1.0) == float("-inf") + assert log_uniform.log_prior_from_value(value=0.0) == float("-inf") + + # Positive values are unchanged: the NumPy path stays unnormalised and + # unbounded, returning -log(value) regardless of (lower_limit, upper_limit). + assert log_uniform.log_prior_from_value(value=10.0) == pytest.approx( + -np.log(10.0), 1.0e-12 + ) + assert log_uniform.log_prior_from_value(value=1.0e4) == pytest.approx( + -np.log(1.0e4), 1.0e-12 + ) + + def test__lower_limit_zero_or_below_raises_error(self): + with pytest.raises(exc.PriorException): + af.LogUniformPrior(lower_limit=-1.0, upper_limit=1.0) + + with pytest.raises(exc.PriorException): + af.LogUniformPrior(lower_limit=0.0, upper_limit=1.0) + + +class TestGaussianPrior: + def test__simple_assumptions(self): + gaussian_simple = af.GaussianPrior(mean=0.0, sigma=1.0) + + assert gaussian_simple.value_for(0.1) == pytest.approx(-1.281551, 1.0e-4) + assert gaussian_simple.value_for(0.9) == pytest.approx(1.281551, 1.0e-4) + assert gaussian_simple.value_for(0.5) == 0.0 + + def test__non_zero_mean(self): + gaussian_half = af.GaussianPrior(mean=0.5, sigma=2.0) + + assert gaussian_half.value_for(0.1) == pytest.approx(-2.0631031, 1.0e-4) + assert gaussian_half.value_for(0.9) == pytest.approx(3.0631031, 1.0e-4) + assert gaussian_half.value_for(0.5) == 0.5 + + @pytest.mark.parametrize( + "mean, sigma, value, expected", + [ + # Density-form log-prior: -(value - mean)**2 / (2 * sigma**2), with + # the -log(sigma * sqrt(2 * pi)) normalisation constant dropped. + # Maximum at value == mean (returns 0), negative elsewhere. + (0.0, 1.0, 0.0, 0.0), + (0.0, 1.0, 1.0, -0.5), + (0.0, 1.0, 2.0, -2.0), + (1.0, 2.0, 0.0, -0.125), + (1.0, 2.0, 1.0, 0.0), + (1.0, 2.0, 2.0, -0.125), + (30.0, 60.0, 2.0, pytest.approx(-0.108888, 1.0e-4)), + ], + ) + def test__log_prior_from_value(self, mean, sigma, value, expected): + gaussian = af.GaussianPrior(mean=mean, sigma=sigma) + log_prior = gaussian.log_prior_from_value(value=value) + assert log_prior == expected + + +def test_log_gaussian_prior_log_prior_from_value(): + log_gaussian_prior = af.LogGaussianPrior( + mean=0.0, sigma=1.0, + ) + + assert log_gaussian_prior.log_prior_from_value(value=0.0) == float("-inf") + # Density form: -(log(value) - mean)**2 / (2 * sigma**2) - log(value), + # where the second term is the Jacobian of the log-space transform. + log_half = math.log(0.5) + expected = -(log_half ** 2) / 2.0 - log_half + assert log_gaussian_prior.log_prior_from_value(value=0.5) == pytest.approx( + expected, 1.0e-12 + ) diff --git a/test_autofit/mapper/prior/test_prior_parsing.py b/test_autofit/mapper/prior/test_prior_parsing.py index 7b4c65246..7804227df 100644 --- a/test_autofit/mapper/prior/test_prior_parsing.py +++ b/test_autofit/mapper/prior/test_prior_parsing.py @@ -1,143 +1,143 @@ -import itertools - -import pytest - -import autofit as af -from autofit.mapper.prior.deferred import DeferredArgument - - -@pytest.fixture(autouse=True) -def reset_prior_count(): - af.Prior._ids = itertools.count() - - -@pytest.fixture(name="uniform_dict") -def make_uniform_dict(): - return {"type": "Uniform", "lower_limit": 2.0, "upper_limit": 3.0} - - -@pytest.fixture(name="uniform_prior") -def make_uniform_prior(uniform_dict): - return af.Prior.from_dict(uniform_dict) - - -@pytest.fixture(name="log_uniform_dict") -def make_log_uniform_dict(): - return {"type": "LogUniform", "lower_limit": 0.2, "upper_limit": 0.3} - - -@pytest.fixture(name="log_uniform_prior") -def make_log_uniform_prior(log_uniform_dict): - return af.Prior.from_dict(log_uniform_dict) - - -@pytest.fixture(name="gaussian_dict") -def make_gaussian_dict(): - return { - "type": "Gaussian", - "mean": 3, - "sigma": 4, - "id": 0, - } - -@pytest.fixture(name="truncated_gaussian_dict") -def make_truncated_gaussian_dict(): - return { - "type": "TruncatedGaussian", - "lower_limit": -10.0, - "upper_limit": 10.0, - "mean": 3, - "sigma": 4, - "id": 0, - } - -@pytest.fixture(name="gaussian_prior") -def make_gaussian_prior(gaussian_dict): - return af.Prior.from_dict(gaussian_dict) - -@pytest.fixture(name="truncated_gaussian_prior") -def make_truncated_gaussian_prior(truncated_gaussian_dict): - return af.Prior.from_dict(truncated_gaussian_dict) - -@pytest.fixture(name="relative_width_dict") -def make_relative_width_dict(): - return {"type": "Relative", "value": 1.0} - - -@pytest.fixture(name="absolute_width_dict") -def make_absolute_width_dict(): - return {"type": "Absolute", "value": 2.0} - - -@pytest.fixture(name="relative_width_modifier") -def make_relative_width_modifier(relative_width_dict): - return af.WidthModifier.from_dict(relative_width_dict) - - -@pytest.fixture(name="absolute_width_modifier") -def make_absolute_width_modifier(absolute_width_dict): - return af.WidthModifier.from_dict(absolute_width_dict) - - -class TestWidth: - def test_relative(self, relative_width_modifier): - assert isinstance(relative_width_modifier, af.RelativeWidthModifier) - assert relative_width_modifier.value == 1.0 - - def test_absolute(self, absolute_width_modifier): - assert isinstance(absolute_width_modifier, af.AbsoluteWidthModifier) - assert absolute_width_modifier.value == 2.0 - - def test_default(self): - modifier = af.WidthModifier.for_class_and_attribute_name( - af.ex.Gaussian, "not_real" - ) - assert modifier.value == 0.5 - assert isinstance(modifier, af.RelativeWidthModifier) - - -class TestDict: - def test_uniform(self, uniform_prior, uniform_dict, remove_ids): - - print(uniform_dict) - print(remove_ids(uniform_prior.dict())) - - assert remove_ids(uniform_prior.dict()) == uniform_dict - - def test_log_uniform(self, log_uniform_prior, log_uniform_dict, remove_ids): - assert remove_ids(log_uniform_prior.dict()) == log_uniform_dict - - def test_gaussian(self, gaussian_prior, gaussian_dict): - assert gaussian_prior.dict() == gaussian_dict - - -class TestFromDict: - def test_uniform(self, uniform_prior): - # assert isinstance(uniform_prior, af.UniformPrior) - assert uniform_prior.lower_limit == 2 - assert uniform_prior.upper_limit == 3 - - def test_log_uniform(self, log_uniform_prior, absolute_width_modifier): - # assert isinstance(log_uniform_prior, af.LogUniformPrior) - assert log_uniform_prior.lower_limit == 0.2 - assert log_uniform_prior.upper_limit == 0.3 - - def test_gaussian(self, gaussian_prior): - assert isinstance(gaussian_prior, af.GaussianPrior) - assert gaussian_prior.mean == 3 - assert gaussian_prior.sigma == 4 - - def test_truncated_gaussian(self, truncated_gaussian_prior): - assert isinstance(truncated_gaussian_prior, af.TruncatedGaussianPrior) - assert truncated_gaussian_prior.lower_limit == -10 - assert truncated_gaussian_prior.upper_limit == 10 - assert truncated_gaussian_prior.mean == 3 - assert truncated_gaussian_prior.sigma == 4 - - def test_constant(self): - result = af.Prior.from_dict({"type": "Constant", "value": 1.5}) - assert result == 1.5 - - def test_deferred(self): - result = af.Prior.from_dict({"type": "Deferred"}) - assert isinstance(result, DeferredArgument) +import itertools + +import pytest + +import autofit as af +from autofit.mapper.prior.deferred import DeferredArgument + + +@pytest.fixture(autouse=True) +def reset_prior_count(): + af.Prior._ids = itertools.count() + + +@pytest.fixture(name="uniform_dict") +def make_uniform_dict(): + return {"type": "Uniform", "lower_limit": 2.0, "upper_limit": 3.0} + + +@pytest.fixture(name="uniform_prior") +def make_uniform_prior(uniform_dict): + return af.Prior.from_dict(uniform_dict) + + +@pytest.fixture(name="log_uniform_dict") +def make_log_uniform_dict(): + return {"type": "LogUniform", "lower_limit": 0.2, "upper_limit": 0.3} + + +@pytest.fixture(name="log_uniform_prior") +def make_log_uniform_prior(log_uniform_dict): + return af.Prior.from_dict(log_uniform_dict) + + +@pytest.fixture(name="gaussian_dict") +def make_gaussian_dict(): + return { + "type": "Gaussian", + "mean": 3, + "sigma": 4, + "id": 0, + } + +@pytest.fixture(name="truncated_gaussian_dict") +def make_truncated_gaussian_dict(): + return { + "type": "TruncatedGaussian", + "lower_limit": -10.0, + "upper_limit": 10.0, + "mean": 3, + "sigma": 4, + "id": 0, + } + +@pytest.fixture(name="gaussian_prior") +def make_gaussian_prior(gaussian_dict): + return af.Prior.from_dict(gaussian_dict) + +@pytest.fixture(name="truncated_gaussian_prior") +def make_truncated_gaussian_prior(truncated_gaussian_dict): + return af.Prior.from_dict(truncated_gaussian_dict) + +@pytest.fixture(name="relative_width_dict") +def make_relative_width_dict(): + return {"type": "Relative", "value": 1.0} + + +@pytest.fixture(name="absolute_width_dict") +def make_absolute_width_dict(): + return {"type": "Absolute", "value": 2.0} + + +@pytest.fixture(name="relative_width_modifier") +def make_relative_width_modifier(relative_width_dict): + return af.WidthModifier.from_dict(relative_width_dict) + + +@pytest.fixture(name="absolute_width_modifier") +def make_absolute_width_modifier(absolute_width_dict): + return af.WidthModifier.from_dict(absolute_width_dict) + + +class TestWidth: + def test_relative(self, relative_width_modifier): + assert isinstance(relative_width_modifier, af.RelativeWidthModifier) + assert relative_width_modifier.value == 1.0 + + def test_absolute(self, absolute_width_modifier): + assert isinstance(absolute_width_modifier, af.AbsoluteWidthModifier) + assert absolute_width_modifier.value == 2.0 + + def test_default(self): + modifier = af.WidthModifier.for_class_and_attribute_name( + af.ex.Gaussian, "not_real" + ) + assert modifier.value == 0.5 + assert isinstance(modifier, af.RelativeWidthModifier) + + +class TestDict: + def test_uniform(self, uniform_prior, uniform_dict, remove_ids): + + print(uniform_dict) + print(remove_ids(uniform_prior.dict())) + + assert remove_ids(uniform_prior.dict()) == uniform_dict + + def test_log_uniform(self, log_uniform_prior, log_uniform_dict, remove_ids): + assert remove_ids(log_uniform_prior.dict()) == log_uniform_dict + + def test_gaussian(self, gaussian_prior, gaussian_dict): + assert gaussian_prior.dict() == gaussian_dict + + +class TestFromDict: + def test_uniform(self, uniform_prior): + # assert isinstance(uniform_prior, af.UniformPrior) + assert uniform_prior.lower_limit == 2 + assert uniform_prior.upper_limit == 3 + + def test_log_uniform(self, log_uniform_prior, absolute_width_modifier): + # assert isinstance(log_uniform_prior, af.LogUniformPrior) + assert log_uniform_prior.lower_limit == 0.2 + assert log_uniform_prior.upper_limit == 0.3 + + def test_gaussian(self, gaussian_prior): + assert isinstance(gaussian_prior, af.GaussianPrior) + assert gaussian_prior.mean == 3 + assert gaussian_prior.sigma == 4 + + def test_truncated_gaussian(self, truncated_gaussian_prior): + assert isinstance(truncated_gaussian_prior, af.TruncatedGaussianPrior) + assert truncated_gaussian_prior.lower_limit == -10 + assert truncated_gaussian_prior.upper_limit == 10 + assert truncated_gaussian_prior.mean == 3 + assert truncated_gaussian_prior.sigma == 4 + + def test_constant(self): + result = af.Prior.from_dict({"type": "Constant", "value": 1.5}) + assert result == 1.5 + + def test_deferred(self): + result = af.Prior.from_dict({"type": "Deferred"}) + assert isinstance(result, DeferredArgument) diff --git a/test_autofit/mapper/prior/test_vectorized.py b/test_autofit/mapper/prior/test_vectorized.py index 63df8d54b..51a1d6b68 100644 --- a/test_autofit/mapper/prior/test_vectorized.py +++ b/test_autofit/mapper/prior/test_vectorized.py @@ -1,91 +1,91 @@ -import numpy as np -import pytest - -import autofit as af - - -class MockModel: - - def __init__(self, priors_ordered_by_id): - - self.priors_ordered_by_id = priors_ordered_by_id - - -@pytest.mark.parametrize( - "lower, upper, unit", - [ - (0.0, 1.0, 0.5), - (1.0, 3.0, 0.25), - (-2.0, 2.0, 0.75), - ] -) -def test__uniform_vectorized_vs_scalar(lower, upper, unit): - - prior = af.UniformPrior(lower_limit=lower, upper_limit=upper) - - # Scalar transform - value = prior.value_for(unit=unit) - - # Vectorized transform - model = MockModel(priors_ordered_by_id=[prior]) - vectorized = af.PriorVectorized(model=model) - value_via_vectorized = vectorized(np.array([[unit]])) - - assert np.allclose(value, value_via_vectorized[0]) - - -@pytest.mark.parametrize( - "mean, sigma, unit", - [ - (0.0, 1.0, 0.5), - (5.0, 2.0, 0.25), - (-3.0, 0.5, 0.75), - ] -) -def test__gaussian_vectorized_vs_scalar(mean, sigma, unit): - prior = af.GaussianPrior(mean=mean, sigma=sigma) - value = prior.value_for(unit) - - model = MockModel(priors_ordered_by_id=[prior]) - vectorized = af.PriorVectorized(model=model) - value_via_vectorized = vectorized(np.array([[unit]])) - - assert np.allclose(value, value_via_vectorized[0]) - - -@pytest.mark.parametrize( - "mean, sigma, lower, upper, unit", - [ - (0.0, 1.0, -1.0, 1.0, 0.5), - (5.0, 2.0, 3.0, 7.0, 0.25), - (-3.0, 0.5, -3.5, -2.5, 0.75), - ] -) -def test__truncated_gaussian_vectorized_vs_scalar(mean, sigma, lower, upper, unit): - prior = af.TruncatedGaussianPrior(mean=mean, sigma=sigma, lower_limit=lower, upper_limit=upper) - value = prior.value_for(unit) - - model = MockModel(priors_ordered_by_id=[prior]) - vectorized = af.PriorVectorized(model=model) - value_via_vectorized = vectorized(np.array([[unit]])) - - assert np.allclose(value, value_via_vectorized[0]) - - -@pytest.mark.parametrize( - "lower, upper, unit", - [ - (1.0, 10.0, 0.5), - (0.1, 100.0, 0.25), - (1e-3, 1e3, 0.75), - ] -) -def test__log_uniform_vectorized_vs_scalar(lower, upper, unit): - prior = af.LogUniformPrior(lower_limit=lower, upper_limit=upper) - value = prior.value_for(unit) - - model = MockModel(priors_ordered_by_id=[prior]) - vectorized = af.PriorVectorized(model=model) - value_via_vectorized = vectorized(np.array([[unit]])) - +import numpy as np +import pytest + +import autofit as af + + +class MockModel: + + def __init__(self, priors_ordered_by_id): + + self.priors_ordered_by_id = priors_ordered_by_id + + +@pytest.mark.parametrize( + "lower, upper, unit", + [ + (0.0, 1.0, 0.5), + (1.0, 3.0, 0.25), + (-2.0, 2.0, 0.75), + ] +) +def test__uniform_vectorized_vs_scalar(lower, upper, unit): + + prior = af.UniformPrior(lower_limit=lower, upper_limit=upper) + + # Scalar transform + value = prior.value_for(unit=unit) + + # Vectorized transform + model = MockModel(priors_ordered_by_id=[prior]) + vectorized = af.PriorVectorized(model=model) + value_via_vectorized = vectorized(np.array([[unit]])) + + assert np.allclose(value, value_via_vectorized[0]) + + +@pytest.mark.parametrize( + "mean, sigma, unit", + [ + (0.0, 1.0, 0.5), + (5.0, 2.0, 0.25), + (-3.0, 0.5, 0.75), + ] +) +def test__gaussian_vectorized_vs_scalar(mean, sigma, unit): + prior = af.GaussianPrior(mean=mean, sigma=sigma) + value = prior.value_for(unit) + + model = MockModel(priors_ordered_by_id=[prior]) + vectorized = af.PriorVectorized(model=model) + value_via_vectorized = vectorized(np.array([[unit]])) + + assert np.allclose(value, value_via_vectorized[0]) + + +@pytest.mark.parametrize( + "mean, sigma, lower, upper, unit", + [ + (0.0, 1.0, -1.0, 1.0, 0.5), + (5.0, 2.0, 3.0, 7.0, 0.25), + (-3.0, 0.5, -3.5, -2.5, 0.75), + ] +) +def test__truncated_gaussian_vectorized_vs_scalar(mean, sigma, lower, upper, unit): + prior = af.TruncatedGaussianPrior(mean=mean, sigma=sigma, lower_limit=lower, upper_limit=upper) + value = prior.value_for(unit) + + model = MockModel(priors_ordered_by_id=[prior]) + vectorized = af.PriorVectorized(model=model) + value_via_vectorized = vectorized(np.array([[unit]])) + + assert np.allclose(value, value_via_vectorized[0]) + + +@pytest.mark.parametrize( + "lower, upper, unit", + [ + (1.0, 10.0, 0.5), + (0.1, 100.0, 0.25), + (1e-3, 1e3, 0.75), + ] +) +def test__log_uniform_vectorized_vs_scalar(lower, upper, unit): + prior = af.LogUniformPrior(lower_limit=lower, upper_limit=upper) + value = prior.value_for(unit) + + model = MockModel(priors_ordered_by_id=[prior]) + vectorized = af.PriorVectorized(model=model) + value_via_vectorized = vectorized(np.array([[unit]])) + assert np.allclose(value, value_via_vectorized[0]) \ No newline at end of file diff --git a/test_autofit/mapper/test_abstract.py b/test_autofit/mapper/test_abstract.py index 9735a29e5..184d239ac 100644 --- a/test_autofit/mapper/test_abstract.py +++ b/test_autofit/mapper/test_abstract.py @@ -1,24 +1,24 @@ -import autofit as af - - -def test_transfer_tuples(): - model = af.ModelMapper() - instance = af.ModelInstance() - - model.profile = af.Model(af.m.MockClassx2Tuple) - assert model.prior_count == 2 - - result = model.copy_with_fixed_priors(instance) - assert result.prior_count == 2 - - instance.profile = af.m.MockClassx2Tuple() - - result = model.copy_with_fixed_priors(instance) - assert result.prior_count == 0 - assert result.profile.one_tuple == (0.0, 0.0) - assert isinstance(result.profile, af.Model) - - instance = result.instance_from_unit_vector([]) - assert result.profile.one_tuple == (0.0, 0.0) - assert isinstance(instance.profile, af.m.MockClassx2Tuple) - +import autofit as af + + +def test_transfer_tuples(): + model = af.ModelMapper() + instance = af.ModelInstance() + + model.profile = af.Model(af.m.MockClassx2Tuple) + assert model.prior_count == 2 + + result = model.copy_with_fixed_priors(instance) + assert result.prior_count == 2 + + instance.profile = af.m.MockClassx2Tuple() + + result = model.copy_with_fixed_priors(instance) + assert result.prior_count == 0 + assert result.profile.one_tuple == (0.0, 0.0) + assert isinstance(result.profile, af.Model) + + instance = result.instance_from_unit_vector([]) + assert result.profile.one_tuple == (0.0, 0.0) + assert isinstance(instance.profile, af.m.MockClassx2Tuple) + diff --git a/test_autofit/mapper/test_recursion.py b/test_autofit/mapper/test_recursion.py index 0cee9f0ce..ef7bb9518 100644 --- a/test_autofit/mapper/test_recursion.py +++ b/test_autofit/mapper/test_recursion.py @@ -1,46 +1,46 @@ -from autofit.mapper.prior_model.recursion import DynamicRecursionCache - - -class Wrapper: - def __init__(self, item): - self.item = item - - -class A: - def __init__(self, b=None): - self.b = b - - -class B: - def __init__(self, a=None): - self.a = a - - -@DynamicRecursionCache() -def dict_recurse(item): - try: - for key, value in item.__dict__.items(): - setattr(item, key, dict_recurse(value)) - except AttributeError: - pass - return Wrapper(item) - - -def test_basic(): - a = A(B()) - a.b.a = a - result = dict_recurse(a) - assert isinstance(result.item.b.item.a, Wrapper) - - -def test_sub_recursion(): - a = A() - b = A() - c = A() - - a.b = b - b.b = c - c.b = b - - result = dict_recurse(a) - assert isinstance(result, Wrapper) +from autofit.mapper.prior_model.recursion import DynamicRecursionCache + + +class Wrapper: + def __init__(self, item): + self.item = item + + +class A: + def __init__(self, b=None): + self.b = b + + +class B: + def __init__(self, a=None): + self.a = a + + +@DynamicRecursionCache() +def dict_recurse(item): + try: + for key, value in item.__dict__.items(): + setattr(item, key, dict_recurse(value)) + except AttributeError: + pass + return Wrapper(item) + + +def test_basic(): + a = A(B()) + a.b.a = a + result = dict_recurse(a) + assert isinstance(result.item.b.item.a, Wrapper) + + +def test_sub_recursion(): + a = A() + b = A() + c = A() + + a.b = b + b.b = c + c.b = b + + result = dict_recurse(a) + assert isinstance(result, Wrapper) diff --git a/test_autofit/non_linear/grid/test_optimizer_grid_search.py b/test_autofit/non_linear/grid/test_optimizer_grid_search.py index 4ca3e7b2f..b37f7dec4 100644 --- a/test_autofit/non_linear/grid/test_optimizer_grid_search.py +++ b/test_autofit/non_linear/grid/test_optimizer_grid_search.py @@ -1,321 +1,321 @@ -import csv -import pickle - -import numpy as np -import pytest - -import autofit as af -from autofit import exc - - -def test_unpickle_result(): - # noinspection PyTypeChecker - result = af.GridSearchResult( - samples=[af.Samples(model=af.Model(af.ex.Gaussian), sample_list=[])], - lower_limits_lists=[[1]], - grid_priors=[], - ) - result = pickle.loads(pickle.dumps(result)) - assert result is not None - - -class TestGridSearchablePriors: - def test_generated_models(self, grid_search, mapper): - mappers = list( - grid_search.model_mappers( - mapper, - grid_priors=[ - mapper.component.one_tuple.one_tuple_0, - mapper.component.one_tuple.one_tuple_1, - ], - ) - ) - - assert len(mappers) == 100 - - assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.1 - assert mappers[0].component.one_tuple.one_tuple_1.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_1.upper_limit == 0.2 - - assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 0.9 - assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 1.0 - assert mappers[-1].component.one_tuple.one_tuple_1.lower_limit == 1.8 - assert mappers[-1].component.one_tuple.one_tuple_1.upper_limit == 2.0 - - def test_non_grid_searched_dimensions(self, mapper): - search = af.m.MockSearch() - search.paths = af.DirectoryPaths(name="") - grid_search = af.SearchGridSearch(number_of_steps=10, search=search) - - mappers = list( - grid_search.model_mappers( - mapper, grid_priors=[mapper.component.one_tuple.one_tuple_0] - ) - ) - - assert len(mappers) == 10 - - assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.1 - assert mappers[0].component.one_tuple.one_tuple_1.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_1.upper_limit == 2.0 - - assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 0.9 - assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 1.0 - assert mappers[-1].component.one_tuple.one_tuple_1.lower_limit == 0.0 - assert mappers[-1].component.one_tuple.one_tuple_1.upper_limit == 2.0 - - def test_tied_priors(self, grid_search, mapper): - mapper.component.one_tuple.one_tuple_0 = mapper.component.one_tuple.one_tuple_1 - - mappers = list( - grid_search.model_mappers( - grid_priors=[ - mapper.component.one_tuple.one_tuple_0, - mapper.component.one_tuple.one_tuple_1, - ], - model=mapper, - ) - ) - - assert len(mappers) == 10 - - assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.2 - assert mappers[0].component.one_tuple.one_tuple_1.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_1.upper_limit == 0.2 - - assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 1.8 - assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 2.0 - assert mappers[-1].component.one_tuple.one_tuple_1.lower_limit == 1.8 - assert mappers[-1].component.one_tuple.one_tuple_1.upper_limit == 2.0 - - for mapper in mappers: - assert ( - mapper.component.one_tuple.one_tuple_0 - == mapper.component.one_tuple.one_tuple_1 - ) - - def test_different_prior_width(self, grid_search, mapper): - mapper.component.one_tuple.one_tuple_0 = af.UniformPrior(0.0, 2.0) - mappers = list( - grid_search.model_mappers( - grid_priors=[mapper.component.one_tuple.one_tuple_0], model=mapper - ) - ) - - assert len(mappers) == 10 - - assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 - assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.2 - - assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 1.8 - assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 2.0 - - mapper.component.one_tuple.one_tuple_0 = af.UniformPrior(1.0, 1.5) - mappers = list( - grid_search.model_mappers( - mapper, grid_priors=[mapper.component.one_tuple.one_tuple_0] - ) - ) - - assert len(mappers) == 10 - - assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 1.0 - assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 1.05 - - assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 1.45 - assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 1.5 - - def test_raises_exception_for_bad_limits(self, grid_search, mapper): - mapper.component.one_tuple.one_tuple_0 = af.GaussianPrior( - 0.0, 2.0, - ) - with pytest.raises(exc.PriorException): - list( - grid_search.make_arguments( - [[0, 1]], grid_priors=[mapper.component.one_tuple.one_tuple_0] - ) - ) - mapper.component.one_tuple.one_tuple_0 = af.UniformPrior( - lower_limit=float("-inf"), upper_limit=float("inf") - ) - with pytest.raises(exc.PriorException): - list( - grid_search.make_arguments( - [[0, 1]], grid_priors=[mapper.component.one_tuple.one_tuple_0] - ) - ) - - -@pytest.fixture(name="grid_search_05") -def make_grid_search_05(): - search = af.SearchGridSearch(search=af.m.MockMLE(), number_of_steps=2) - search.search.paths = af.DirectoryPaths(name="sample_name") - return search - - -@pytest.fixture(autouse=True) -def empty_args(): - af.m.MockMLE.init_args = list() - - -def test_csv_headers(grid_search_10_result, sample_name_paths): - with open(sample_name_paths.output_path / "results.csv") as f: - reader = csv.reader(f) - headers = next(reader) - - assert headers == [ - "index", - "component_one_tuple_0", - "component_one_tuple_1", - "log_likelihood_increase", - ] - - -def test_output_result_json(grid_search_10_result, sample_name_paths): - assert isinstance(sample_name_paths.load_json("result"), dict) - - -class TestGridNLOBehaviour: - def test_results(self, grid_search_05, mapper): - result = grid_search_05.fit( - model=mapper, - analysis=af.m.MockAnalysis(), - grid_priors=[ - mapper.component.one_tuple.one_tuple_0, - mapper.component.one_tuple.one_tuple_1, - ], - ) - - assert len(result.samples) == 4 - assert result.no_dimensions == 2 - - def test_results_10(self, grid_search_10_result): - assert len(grid_search_10_result.samples) == 25 - assert grid_search_10_result.no_dimensions == 2 - assert grid_search_10_result.log_likelihoods().native.shape == (5, 5) - - def test_passes_attributes(self): - search = af.DynestyStatic() - search.paths = af.DirectoryPaths(name="") - grid_search = af.SearchGridSearch(number_of_steps=10, search=search) - - grid_search.nlive = 20 - grid_search.facc = 0.3 - - search = grid_search.search_instance("name_path") - - assert search.nlive is grid_search.nlive - assert grid_search.paths.output_path != search.paths.output_path - - -@pytest.fixture(name="grid_search_result") -def make_grid_search_result(): - one = af.m.MockResultGrid(1) - two = af.m.MockResultGrid(2) - - # noinspection PyTypeChecker - return af.GridSearchResult( - samples=[one, two], lower_limits_lists=[[1], [2]], grid_priors=[[1], [2]] - ) - - -class TestGridSearchResult: - def test_best_result(self, grid_search_result): - assert grid_search_result.best_samples.log_likelihood == 2 - - def test_attributes(self, grid_search_result): - assert grid_search_result.model == 2 - - def test_best_model(self, grid_search_result): - assert grid_search_result.best_model == 2 - - def test_all_models(self, grid_search_result): - assert grid_search_result.all_models == [1, 2] - - def test__result_derived_properties(self): - lower_limit_lists = [[0.0, 0.0], [0.0, 0.5], [0.5, 0.0], [0.5, 0.5]] - - # noinspection PyTypeChecker - grid_search_result = af.GridSearchResult( - samples=None, - grid_priors=[ - af.UniformPrior(lower_limit=-2.0, upper_limit=2.0), - af.UniformPrior(lower_limit=-3.0, upper_limit=3.0), - ], - lower_limits_lists=lower_limit_lists, - ) - - assert grid_search_result.shape == (2, 2) - assert grid_search_result.physical_step_sizes == (2.0, 3.0) - assert grid_search_result.physical_centres_lists == [ - [-1.0, -1.5], - [-1.0, 1.5], - [1.0, -1.5], - [1.0, 1.5], - ] - assert grid_search_result.physical_upper_limits_lists == [ - [0.0, 0.0], - [0.0, 3.0], - [2.0, 0.0], - [2.0, 3.0], - ] - - def test__results_on_native_grid(self, grid_search_result): - assert ( - grid_search_result.samples.native - == np.array( - [ - [grid_search_result.samples[0], grid_search_result.samples[1]], - ] - ) - ).all() - - assert ( - grid_search_result.log_likelihoods().native - == np.array( - [ - [1, 2], - ] - ) - ).all() - - -@pytest.mark.parametrize( - "n_dimensions, n_steps", - [ - (2, 2), - (3, 3), - (2, 3), - (3, 2), - (4, 4), - ], -) -def test_higher_dimensions(n_dimensions, n_steps): - shape = n_dimensions * (n_steps,) - total = n_steps**n_dimensions - model = af.Model(af.ex.Gaussian) - result = af.GridSearchResult( - samples=total - * [ - af.SamplesPDF( - model, - [ - af.Sample( - 1.0, - 1.0, - 1.0, - {"centre": 1.0, "sigma": 1.0, "normalization": 1.0}, - ) - ], - ), - ], - grid_priors=[], - lower_limits_lists=total * [n_dimensions * [0.0]], - ) - - assert result.shape == shape - assert result.samples.native.shape == shape - assert result.log_likelihoods().native.shape == shape +import csv +import pickle + +import numpy as np +import pytest + +import autofit as af +from autofit import exc + + +def test_unpickle_result(): + # noinspection PyTypeChecker + result = af.GridSearchResult( + samples=[af.Samples(model=af.Model(af.ex.Gaussian), sample_list=[])], + lower_limits_lists=[[1]], + grid_priors=[], + ) + result = pickle.loads(pickle.dumps(result)) + assert result is not None + + +class TestGridSearchablePriors: + def test_generated_models(self, grid_search, mapper): + mappers = list( + grid_search.model_mappers( + mapper, + grid_priors=[ + mapper.component.one_tuple.one_tuple_0, + mapper.component.one_tuple.one_tuple_1, + ], + ) + ) + + assert len(mappers) == 100 + + assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.1 + assert mappers[0].component.one_tuple.one_tuple_1.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_1.upper_limit == 0.2 + + assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 0.9 + assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 1.0 + assert mappers[-1].component.one_tuple.one_tuple_1.lower_limit == 1.8 + assert mappers[-1].component.one_tuple.one_tuple_1.upper_limit == 2.0 + + def test_non_grid_searched_dimensions(self, mapper): + search = af.m.MockSearch() + search.paths = af.DirectoryPaths(name="") + grid_search = af.SearchGridSearch(number_of_steps=10, search=search) + + mappers = list( + grid_search.model_mappers( + mapper, grid_priors=[mapper.component.one_tuple.one_tuple_0] + ) + ) + + assert len(mappers) == 10 + + assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.1 + assert mappers[0].component.one_tuple.one_tuple_1.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_1.upper_limit == 2.0 + + assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 0.9 + assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 1.0 + assert mappers[-1].component.one_tuple.one_tuple_1.lower_limit == 0.0 + assert mappers[-1].component.one_tuple.one_tuple_1.upper_limit == 2.0 + + def test_tied_priors(self, grid_search, mapper): + mapper.component.one_tuple.one_tuple_0 = mapper.component.one_tuple.one_tuple_1 + + mappers = list( + grid_search.model_mappers( + grid_priors=[ + mapper.component.one_tuple.one_tuple_0, + mapper.component.one_tuple.one_tuple_1, + ], + model=mapper, + ) + ) + + assert len(mappers) == 10 + + assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.2 + assert mappers[0].component.one_tuple.one_tuple_1.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_1.upper_limit == 0.2 + + assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 1.8 + assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 2.0 + assert mappers[-1].component.one_tuple.one_tuple_1.lower_limit == 1.8 + assert mappers[-1].component.one_tuple.one_tuple_1.upper_limit == 2.0 + + for mapper in mappers: + assert ( + mapper.component.one_tuple.one_tuple_0 + == mapper.component.one_tuple.one_tuple_1 + ) + + def test_different_prior_width(self, grid_search, mapper): + mapper.component.one_tuple.one_tuple_0 = af.UniformPrior(0.0, 2.0) + mappers = list( + grid_search.model_mappers( + grid_priors=[mapper.component.one_tuple.one_tuple_0], model=mapper + ) + ) + + assert len(mappers) == 10 + + assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 0.0 + assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 0.2 + + assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 1.8 + assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 2.0 + + mapper.component.one_tuple.one_tuple_0 = af.UniformPrior(1.0, 1.5) + mappers = list( + grid_search.model_mappers( + mapper, grid_priors=[mapper.component.one_tuple.one_tuple_0] + ) + ) + + assert len(mappers) == 10 + + assert mappers[0].component.one_tuple.one_tuple_0.lower_limit == 1.0 + assert mappers[0].component.one_tuple.one_tuple_0.upper_limit == 1.05 + + assert mappers[-1].component.one_tuple.one_tuple_0.lower_limit == 1.45 + assert mappers[-1].component.one_tuple.one_tuple_0.upper_limit == 1.5 + + def test_raises_exception_for_bad_limits(self, grid_search, mapper): + mapper.component.one_tuple.one_tuple_0 = af.GaussianPrior( + 0.0, 2.0, + ) + with pytest.raises(exc.PriorException): + list( + grid_search.make_arguments( + [[0, 1]], grid_priors=[mapper.component.one_tuple.one_tuple_0] + ) + ) + mapper.component.one_tuple.one_tuple_0 = af.UniformPrior( + lower_limit=float("-inf"), upper_limit=float("inf") + ) + with pytest.raises(exc.PriorException): + list( + grid_search.make_arguments( + [[0, 1]], grid_priors=[mapper.component.one_tuple.one_tuple_0] + ) + ) + + +@pytest.fixture(name="grid_search_05") +def make_grid_search_05(): + search = af.SearchGridSearch(search=af.m.MockMLE(), number_of_steps=2) + search.search.paths = af.DirectoryPaths(name="sample_name") + return search + + +@pytest.fixture(autouse=True) +def empty_args(): + af.m.MockMLE.init_args = list() + + +def test_csv_headers(grid_search_10_result, sample_name_paths): + with open(sample_name_paths.output_path / "results.csv") as f: + reader = csv.reader(f) + headers = next(reader) + + assert headers == [ + "index", + "component_one_tuple_0", + "component_one_tuple_1", + "log_likelihood_increase", + ] + + +def test_output_result_json(grid_search_10_result, sample_name_paths): + assert isinstance(sample_name_paths.load_json("result"), dict) + + +class TestGridNLOBehaviour: + def test_results(self, grid_search_05, mapper): + result = grid_search_05.fit( + model=mapper, + analysis=af.m.MockAnalysis(), + grid_priors=[ + mapper.component.one_tuple.one_tuple_0, + mapper.component.one_tuple.one_tuple_1, + ], + ) + + assert len(result.samples) == 4 + assert result.no_dimensions == 2 + + def test_results_10(self, grid_search_10_result): + assert len(grid_search_10_result.samples) == 25 + assert grid_search_10_result.no_dimensions == 2 + assert grid_search_10_result.log_likelihoods().native.shape == (5, 5) + + def test_passes_attributes(self): + search = af.DynestyStatic() + search.paths = af.DirectoryPaths(name="") + grid_search = af.SearchGridSearch(number_of_steps=10, search=search) + + grid_search.nlive = 20 + grid_search.facc = 0.3 + + search = grid_search.search_instance("name_path") + + assert search.nlive is grid_search.nlive + assert grid_search.paths.output_path != search.paths.output_path + + +@pytest.fixture(name="grid_search_result") +def make_grid_search_result(): + one = af.m.MockResultGrid(1) + two = af.m.MockResultGrid(2) + + # noinspection PyTypeChecker + return af.GridSearchResult( + samples=[one, two], lower_limits_lists=[[1], [2]], grid_priors=[[1], [2]] + ) + + +class TestGridSearchResult: + def test_best_result(self, grid_search_result): + assert grid_search_result.best_samples.log_likelihood == 2 + + def test_attributes(self, grid_search_result): + assert grid_search_result.model == 2 + + def test_best_model(self, grid_search_result): + assert grid_search_result.best_model == 2 + + def test_all_models(self, grid_search_result): + assert grid_search_result.all_models == [1, 2] + + def test__result_derived_properties(self): + lower_limit_lists = [[0.0, 0.0], [0.0, 0.5], [0.5, 0.0], [0.5, 0.5]] + + # noinspection PyTypeChecker + grid_search_result = af.GridSearchResult( + samples=None, + grid_priors=[ + af.UniformPrior(lower_limit=-2.0, upper_limit=2.0), + af.UniformPrior(lower_limit=-3.0, upper_limit=3.0), + ], + lower_limits_lists=lower_limit_lists, + ) + + assert grid_search_result.shape == (2, 2) + assert grid_search_result.physical_step_sizes == (2.0, 3.0) + assert grid_search_result.physical_centres_lists == [ + [-1.0, -1.5], + [-1.0, 1.5], + [1.0, -1.5], + [1.0, 1.5], + ] + assert grid_search_result.physical_upper_limits_lists == [ + [0.0, 0.0], + [0.0, 3.0], + [2.0, 0.0], + [2.0, 3.0], + ] + + def test__results_on_native_grid(self, grid_search_result): + assert ( + grid_search_result.samples.native + == np.array( + [ + [grid_search_result.samples[0], grid_search_result.samples[1]], + ] + ) + ).all() + + assert ( + grid_search_result.log_likelihoods().native + == np.array( + [ + [1, 2], + ] + ) + ).all() + + +@pytest.mark.parametrize( + "n_dimensions, n_steps", + [ + (2, 2), + (3, 3), + (2, 3), + (3, 2), + (4, 4), + ], +) +def test_higher_dimensions(n_dimensions, n_steps): + shape = n_dimensions * (n_steps,) + total = n_steps**n_dimensions + model = af.Model(af.ex.Gaussian) + result = af.GridSearchResult( + samples=total + * [ + af.SamplesPDF( + model, + [ + af.Sample( + 1.0, + 1.0, + 1.0, + {"centre": 1.0, "sigma": 1.0, "normalization": 1.0}, + ) + ], + ), + ], + grid_priors=[], + lower_limits_lists=total * [n_dimensions * [0.0]], + ) + + assert result.shape == shape + assert result.samples.native.shape == shape + assert result.log_likelihoods().native.shape == shape diff --git a/test_autofit/non_linear/result/test_result.py b/test_autofit/non_linear/result/test_result.py index 400b101bb..dc89cfefa 100644 --- a/test_autofit/non_linear/result/test_result.py +++ b/test_autofit/non_linear/result/test_result.py @@ -1,104 +1,104 @@ -import pytest - -import autofit as af -from autofit import Sample -from autofit.non_linear.mock.mock_samples_summary import MockSamplesSummary - - -@pytest.fixture(name="result") -def make_result(): - mapper = af.ModelMapper() - mapper.component = af.m.MockClassx2Tuple - sample = Sample( - log_likelihood=1.0, - log_prior=0.0, - weight=0.0, - kwargs={ - "component.one_tuple.one_tuple_0": 0, - "component.one_tuple.one_tuple_1": 1, - }, - ) - # noinspection PyTypeChecker - return af.mock.MockResult( - samples=af.m.MockSamples( - sample_list=[sample], - # max_log_likelihood_instance=[0, 1], - prior_means=[0, 1], - model=mapper, - ), - samples_summary=MockSamplesSummary( - model=mapper, - max_log_likelihood_instance=[0, 1], - median_pdf_sample=sample, - ), - ) - - -class TestResult: - - def test_model(self, result): - component = result.model.component - assert component.one_tuple.one_tuple_0.mean == 0.5 - assert component.one_tuple.one_tuple_1.mean == 1 - - def test_model_centred(self, result): - component = result.model_centred.component - assert component.one_tuple.one_tuple_0.mean == 0 - assert component.one_tuple.one_tuple_1.mean == 1 - assert component.one_tuple.one_tuple_0.sigma == 0.2 - assert component.one_tuple.one_tuple_1.sigma == 0.2 - - def test_model_absolute(self, result): - component = result.model_centred_absolute(a=2.0).component - assert component.one_tuple.one_tuple_0.mean == 0 - assert component.one_tuple.one_tuple_1.mean == 1 - assert component.one_tuple.one_tuple_0.sigma == 2.0 - assert component.one_tuple.one_tuple_1.sigma == 2.0 - - def test_model_relative(self, result): - component = result.model_centred_relative(r=1.0).component - assert component.one_tuple.one_tuple_0.mean == 0 - assert component.one_tuple.one_tuple_1.mean == 1 - assert component.one_tuple.one_tuple_0.sigma == 0.0 - assert component.one_tuple.one_tuple_1.sigma == 1.0 - - def test_model_bounded(self, result): - component = result.model_centred_max_lh_bounded(b=1.0).component - - assert component.one_tuple.one_tuple_0.lower_limit == -1.0 - assert component.one_tuple.one_tuple_1.lower_limit == 0.0 - assert component.one_tuple.one_tuple_0.upper_limit == 1.0 - assert component.one_tuple.one_tuple_1.upper_limit == 2.0 - - def test_raises(self, result): - with pytest.raises(af.exc.PriorException): - result.model.mapper_from_prior_means( - result.samples.prior_means, a=2.0, r=1.0 - ) - - -@pytest.fixture(name="results") -def make_results_collection(): - results = af.ResultsCollection() - - results.add("first", "one") - results.add("second", "two") - - return results - - -class TestResultsCollection: - def test_with_name(self, results): - assert results.from_name("first") == "one" - assert results.from_name("second") == "two" - - def test_with_index(self, results): - assert results[0] == "one" - assert results[1] == "two" - assert results.first == "one" - assert results.last == "two" - assert len(results) == 2 - - def test_missing_result(self, results): - with pytest.raises(af.exc.PipelineException): - results.from_name("third") +import pytest + +import autofit as af +from autofit import Sample +from autofit.non_linear.mock.mock_samples_summary import MockSamplesSummary + + +@pytest.fixture(name="result") +def make_result(): + mapper = af.ModelMapper() + mapper.component = af.m.MockClassx2Tuple + sample = Sample( + log_likelihood=1.0, + log_prior=0.0, + weight=0.0, + kwargs={ + "component.one_tuple.one_tuple_0": 0, + "component.one_tuple.one_tuple_1": 1, + }, + ) + # noinspection PyTypeChecker + return af.mock.MockResult( + samples=af.m.MockSamples( + sample_list=[sample], + # max_log_likelihood_instance=[0, 1], + prior_means=[0, 1], + model=mapper, + ), + samples_summary=MockSamplesSummary( + model=mapper, + max_log_likelihood_instance=[0, 1], + median_pdf_sample=sample, + ), + ) + + +class TestResult: + + def test_model(self, result): + component = result.model.component + assert component.one_tuple.one_tuple_0.mean == 0.5 + assert component.one_tuple.one_tuple_1.mean == 1 + + def test_model_centred(self, result): + component = result.model_centred.component + assert component.one_tuple.one_tuple_0.mean == 0 + assert component.one_tuple.one_tuple_1.mean == 1 + assert component.one_tuple.one_tuple_0.sigma == 0.2 + assert component.one_tuple.one_tuple_1.sigma == 0.2 + + def test_model_absolute(self, result): + component = result.model_centred_absolute(a=2.0).component + assert component.one_tuple.one_tuple_0.mean == 0 + assert component.one_tuple.one_tuple_1.mean == 1 + assert component.one_tuple.one_tuple_0.sigma == 2.0 + assert component.one_tuple.one_tuple_1.sigma == 2.0 + + def test_model_relative(self, result): + component = result.model_centred_relative(r=1.0).component + assert component.one_tuple.one_tuple_0.mean == 0 + assert component.one_tuple.one_tuple_1.mean == 1 + assert component.one_tuple.one_tuple_0.sigma == 0.0 + assert component.one_tuple.one_tuple_1.sigma == 1.0 + + def test_model_bounded(self, result): + component = result.model_centred_max_lh_bounded(b=1.0).component + + assert component.one_tuple.one_tuple_0.lower_limit == -1.0 + assert component.one_tuple.one_tuple_1.lower_limit == 0.0 + assert component.one_tuple.one_tuple_0.upper_limit == 1.0 + assert component.one_tuple.one_tuple_1.upper_limit == 2.0 + + def test_raises(self, result): + with pytest.raises(af.exc.PriorException): + result.model.mapper_from_prior_means( + result.samples.prior_means, a=2.0, r=1.0 + ) + + +@pytest.fixture(name="results") +def make_results_collection(): + results = af.ResultsCollection() + + results.add("first", "one") + results.add("second", "two") + + return results + + +class TestResultsCollection: + def test_with_name(self, results): + assert results.from_name("first") == "one" + assert results.from_name("second") == "two" + + def test_with_index(self, results): + assert results[0] == "one" + assert results[1] == "two" + assert results.first == "one" + assert results.last == "two" + assert len(results) == 2 + + def test_missing_result(self, results): + with pytest.raises(af.exc.PipelineException): + results.from_name("third") diff --git a/test_autofit/non_linear/samples/test_nest.py b/test_autofit/non_linear/samples/test_nest.py index 58a0f2381..ae61e591a 100644 --- a/test_autofit/non_linear/samples/test_nest.py +++ b/test_autofit/non_linear/samples/test_nest.py @@ -1,79 +1,79 @@ -import pytest - -import autofit as af - -pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") - -def test__samples_within_parameter_range(samples_x5): - model = af.ModelMapper(mock_class_1=af.m.MockClassx4) - - parameters = [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - [0.0, 1.0, 2.0, 3.0], - ] - - samples_x5 = af.m.MockSamplesNest( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], - ), - samples_info={ - "total_samples" : 10, - "log_evidence" : 0.0, - "number_live_points" : 5, - } - ) - - samples_range = samples_x5.samples_within_parameter_range( - parameter_index=0, parameter_range=[-1.0, 100.0] - ) - - assert len(samples_range.parameter_lists) == 5 - assert samples_x5.parameter_lists[0] == samples_range.parameter_lists[0] - - samples_range = samples_x5.samples_within_parameter_range( - parameter_index=0, parameter_range=[1.0, 100.0] - ) - - assert len(samples_range.parameter_lists) == 1 - assert samples_range.parameter_lists[0] == [21.0, 22.0, 23.0, 24.0] - - samples_range = samples_x5.samples_within_parameter_range( - parameter_index=2, parameter_range=[1.5, 2.5] - ) - - assert len(samples_range.parameter_lists) == 4 - assert samples_range.parameter_lists[0] == [0.0, 1.0, 2.0, 3.0] - assert samples_range.parameter_lists[1] == [0.0, 1.0, 2.0, 3.0] - assert samples_range.parameter_lists[2] == [0.0, 1.0, 2.0, 3.0] - assert samples_range.parameter_lists[3] == [0.0, 1.0, 2.0, 3.0] - - -def test__acceptance_ratio_is_correct(): - model = af.ModelMapper(mock_class_1=af.m.MockClassx4) - - samples_x5 = af.m.MockSamplesNest( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=5 * [[]], - log_likelihood_list=[1.0, 2.0, 3.0, 4.0, 5.0], - log_prior_list=5 * [0.0], - weight_list=5 * [0.0], - ), - samples_info={ - "total_samples" : 10, - "log_evidence" : 0.0, - "number_live_points" : 5, - "total_accepted_samples": 5 - } - ) - - assert samples_x5.acceptance_ratio == 0.5 +import pytest + +import autofit as af + +pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") + +def test__samples_within_parameter_range(samples_x5): + model = af.ModelMapper(mock_class_1=af.m.MockClassx4) + + parameters = [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + [0.0, 1.0, 2.0, 3.0], + ] + + samples_x5 = af.m.MockSamplesNest( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], + ), + samples_info={ + "total_samples" : 10, + "log_evidence" : 0.0, + "number_live_points" : 5, + } + ) + + samples_range = samples_x5.samples_within_parameter_range( + parameter_index=0, parameter_range=[-1.0, 100.0] + ) + + assert len(samples_range.parameter_lists) == 5 + assert samples_x5.parameter_lists[0] == samples_range.parameter_lists[0] + + samples_range = samples_x5.samples_within_parameter_range( + parameter_index=0, parameter_range=[1.0, 100.0] + ) + + assert len(samples_range.parameter_lists) == 1 + assert samples_range.parameter_lists[0] == [21.0, 22.0, 23.0, 24.0] + + samples_range = samples_x5.samples_within_parameter_range( + parameter_index=2, parameter_range=[1.5, 2.5] + ) + + assert len(samples_range.parameter_lists) == 4 + assert samples_range.parameter_lists[0] == [0.0, 1.0, 2.0, 3.0] + assert samples_range.parameter_lists[1] == [0.0, 1.0, 2.0, 3.0] + assert samples_range.parameter_lists[2] == [0.0, 1.0, 2.0, 3.0] + assert samples_range.parameter_lists[3] == [0.0, 1.0, 2.0, 3.0] + + +def test__acceptance_ratio_is_correct(): + model = af.ModelMapper(mock_class_1=af.m.MockClassx4) + + samples_x5 = af.m.MockSamplesNest( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=5 * [[]], + log_likelihood_list=[1.0, 2.0, 3.0, 4.0, 5.0], + log_prior_list=5 * [0.0], + weight_list=5 * [0.0], + ), + samples_info={ + "total_samples" : 10, + "log_evidence" : 0.0, + "number_live_points" : 5, + "total_accepted_samples": 5 + } + ) + + assert samples_x5.acceptance_ratio == 0.5 diff --git a/test_autofit/non_linear/samples/test_pdf.py b/test_autofit/non_linear/samples/test_pdf.py index 38d6e25a9..533e508d2 100644 --- a/test_autofit/non_linear/samples/test_pdf.py +++ b/test_autofit/non_linear/samples/test_pdf.py @@ -1,514 +1,514 @@ -import os - -import numpy as np -import pytest - -from autonerves.conf import with_config - -import autofit as af - -pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") - - -@pytest.fixture(name="samples_x5") -def make_samples_x5(): - model = af.ModelMapper(mock_class_1=af.m.MockClassx4) - - parameters = [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - [0.0, 1.0, 2.0, 3.0], - ] - - return af.SamplesPDF( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], - ), - ) - - -@pytest.fixture(autouse=True) -def remove_csv_output(): - yield - try: - os.remove("samples.csv") - except FileNotFoundError: - pass - try: - os.remove("covariance.csv") - except FileNotFoundError: - pass - - -def test_save_covariance_matrix(samples_x5): - samples_x5.save_covariance_matrix("covariance.csv") - with open("covariance.csv") as f: - string = f.read() - print(string) - - assert ( - string - == """8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 -8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 -8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 -8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 -""" - ) - - -def test__from_csv_table(samples_x5): - filename = "samples.csv" - samples_x5.write_table(filename=filename) - - samples_x5 = af.SamplesPDF.from_table(filename=filename, model=samples_x5.model) - - assert samples_x5.parameter_lists == [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - [0.0, 1.0, 2.0, 3.0], - ] - assert samples_x5.log_likelihood_list == [1.0, 2.0, 3.0, 10.0, 5.0] - assert samples_x5.log_prior_list == [0.0, 0.0, 0.0, 0.0, 0.0] - assert samples_x5.log_posterior_list == [1.0, 2.0, 3.0, 10.0, 5.0] - assert samples_x5.weight_list == [1.0, 1.0, 1.0, 1.0, 1.0] - - -def test_format(samples_x5): - filename = "samples.csv" - samples_x5.write_table(filename=filename) - - with open(filename) as f: - text = f.read() - - assert ( - text - == """mock_class_1.one,mock_class_1.two,mock_class_1.three,mock_class_1.four,log_likelihood,log_prior,log_posterior,weight - 0.0, 1.0, 2.0, 3.0, 1.0, 0.0, 1.0, 1.0 - 0.0, 1.0, 2.0, 3.0, 2.0, 0.0, 2.0, 1.0 - 0.0, 1.0, 2.0, 3.0, 3.0, 0.0, 3.0, 1.0 - 21.0, 22.0, 23.0, 24.0, 10.0, 0.0, 10.0, 1.0 - 0.0, 1.0, 2.0, 3.0, 5.0, 0.0, 5.0, 1.0 -""" - ) - - -def test__median_pdf__converged(): - parameters = [ - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [0.9, 1.9], - [1.1, 2.1], - ] - - log_likelihood_list = 10 * [0.1] - weight_list = 10 * [0.1] - - model = af.ModelMapper(mock_class=af.m.MockClassx2) - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=10 * [0.0], - weight_list=weight_list, - ), - ) - - assert samples_x5.pdf_converged is True - - median_pdf = samples_x5.median_pdf(as_instance=False) - - assert median_pdf[0] == pytest.approx(1.0, 1.0e-4) - assert median_pdf[1] == pytest.approx(2.0, 1.0e-4) - - median_pdf_instance = samples_x5.median_pdf(as_instance=True) - - assert median_pdf_instance.mock_class.one == pytest.approx(1.0, 1e-1) - assert median_pdf_instance.mock_class.two == pytest.approx(2.0, 1e-1) - - -def test__median_pdf__unconverged(): - parameters = [ - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.1, 2.1], - [0.9, 1.9], - ] - - log_likelihood_list = 9 * [0.0] + [1.0] - weight_list = 9 * [0.0] + [1.0] - - model = af.ModelMapper(mock_class=af.m.MockClassx2) - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=10 * [0.0], - weight_list=weight_list, - ), - ) - - assert samples_x5.pdf_converged is False - - median_pdf = samples_x5.median_pdf(as_instance=False) - - assert median_pdf[0] == pytest.approx(0.9, 1.0e-4) - assert median_pdf[1] == pytest.approx(1.9, 1.0e-4) - - -@with_config("general", "model", value=True) -def test__converged__vector_and_instance_at_upper_and_lower_sigma(): - parameters = [ - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.0, 0.5], - [0.2, 0.3], - ] - - log_likelihood_list = list(range(10)) - - weight_list = 10 * [0.1] - - model = af.ModelMapper(mock_class=af.m.MockClassx2) - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=10 * [0.0], - weight_list=weight_list, - ), - ) - - assert samples_x5.pdf_converged is True - - values = samples_x5.values_at_sigma(sigma=3.0, as_instance=False) - - assert values[0] == pytest.approx((0.00121, 0.19878), 1e-1) - assert values[1] == pytest.approx((0.30121, 0.49878), 1e-1) - - values = samples_x5.values_at_upper_sigma(sigma=3.0, as_instance=False) - - assert values[0] == pytest.approx(0.19757, 1e-1) - assert values[1] == pytest.approx(0.49757, 1e-1) - - values = samples_x5.values_at_lower_sigma(sigma=3.0, as_instance=False) - - assert values[0] == pytest.approx(0.00121, 1e-1) - assert values[1] == pytest.approx(0.30121, 1e-1) - - values = samples_x5.values_at_sigma(sigma=1.0, as_instance=False) - - assert values[0] == pytest.approx((0.1, 0.1), 1e-1) - assert values[1] == pytest.approx((0.4, 0.4), 1e-1) - - values = samples_x5.values_at_sigma(sigma=1.0) - - assert values.mock_class.one == pytest.approx((0.1, 0.1), 1e-1) - assert values.mock_class.two == pytest.approx((0.4, 0.4), 1e-1) - - values = samples_x5.values_at_upper_sigma(sigma=3.0) - - assert values.mock_class.one == pytest.approx(0.19757, 1e-1) - assert values.mock_class.two == pytest.approx(0.49757, 1e-1) - - values = samples_x5.values_at_lower_sigma(sigma=3.0) - - assert values.mock_class.one == pytest.approx(0.00121, 1e-1) - assert values.mock_class.two == pytest.approx(0.30121, 1e-1) - - -def test__values_at_sigma__unconverged(): - parameters = [ - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.0, 2.0], - [1.1, 2.1], - [0.9, 1.9], - ] - - log_likelihood_list = 9 * [0.0] + [1.0] - weight_list = 9 * [0.0] + [1.0] - - model = af.ModelMapper(mock_class=af.m.MockClassx2) - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=10 * [0.0], - weight_list=weight_list, - ), - ) - - assert samples_x5.pdf_converged is False - - values_at_sigma = samples_x5.values_at_sigma(sigma=1.0, as_instance=False) - - assert values_at_sigma[0] == pytest.approx(((0.9, 1.1)), 1e-2) - assert values_at_sigma[1] == pytest.approx(((1.9, 2.1)), 1e-2) - - values_at_sigma = samples_x5.values_at_sigma(sigma=3.0, as_instance=False) - - assert values_at_sigma[0] == pytest.approx(((0.9, 1.1)), 1e-2) - assert values_at_sigma[1] == pytest.approx(((1.9, 2.1)), 1e-2) - - -def test__errors_at__converged(): - parameters = [ - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.1, 0.4], - [0.0, 0.5], - [0.2, 0.3], - ] - - log_likelihood_list = list(range(10)) - - weight_list = 10 * [0.1] - - model = af.ModelMapper(mock_class=af.m.MockClassx2) - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=10 * [0.0], - weight_list=weight_list, - ), - ) - - assert samples_x5.pdf_converged is True - - errors = samples_x5.error_magnitudes_at_sigma(sigma=3.0, as_instance=False) - - assert errors == pytest.approx([0.19514, 0.19514], 1e-1) - - errors = samples_x5.errors_at_upper_sigma(sigma=3.0, as_instance=False) - - assert errors == pytest.approx([0.09757, 0.09757], 1e-1) - - errors = samples_x5.errors_at_lower_sigma(sigma=3.0, as_instance=False) - - assert errors == pytest.approx([0.09757, 0.09757], 1e-1) - - errors = samples_x5.errors_at_sigma(sigma=3.0, as_instance=False) - assert errors[0] == pytest.approx((0.09757, 0.09757), 1e-1) - assert errors[1] == pytest.approx((0.09757, 0.09757), 1e-1) - - errors = samples_x5.error_magnitudes_at_sigma(sigma=1.0, as_instance=False) - - assert errors == pytest.approx([0.0, 0.0], 1e-1) - - errors_instance = samples_x5.errors_at_sigma(sigma=1.0, as_instance=True) - - assert errors_instance.mock_class.one[0] == pytest.approx(0.0, 1e-1) - assert errors_instance.mock_class.two[0] == pytest.approx(0.0, 1e-1) - - errors_instance = samples_x5.errors_at_upper_sigma(sigma=3.0, as_instance=True) - - assert errors_instance.mock_class.one == pytest.approx(0.09757, 1e-1) - assert errors_instance.mock_class.two == pytest.approx(0.09757, 1e-1) - - errors_instance = samples_x5.errors_at_lower_sigma(sigma=3.0, as_instance=True) - - assert errors_instance.mock_class.one == pytest.approx(0.09757, 1e-1) - assert errors_instance.mock_class.two == pytest.approx(0.09757, 1e-1) - - -def test__unconverged_sample_size(): - model = af.ModelMapper(mock_class_1=af.m.MockClassx4) - - log_likelihood_list = 4 * [0.0] + [1.0] - weight_list = 4 * [0.0] + [1.0] - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=5 * [[]], - log_likelihood_list=log_likelihood_list, - log_prior_list=[1.0, 1.0, 1.0, 1.0, 1.0], - weight_list=weight_list, - ), - samples_info={"unconverged_sample_size": 2}, - ) - - assert samples_x5.pdf_converged is False - assert samples_x5.unconverged_sample_size == 2 - - -def test__offset_values_via_input_values(): - model = af.ModelMapper(mock_class_1=af.m.MockClassx4) - - parameters = [ - [1.1, 2.1, 3.1, 4.1], - [1.0, 2.0, 3.0, 4.0], - [1.0, 2.0, 3.0, 4.0], - [1.0, 2.0, 3.0, 4.0], - [1.0, 2.0, 3.0, 4.1], - ] - - weight_list = [0.3, 0.2, 0.2, 0.2, 0.1] - - log_likelihood_list = list(map(lambda weight: 10.0 * weight, weight_list)) - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=10 * [0.0], - weight_list=weight_list, - ), - ) - - offset_values = samples_x5.offset_values_via_input_values( - input_vector=[1.0, 1.0, 2.0, 3.0], as_instance=False - ) - - assert offset_values == pytest.approx([0.0, 1.0, 1.0, 1.025], 1.0e-4) - - -def test__draw_randomly_via_pdf(): - parameters = [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - [0.0, 1.0, 2.0, 3.0], - ] - - model = af.ModelMapper(mock_class_1=af.m.MockClassx4) - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0, 3.0, 4.0, 5.0], - log_prior_list=5 * [0.0], - weight_list=[0.0, 0.0, 0.0, 1.0, 0.0], - ), - ) - - vector = samples_x5.draw_randomly_via_pdf(as_instance=False) - - assert vector == [21.0, 22.0, 23.0, 24.0] - - instance = samples_x5.draw_randomly_via_pdf(as_instance=True) - - assert vector == [21.0, 22.0, 23.0, 24.0] - - assert instance.mock_class_1.one == 21.0 - assert instance.mock_class_1.two == 22.0 - assert instance.mock_class_1.three == 23.0 - assert instance.mock_class_1.four == 24.0 - - -@pytest.fixture(name="make_samples") -def make_samples_fixture(): - def make_samples(parameters, weight_list=None): - log_likelihood_list = list(range(len(parameters))) - - weight_list = weight_list or len(parameters) * [0.1] - - model = af.ModelMapper(mock_class=af.m.MockClassx2) - return af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=3 * [0.0], - weight_list=weight_list, - ), - ) - - return make_samples - - -def test__covariance_matrix(make_samples): - samples_x5 = make_samples( - parameters=[[2.0, 2.0], [1.0, 1.0], [0.0, 0.0]], - ) - - assert samples_x5.covariance_matrix == pytest.approx( - np.array([[1.0, 1.0], [1.0, 1.0]]), 1.0e-4 - ) - - parameters = [[0.0, 2.0], [1.0, 1.0], [2.0, 0.0]] - - samples_x5 = make_samples(parameters) - - assert samples_x5.covariance_matrix == pytest.approx( - np.array([[1.0, -1.0], [-1.0, 1.0]]), 1.0e-4 - ) - - samples_x5 = make_samples( - parameters, - weight_list=[0.1, 0.2, 0.3], - ) - - assert samples_x5.covariance_matrix == pytest.approx( - np.array([[0.90909, -0.90909], [-0.90909, 0.90909]]), 1.0e-4 - ) - - -def test__quantile_single_weighted_sample_does_not_crash(): - """ - A weighted `quantile` over a single sample must return that sample's value - for every quantile rather than raising. Previously `np.cumsum(sw)[:-1]` was - empty for one sample and `cdf[-1]` raised IndexError — surfaced when latent - masking left exactly one finite sample whose weight was < 0.99. - """ - from autofit.non_linear.samples.pdf import quantile - - assert quantile(x=[5.0], q=0.5, weights=[0.3]) == [5.0] - assert quantile(x=[5.0], q=[0.16, 0.5, 0.84], weights=[0.3]) == [5.0, 5.0, 5.0] - # n >= 2 weighted path is unchanged. - assert quantile(x=[1.0, 2.0], q=0.5, weights=[0.5, 0.5]) == pytest.approx([1.5]) +import os + +import numpy as np +import pytest + +from autonerves.conf import with_config + +import autofit as af + +pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") + + +@pytest.fixture(name="samples_x5") +def make_samples_x5(): + model = af.ModelMapper(mock_class_1=af.m.MockClassx4) + + parameters = [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + [0.0, 1.0, 2.0, 3.0], + ] + + return af.SamplesPDF( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], + ), + ) + + +@pytest.fixture(autouse=True) +def remove_csv_output(): + yield + try: + os.remove("samples.csv") + except FileNotFoundError: + pass + try: + os.remove("covariance.csv") + except FileNotFoundError: + pass + + +def test_save_covariance_matrix(samples_x5): + samples_x5.save_covariance_matrix("covariance.csv") + with open("covariance.csv") as f: + string = f.read() + print(string) + + assert ( + string + == """8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 +8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 +8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 +8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01,8.820000000000000284e+01 +""" + ) + + +def test__from_csv_table(samples_x5): + filename = "samples.csv" + samples_x5.write_table(filename=filename) + + samples_x5 = af.SamplesPDF.from_table(filename=filename, model=samples_x5.model) + + assert samples_x5.parameter_lists == [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + [0.0, 1.0, 2.0, 3.0], + ] + assert samples_x5.log_likelihood_list == [1.0, 2.0, 3.0, 10.0, 5.0] + assert samples_x5.log_prior_list == [0.0, 0.0, 0.0, 0.0, 0.0] + assert samples_x5.log_posterior_list == [1.0, 2.0, 3.0, 10.0, 5.0] + assert samples_x5.weight_list == [1.0, 1.0, 1.0, 1.0, 1.0] + + +def test_format(samples_x5): + filename = "samples.csv" + samples_x5.write_table(filename=filename) + + with open(filename) as f: + text = f.read() + + assert ( + text + == """mock_class_1.one,mock_class_1.two,mock_class_1.three,mock_class_1.four,log_likelihood,log_prior,log_posterior,weight + 0.0, 1.0, 2.0, 3.0, 1.0, 0.0, 1.0, 1.0 + 0.0, 1.0, 2.0, 3.0, 2.0, 0.0, 2.0, 1.0 + 0.0, 1.0, 2.0, 3.0, 3.0, 0.0, 3.0, 1.0 + 21.0, 22.0, 23.0, 24.0, 10.0, 0.0, 10.0, 1.0 + 0.0, 1.0, 2.0, 3.0, 5.0, 0.0, 5.0, 1.0 +""" + ) + + +def test__median_pdf__converged(): + parameters = [ + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [0.9, 1.9], + [1.1, 2.1], + ] + + log_likelihood_list = 10 * [0.1] + weight_list = 10 * [0.1] + + model = af.ModelMapper(mock_class=af.m.MockClassx2) + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=10 * [0.0], + weight_list=weight_list, + ), + ) + + assert samples_x5.pdf_converged is True + + median_pdf = samples_x5.median_pdf(as_instance=False) + + assert median_pdf[0] == pytest.approx(1.0, 1.0e-4) + assert median_pdf[1] == pytest.approx(2.0, 1.0e-4) + + median_pdf_instance = samples_x5.median_pdf(as_instance=True) + + assert median_pdf_instance.mock_class.one == pytest.approx(1.0, 1e-1) + assert median_pdf_instance.mock_class.two == pytest.approx(2.0, 1e-1) + + +def test__median_pdf__unconverged(): + parameters = [ + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.1, 2.1], + [0.9, 1.9], + ] + + log_likelihood_list = 9 * [0.0] + [1.0] + weight_list = 9 * [0.0] + [1.0] + + model = af.ModelMapper(mock_class=af.m.MockClassx2) + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=10 * [0.0], + weight_list=weight_list, + ), + ) + + assert samples_x5.pdf_converged is False + + median_pdf = samples_x5.median_pdf(as_instance=False) + + assert median_pdf[0] == pytest.approx(0.9, 1.0e-4) + assert median_pdf[1] == pytest.approx(1.9, 1.0e-4) + + +@with_config("general", "model", value=True) +def test__converged__vector_and_instance_at_upper_and_lower_sigma(): + parameters = [ + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.0, 0.5], + [0.2, 0.3], + ] + + log_likelihood_list = list(range(10)) + + weight_list = 10 * [0.1] + + model = af.ModelMapper(mock_class=af.m.MockClassx2) + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=10 * [0.0], + weight_list=weight_list, + ), + ) + + assert samples_x5.pdf_converged is True + + values = samples_x5.values_at_sigma(sigma=3.0, as_instance=False) + + assert values[0] == pytest.approx((0.00121, 0.19878), 1e-1) + assert values[1] == pytest.approx((0.30121, 0.49878), 1e-1) + + values = samples_x5.values_at_upper_sigma(sigma=3.0, as_instance=False) + + assert values[0] == pytest.approx(0.19757, 1e-1) + assert values[1] == pytest.approx(0.49757, 1e-1) + + values = samples_x5.values_at_lower_sigma(sigma=3.0, as_instance=False) + + assert values[0] == pytest.approx(0.00121, 1e-1) + assert values[1] == pytest.approx(0.30121, 1e-1) + + values = samples_x5.values_at_sigma(sigma=1.0, as_instance=False) + + assert values[0] == pytest.approx((0.1, 0.1), 1e-1) + assert values[1] == pytest.approx((0.4, 0.4), 1e-1) + + values = samples_x5.values_at_sigma(sigma=1.0) + + assert values.mock_class.one == pytest.approx((0.1, 0.1), 1e-1) + assert values.mock_class.two == pytest.approx((0.4, 0.4), 1e-1) + + values = samples_x5.values_at_upper_sigma(sigma=3.0) + + assert values.mock_class.one == pytest.approx(0.19757, 1e-1) + assert values.mock_class.two == pytest.approx(0.49757, 1e-1) + + values = samples_x5.values_at_lower_sigma(sigma=3.0) + + assert values.mock_class.one == pytest.approx(0.00121, 1e-1) + assert values.mock_class.two == pytest.approx(0.30121, 1e-1) + + +def test__values_at_sigma__unconverged(): + parameters = [ + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.0, 2.0], + [1.1, 2.1], + [0.9, 1.9], + ] + + log_likelihood_list = 9 * [0.0] + [1.0] + weight_list = 9 * [0.0] + [1.0] + + model = af.ModelMapper(mock_class=af.m.MockClassx2) + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=10 * [0.0], + weight_list=weight_list, + ), + ) + + assert samples_x5.pdf_converged is False + + values_at_sigma = samples_x5.values_at_sigma(sigma=1.0, as_instance=False) + + assert values_at_sigma[0] == pytest.approx(((0.9, 1.1)), 1e-2) + assert values_at_sigma[1] == pytest.approx(((1.9, 2.1)), 1e-2) + + values_at_sigma = samples_x5.values_at_sigma(sigma=3.0, as_instance=False) + + assert values_at_sigma[0] == pytest.approx(((0.9, 1.1)), 1e-2) + assert values_at_sigma[1] == pytest.approx(((1.9, 2.1)), 1e-2) + + +def test__errors_at__converged(): + parameters = [ + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.1, 0.4], + [0.0, 0.5], + [0.2, 0.3], + ] + + log_likelihood_list = list(range(10)) + + weight_list = 10 * [0.1] + + model = af.ModelMapper(mock_class=af.m.MockClassx2) + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=10 * [0.0], + weight_list=weight_list, + ), + ) + + assert samples_x5.pdf_converged is True + + errors = samples_x5.error_magnitudes_at_sigma(sigma=3.0, as_instance=False) + + assert errors == pytest.approx([0.19514, 0.19514], 1e-1) + + errors = samples_x5.errors_at_upper_sigma(sigma=3.0, as_instance=False) + + assert errors == pytest.approx([0.09757, 0.09757], 1e-1) + + errors = samples_x5.errors_at_lower_sigma(sigma=3.0, as_instance=False) + + assert errors == pytest.approx([0.09757, 0.09757], 1e-1) + + errors = samples_x5.errors_at_sigma(sigma=3.0, as_instance=False) + assert errors[0] == pytest.approx((0.09757, 0.09757), 1e-1) + assert errors[1] == pytest.approx((0.09757, 0.09757), 1e-1) + + errors = samples_x5.error_magnitudes_at_sigma(sigma=1.0, as_instance=False) + + assert errors == pytest.approx([0.0, 0.0], 1e-1) + + errors_instance = samples_x5.errors_at_sigma(sigma=1.0, as_instance=True) + + assert errors_instance.mock_class.one[0] == pytest.approx(0.0, 1e-1) + assert errors_instance.mock_class.two[0] == pytest.approx(0.0, 1e-1) + + errors_instance = samples_x5.errors_at_upper_sigma(sigma=3.0, as_instance=True) + + assert errors_instance.mock_class.one == pytest.approx(0.09757, 1e-1) + assert errors_instance.mock_class.two == pytest.approx(0.09757, 1e-1) + + errors_instance = samples_x5.errors_at_lower_sigma(sigma=3.0, as_instance=True) + + assert errors_instance.mock_class.one == pytest.approx(0.09757, 1e-1) + assert errors_instance.mock_class.two == pytest.approx(0.09757, 1e-1) + + +def test__unconverged_sample_size(): + model = af.ModelMapper(mock_class_1=af.m.MockClassx4) + + log_likelihood_list = 4 * [0.0] + [1.0] + weight_list = 4 * [0.0] + [1.0] + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=5 * [[]], + log_likelihood_list=log_likelihood_list, + log_prior_list=[1.0, 1.0, 1.0, 1.0, 1.0], + weight_list=weight_list, + ), + samples_info={"unconverged_sample_size": 2}, + ) + + assert samples_x5.pdf_converged is False + assert samples_x5.unconverged_sample_size == 2 + + +def test__offset_values_via_input_values(): + model = af.ModelMapper(mock_class_1=af.m.MockClassx4) + + parameters = [ + [1.1, 2.1, 3.1, 4.1], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.1], + ] + + weight_list = [0.3, 0.2, 0.2, 0.2, 0.1] + + log_likelihood_list = list(map(lambda weight: 10.0 * weight, weight_list)) + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=10 * [0.0], + weight_list=weight_list, + ), + ) + + offset_values = samples_x5.offset_values_via_input_values( + input_vector=[1.0, 1.0, 2.0, 3.0], as_instance=False + ) + + assert offset_values == pytest.approx([0.0, 1.0, 1.0, 1.025], 1.0e-4) + + +def test__draw_randomly_via_pdf(): + parameters = [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + [0.0, 1.0, 2.0, 3.0], + ] + + model = af.ModelMapper(mock_class_1=af.m.MockClassx4) + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0, 3.0, 4.0, 5.0], + log_prior_list=5 * [0.0], + weight_list=[0.0, 0.0, 0.0, 1.0, 0.0], + ), + ) + + vector = samples_x5.draw_randomly_via_pdf(as_instance=False) + + assert vector == [21.0, 22.0, 23.0, 24.0] + + instance = samples_x5.draw_randomly_via_pdf(as_instance=True) + + assert vector == [21.0, 22.0, 23.0, 24.0] + + assert instance.mock_class_1.one == 21.0 + assert instance.mock_class_1.two == 22.0 + assert instance.mock_class_1.three == 23.0 + assert instance.mock_class_1.four == 24.0 + + +@pytest.fixture(name="make_samples") +def make_samples_fixture(): + def make_samples(parameters, weight_list=None): + log_likelihood_list = list(range(len(parameters))) + + weight_list = weight_list or len(parameters) * [0.1] + + model = af.ModelMapper(mock_class=af.m.MockClassx2) + return af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=3 * [0.0], + weight_list=weight_list, + ), + ) + + return make_samples + + +def test__covariance_matrix(make_samples): + samples_x5 = make_samples( + parameters=[[2.0, 2.0], [1.0, 1.0], [0.0, 0.0]], + ) + + assert samples_x5.covariance_matrix == pytest.approx( + np.array([[1.0, 1.0], [1.0, 1.0]]), 1.0e-4 + ) + + parameters = [[0.0, 2.0], [1.0, 1.0], [2.0, 0.0]] + + samples_x5 = make_samples(parameters) + + assert samples_x5.covariance_matrix == pytest.approx( + np.array([[1.0, -1.0], [-1.0, 1.0]]), 1.0e-4 + ) + + samples_x5 = make_samples( + parameters, + weight_list=[0.1, 0.2, 0.3], + ) + + assert samples_x5.covariance_matrix == pytest.approx( + np.array([[0.90909, -0.90909], [-0.90909, 0.90909]]), 1.0e-4 + ) + + +def test__quantile_single_weighted_sample_does_not_crash(): + """ + A weighted `quantile` over a single sample must return that sample's value + for every quantile rather than raising. Previously `np.cumsum(sw)[:-1]` was + empty for one sample and `cdf[-1]` raised IndexError — surfaced when latent + masking left exactly one finite sample whose weight was < 0.99. + """ + from autofit.non_linear.samples.pdf import quantile + + assert quantile(x=[5.0], q=0.5, weights=[0.3]) == [5.0] + assert quantile(x=[5.0], q=[0.16, 0.5, 0.84], weights=[0.3]) == [5.0, 5.0, 5.0] + # n >= 2 weighted path is unchanged. + assert quantile(x=[1.0, 2.0], q=0.5, weights=[0.5, 0.5]) == pytest.approx([1.5]) diff --git a/test_autofit/non_linear/samples/test_samples.py b/test_autofit/non_linear/samples/test_samples.py index 45455b55d..1a3cb31b3 100644 --- a/test_autofit/non_linear/samples/test_samples.py +++ b/test_autofit/non_linear/samples/test_samples.py @@ -1,266 +1,266 @@ -import os -from pathlib import Path -import pytest - -import autofit as af - -pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") - - -def test__table__headers(samples_x5): - assert samples_x5._headers == [ - "mock_class_1.one", - "mock_class_1.two", - "mock_class_1.three", - "mock_class_1.four", - "log_likelihood", - "log_prior", - "log_posterior", - "weight", - ] - - -def test__table__rows(samples_x5): - rows = list(samples_x5._rows) - assert rows == [ - [0.0, 1.0, 2.0, 3.0, 1.0, 0.0, 1.0, 1.0], - [0.0, 1.0, 2.0, 3.0, 2.0, 0.0, 2.0, 1.0], - [0.0, 1.0, 2.0, 3.0, 3.0, 0.0, 3.0, 1.0], - [21.0, 22.0, 23.0, 24.0, 10.0, 0.0, 10.0, 1.0], - [0.0, 1.0, 2.0, 3.0, 5.0, 0.0, 5.0, 1.0], - ] - - -def test__table__write_table(): - model = af.Collection(mock_class_1=af.m.MockClassx4) - - parameters = [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - [0.0, 1.0, 2.0, 3.0], - ] - - samples_x5 = af.Samples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], - ), - ) - - filename = "samples.csv" - samples_x5.write_table(filename=filename) - - assert Path(filename).exists() - os.remove(filename) - - -def test__max_log_likelihood(samples_x5): - assert samples_x5.max_log_likelihood(as_instance=False) == [21.0, 22.0, 23.0, 24.0] - - instance = samples_x5.max_log_likelihood(as_instance=True) - - assert instance.mock_class_1.one == 21.0 - assert instance.mock_class_1.two == 22.0 - assert instance.mock_class_1.three == 23.0 - assert instance.mock_class_1.four == 24.0 - - -def test__max_log_posterior(): - model = af.Collection(mock_class_1=af.m.MockClassx4) - - parameters = [ - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [0.0, 1.0, 2.0, 3.0], - [21.0, 22.0, 23.0, 24.0], - ] - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0, 3.0, 0.0, 5.0], - log_prior_list=[1.0, 2.0, 3.0, 10.0, 6.0], - weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], - ), - ) - - assert samples_x5.log_posterior_list == [2.0, 4.0, 6.0, 10.0, 11.0] - - assert samples_x5.max_log_posterior(as_instance=False) == [21.0, 22.0, 23.0, 24.0] - - instance = samples_x5.max_log_posterior(as_instance=True) - - assert instance.mock_class_1.one == 21.0 - assert instance.mock_class_1.two == 22.0 - assert instance.mock_class_1.three == 23.0 - assert instance.mock_class_1.four == 24.0 - - -def test__instance_from_sample_index(): - model = af.Collection(mock_class=af.m.MockClassx4) - - parameters = [ - [1.0, 2.0, 3.0, 4.0], - [5.0, 6.0, 7.0, 8.0], - [1.0, 2.0, 3.0, 4.0], - [1.0, 2.0, 3.0, 4.0], - [1.1, 2.1, 3.1, 4.1], - ] - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[0.0, 0.0, 0.0, 0.0, 0.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], - ), - ) - - instance = samples_x5.from_sample_index(sample_index=0, as_instance=True) - - assert instance.mock_class.one == 1.0 - assert instance.mock_class.two == 2.0 - assert instance.mock_class.three == 3.0 - assert instance.mock_class.four == 4.0 - - -def test__samples_above_weight_threshold_from(): - - model = af.Collection(mock_class=af.m.MockClassx4) - - parameters = [ - [1.0, 2.0, 3.0, 4.0], - [5.0, 6.0, 7.0, 8.0], - [1.0, 2.0, 3.0, 4.0], - [1.0, 2.0, 3.0, 4.0], - [1.1, 2.1, 3.1, 4.1], - ] - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[0.0, 0.0, 0.0, 0.0, 0.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[0.2, 0.2, 1.0, 1.0, 1.0], - ), - ) - - samples_above_weight_threshold = samples_x5.samples_above_weight_threshold_from( - weight_threshold=0.5 - ) - - assert len(samples_above_weight_threshold) == 3 - assert samples_above_weight_threshold.sample_list[0].weight == 1.0 - -def test__samples_drawn_randomly_via_pdf_from(): - - model = af.Collection(mock_class=af.m.MockClassx4) - - parameters = [ - [1.0, 2.0, 3.0, 4.0], - [5.0, 6.0, 7.0, 8.0], - [1.0, 2.0, 3.0, 4.0], - [1.0, 2.0, 3.0, 4.0], - [1.1, 2.1, 3.1, 4.1], - ] - - samples_x5 = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[0.0, 0.0, 0.0, 0.0, 0.0], - log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], - weight_list=[0.2, 0.2, 0.2, 0.2, 0.2], - ), - ) - - samples_drawn_randomly_via_pdf = samples_x5.samples_drawn_randomly_via_pdf_from( - total_draws=3 - ) - - assert len(samples_drawn_randomly_via_pdf) == 3 - assert samples_drawn_randomly_via_pdf.sample_list[0].weight == 0.2 - -def test__addition_of_samples(samples_x5): - samples = samples_x5 + samples_x5 - - assert len(samples.sample_list) == 10 - assert samples.sample_list[0].log_likelihood == 1.0 - assert samples.sample_list[4].log_likelihood == 5.0 - assert samples.sample_list[5].log_likelihood == 1.0 - assert samples.sample_list[9].log_likelihood == 5.0 - - -def test__sum_of_samples(samples_x5): - samples = sum([samples_x5, samples_x5, samples_x5]) - - assert len(samples.sample_list) == 15 - assert samples.sample_list[0].log_likelihood == 1.0 - assert samples.sample_list[4].log_likelihood == 5.0 - assert samples.sample_list[5].log_likelihood == 1.0 - assert samples.sample_list[9].log_likelihood == 5.0 - assert samples.sample_list[10].log_likelihood == 1.0 - assert samples.sample_list[14].log_likelihood == 5.0 - - -def test__addition_of_samples__raises_error_if_model_mismatch(samples_x5): - model = af.Collection(mock_class_1=af.m.MockClassx2) - - parameters = [ - [0.0, 1.0], - [0.0, 1.0], - [0.0, 1.0], - [21.0, 22.0], - [0.0, 1.0], - ] - - samples_different_model = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - model=model, - parameter_lists=parameters, - log_likelihood_list=[1.0, 2.0], - log_prior_list=[0.0, 0.0], - weight_list=[1.0, 1.0], - ), - ) - - with pytest.raises(af.exc.SamplesException): - samples_x5 + samples_different_model - - -def test__sample_kwargs__mixed_dotted_and_dotless_string_keys(): - sample = af.Sample( - log_likelihood=1.0, - log_prior=0.0, - weight=1.0, - kwargs={ - "mock_class_1.one": 10.0, - "dummy_0": 99.0, - }, - ) - - assert all(isinstance(key, tuple) for key in sample.kwargs) - assert sample.is_path_kwargs is True - assert sample.kwargs[("dummy_0",)] == 99.0 - assert sample.kwargs[("mock_class_1", "one")] == 10.0 - - paths = [ - [("mock_class_1", "one")], - [("dummy_0",)], - ] - assert sample.parameter_lists_for_paths(paths) == [10.0, 99.0] +import os +from pathlib import Path +import pytest + +import autofit as af + +pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") + + +def test__table__headers(samples_x5): + assert samples_x5._headers == [ + "mock_class_1.one", + "mock_class_1.two", + "mock_class_1.three", + "mock_class_1.four", + "log_likelihood", + "log_prior", + "log_posterior", + "weight", + ] + + +def test__table__rows(samples_x5): + rows = list(samples_x5._rows) + assert rows == [ + [0.0, 1.0, 2.0, 3.0, 1.0, 0.0, 1.0, 1.0], + [0.0, 1.0, 2.0, 3.0, 2.0, 0.0, 2.0, 1.0], + [0.0, 1.0, 2.0, 3.0, 3.0, 0.0, 3.0, 1.0], + [21.0, 22.0, 23.0, 24.0, 10.0, 0.0, 10.0, 1.0], + [0.0, 1.0, 2.0, 3.0, 5.0, 0.0, 5.0, 1.0], + ] + + +def test__table__write_table(): + model = af.Collection(mock_class_1=af.m.MockClassx4) + + parameters = [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + [0.0, 1.0, 2.0, 3.0], + ] + + samples_x5 = af.Samples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0, 3.0, 10.0, 5.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], + ), + ) + + filename = "samples.csv" + samples_x5.write_table(filename=filename) + + assert Path(filename).exists() + os.remove(filename) + + +def test__max_log_likelihood(samples_x5): + assert samples_x5.max_log_likelihood(as_instance=False) == [21.0, 22.0, 23.0, 24.0] + + instance = samples_x5.max_log_likelihood(as_instance=True) + + assert instance.mock_class_1.one == 21.0 + assert instance.mock_class_1.two == 22.0 + assert instance.mock_class_1.three == 23.0 + assert instance.mock_class_1.four == 24.0 + + +def test__max_log_posterior(): + model = af.Collection(mock_class_1=af.m.MockClassx4) + + parameters = [ + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [0.0, 1.0, 2.0, 3.0], + [21.0, 22.0, 23.0, 24.0], + ] + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0, 3.0, 0.0, 5.0], + log_prior_list=[1.0, 2.0, 3.0, 10.0, 6.0], + weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], + ), + ) + + assert samples_x5.log_posterior_list == [2.0, 4.0, 6.0, 10.0, 11.0] + + assert samples_x5.max_log_posterior(as_instance=False) == [21.0, 22.0, 23.0, 24.0] + + instance = samples_x5.max_log_posterior(as_instance=True) + + assert instance.mock_class_1.one == 21.0 + assert instance.mock_class_1.two == 22.0 + assert instance.mock_class_1.three == 23.0 + assert instance.mock_class_1.four == 24.0 + + +def test__instance_from_sample_index(): + model = af.Collection(mock_class=af.m.MockClassx4) + + parameters = [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.1, 2.1, 3.1, 4.1], + ] + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[0.0, 0.0, 0.0, 0.0, 0.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[1.0, 1.0, 1.0, 1.0, 1.0], + ), + ) + + instance = samples_x5.from_sample_index(sample_index=0, as_instance=True) + + assert instance.mock_class.one == 1.0 + assert instance.mock_class.two == 2.0 + assert instance.mock_class.three == 3.0 + assert instance.mock_class.four == 4.0 + + +def test__samples_above_weight_threshold_from(): + + model = af.Collection(mock_class=af.m.MockClassx4) + + parameters = [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.1, 2.1, 3.1, 4.1], + ] + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[0.0, 0.0, 0.0, 0.0, 0.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[0.2, 0.2, 1.0, 1.0, 1.0], + ), + ) + + samples_above_weight_threshold = samples_x5.samples_above_weight_threshold_from( + weight_threshold=0.5 + ) + + assert len(samples_above_weight_threshold) == 3 + assert samples_above_weight_threshold.sample_list[0].weight == 1.0 + +def test__samples_drawn_randomly_via_pdf_from(): + + model = af.Collection(mock_class=af.m.MockClassx4) + + parameters = [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [1.0, 2.0, 3.0, 4.0], + [1.0, 2.0, 3.0, 4.0], + [1.1, 2.1, 3.1, 4.1], + ] + + samples_x5 = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[0.0, 0.0, 0.0, 0.0, 0.0], + log_prior_list=[0.0, 0.0, 0.0, 0.0, 0.0], + weight_list=[0.2, 0.2, 0.2, 0.2, 0.2], + ), + ) + + samples_drawn_randomly_via_pdf = samples_x5.samples_drawn_randomly_via_pdf_from( + total_draws=3 + ) + + assert len(samples_drawn_randomly_via_pdf) == 3 + assert samples_drawn_randomly_via_pdf.sample_list[0].weight == 0.2 + +def test__addition_of_samples(samples_x5): + samples = samples_x5 + samples_x5 + + assert len(samples.sample_list) == 10 + assert samples.sample_list[0].log_likelihood == 1.0 + assert samples.sample_list[4].log_likelihood == 5.0 + assert samples.sample_list[5].log_likelihood == 1.0 + assert samples.sample_list[9].log_likelihood == 5.0 + + +def test__sum_of_samples(samples_x5): + samples = sum([samples_x5, samples_x5, samples_x5]) + + assert len(samples.sample_list) == 15 + assert samples.sample_list[0].log_likelihood == 1.0 + assert samples.sample_list[4].log_likelihood == 5.0 + assert samples.sample_list[5].log_likelihood == 1.0 + assert samples.sample_list[9].log_likelihood == 5.0 + assert samples.sample_list[10].log_likelihood == 1.0 + assert samples.sample_list[14].log_likelihood == 5.0 + + +def test__addition_of_samples__raises_error_if_model_mismatch(samples_x5): + model = af.Collection(mock_class_1=af.m.MockClassx2) + + parameters = [ + [0.0, 1.0], + [0.0, 1.0], + [0.0, 1.0], + [21.0, 22.0], + [0.0, 1.0], + ] + + samples_different_model = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + model=model, + parameter_lists=parameters, + log_likelihood_list=[1.0, 2.0], + log_prior_list=[0.0, 0.0], + weight_list=[1.0, 1.0], + ), + ) + + with pytest.raises(af.exc.SamplesException): + samples_x5 + samples_different_model + + +def test__sample_kwargs__mixed_dotted_and_dotless_string_keys(): + sample = af.Sample( + log_likelihood=1.0, + log_prior=0.0, + weight=1.0, + kwargs={ + "mock_class_1.one": 10.0, + "dummy_0": 99.0, + }, + ) + + assert all(isinstance(key, tuple) for key in sample.kwargs) + assert sample.is_path_kwargs is True + assert sample.kwargs[("dummy_0",)] == 99.0 + assert sample.kwargs[("mock_class_1", "one")] == 10.0 + + paths = [ + [("mock_class_1", "one")], + [("dummy_0",)], + ] + assert sample.parameter_lists_for_paths(paths) == [10.0, 99.0] diff --git a/test_autofit/non_linear/search/test_abstract_search.py b/test_autofit/non_linear/search/test_abstract_search.py index 4a2e3bb5f..871d4afe5 100644 --- a/test_autofit/non_linear/search/test_abstract_search.py +++ b/test_autofit/non_linear/search/test_abstract_search.py @@ -1,425 +1,425 @@ -import os - -import numpy as np -import pytest - -import autofit as af -from autonerves import conf - -pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") - - -@pytest.fixture(name="mapper") -def make_mapper(): - return af.ModelMapper() - - -@pytest.fixture(name="mock_list") -def make_mock_list(): - return [af.Model(af.m.MockClassx4), af.Model(af.m.MockClassx4)] - - -@pytest.fixture(name="result") -def make_result(): - mapper = af.ModelMapper() - mapper.component = af.m.MockClassx2Tuple - # noinspection PyTypeChecker - return af.mock.MockResult( - samples_summary=af.m.MockSamplesSummary( - model=mapper, - prior_means=[0, 1], - ), - ) - - -def test__environment_variable_override(): - os.environ["OPENBLAS_NUM_THREADS"] = "2" - os.environ["MKL_NUM_THREADS"] = "2" - os.environ["OMP_NUM_THREADS"] = "2" - os.environ["VECLIB_MAXIMUM_THREADS"] = "2" - os.environ["NUMEXPR_NUM_THREADS"] = "2" - - conf.instance["general"]["parallel"]["warn_environment_variables"] = True - - with pytest.warns(af.exc.SearchWarning): - af.mock.MockSearch(number_of_cores=2) - - conf.instance["general"]["parallel"]["warn_environment_variables"] = False - -class TestResult: - def test_model(self, result): - - component = result.model.component - assert component.one_tuple.one_tuple_0.mean == 0.5 - assert component.one_tuple.one_tuple_1.mean == 1 - - def test_model_centred(self, result): - - component = result.model_centred.component - assert component.one_tuple.one_tuple_0.mean == 0 - assert component.one_tuple.one_tuple_1.mean == 1 - assert component.one_tuple.one_tuple_0.sigma == 0.2 - assert component.one_tuple.one_tuple_1.sigma == 0.2 - - def test_model_centred_absolute(self, result): - component = result.model_centred_absolute(a=2.0).component - assert component.one_tuple.one_tuple_0.mean == 0 - assert component.one_tuple.one_tuple_1.mean == 1 - assert component.one_tuple.one_tuple_0.sigma == 2.0 - assert component.one_tuple.one_tuple_1.sigma == 2.0 - - def test_model_centred_relative(self, result): - component = result.model_centred_relative(r=1.0).component - assert component.one_tuple.one_tuple_0.mean == 0 - assert component.one_tuple.one_tuple_1.mean == 1 - assert component.one_tuple.one_tuple_0.sigma == 0.0 - assert component.one_tuple.one_tuple_1.sigma == 1.0 - - def test_raises(self, result): - with pytest.raises(af.exc.PriorException): - result.model.mapper_from_prior_means( - result.samples_summary.prior_means, a=2.0, r=1.0 - ) - - -class TestSearchConfig: - def test__explicit_params_accessible(self): - search = af.DynestyStatic(nlive=100) - assert search.nlive == 100 - - def test__run_params_accessible(self): - search = af.DynestyStatic(dlogz=0.5) - assert search.dlogz == 0.5 - - def test__unique_tag(self): - search = af.DynestyStatic(unique_tag="my_tag") - assert search.unique_tag == "my_tag" - - def test__path_prefix_and_name(self): - from pathlib import Path - - search = af.DynestyStatic( - path_prefix="prefix", - name="my_search", - ) - assert search.path_prefix == Path("prefix") - assert search.name == "my_search" - - def test__identifier_fields_differ_across_searches(self): - emcee = af.Emcee() - dynesty = af.DynestyStatic() - - assert emcee.__identifier_fields__ != dynesty.__identifier_fields__ - assert "nwalkers" in emcee.__identifier_fields__ - assert "nlive" in dynesty.__identifier_fields__ - - def test__bypass_fake_samples_support_multi_batch_checks(self): - model = af.Model(af.m.MockClassx2) - parameter_vector = [1.0, 2.0] - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=parameter_vector, - log_likelihood=-10.0, - ) - - assert len(sample_list) == 4 - assert sample_list[0].parameter_lists_for_model(model) == parameter_vector - assert sample_list[0].log_likelihood == -10.0 - assert [sample.log_likelihood for sample in sample_list] == [ - -10.0, - -11.0, - -12.0, - -13.0, - ] - assert all(sample.weight > 0.0 for sample in sample_list) - - -class TestBypassFakeSamplesSizeRealistic: - """ - PYAUTO_TEST_MODE_SAMPLES=N makes the bypass write N samples so - samples.csv row count / byte size match a production sampler stage - (PyAutoFit#1379; design locked on #1378). - """ - - def _samples_pdf(self, model, sample_list): - from autofit.non_linear.samples.pdf import SamplesPDF - - return SamplesPDF( - model=model, - sample_list=sample_list, - samples_info={ - "total_iterations": 1, - "time": 0.0, - "log_evidence": -10.0, - }, - ) - - def test__env_unset__legacy_four_samples_byte_identical(self, monkeypatch): - monkeypatch.delenv("PYAUTO_TEST_MODE_SAMPLES", raising=False) - - model = af.Model(af.m.MockClassx2) - parameter_vector = [1.0, 2.0] - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=parameter_vector, - log_likelihood=-10.0, - ) - - assert len(sample_list) == 4 - assert sample_list[0].parameter_lists_for_model(model) == [1.0, 2.0] - assert sample_list[1].parameter_lists_for_model(model) == [1.001, 2.002] - assert sample_list[2].parameter_lists_for_model(model) == [0.999, 1.998] - assert sample_list[3].parameter_lists_for_model(model) == [1.002, 2.004] - assert [sample.weight for sample in sample_list] == [1.0, 0.5, 0.25, 0.125] - - def test__env_below_four__raises(self, monkeypatch): - monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "3") - - with pytest.raises(ValueError): - af.DynestyStatic._build_fake_samples( - model=af.Model(af.m.MockClassx2), - parameter_vector=[1.0, 2.0], - log_likelihood=-10.0, - ) - - def test__large_n__structure_and_determinism(self, monkeypatch): - monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "50") - - model = af.Model(af.m.MockClassx2) - parameter_vector = [1.0, 2.0] - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=parameter_vector, - log_likelihood=-10.0, - ) - - assert len(sample_list) == 50 - - # Best sample is the unperturbed prior median, first, as in the - # 4-sample branch; likelihoods are monotone decreasing from it. - assert sample_list[0].parameter_lists_for_model(model) == [1.0, 2.0] - assert sample_list[0].log_likelihood == -10.0 - assert sample_list[-1].log_likelihood == -10.0 - 49.0 - - weights = [sample.weight for sample in sample_list] - assert all(w > 0.0 for w in weights) - assert weights == sorted(weights, reverse=True) - assert sum(weights) == pytest.approx(1.0) - - # Perturbed parameters stay within the 1e-3 scatter scale. - for sample in sample_list[1:]: - params = sample.parameter_lists_for_model(model) - assert params[0] == pytest.approx(1.0, abs=1e-2) - assert params[1] == pytest.approx(2.0, abs=2e-2) - - # Fixed seed: a second call reproduces the set exactly. - sample_list_repeat = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=parameter_vector, - log_likelihood=-10.0, - ) - assert [ - s.parameter_lists_for_model(model) for s in sample_list_repeat - ] == [s.parameter_lists_for_model(model) for s in sample_list] - - def test__zero_valued_parameter__perturbed_like_legacy_branch( - self, monkeypatch - ): - monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "20") - - model = af.Model(af.m.MockClassx2) - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=[0.0, 2.0], - log_likelihood=-10.0, - ) - - for sample in sample_list[1:]: - params = sample.parameter_lists_for_model(model) - assert params[0] != 0.0 - assert abs(params[0]) < 1e-2 - - def test__summary_and_median_pdf_on_synthetic_set(self, monkeypatch): - monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "50") - - model = af.Model(af.m.MockClassx2) - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=[1.0, 2.0], - log_likelihood=-10.0, - ) - samples = self._samples_pdf(model=model, sample_list=sample_list) - - # Max weight ~10/N < 0.99, so the real weighted-quantile path runs - # (the 4-sample branch's max weight 1.0 forces the unconverged - # fallback) — this is the production-representative code path. - assert samples.pdf_converged is True - - median_pdf = samples.median_pdf(as_instance=False) - assert median_pdf[0] == pytest.approx(1.0, abs=1e-2) - assert median_pdf[1] == pytest.approx(2.0, abs=2e-2) - - summary = samples.summary() - assert summary.max_log_likelihood_sample.log_likelihood == -10.0 - - lower, upper = samples.values_at_sigma(sigma=1.0, as_instance=False) - assert all(np.isfinite(lower)) and all(np.isfinite(upper)) - lower, upper = samples.values_at_sigma(sigma=3.0, as_instance=False) - assert all(np.isfinite(lower)) and all(np.isfinite(upper)) - - instance = samples.max_log_likelihood() - assert instance.one == 1.0 - assert instance.two == 2.0 - - def test__write_table_round_trip(self, monkeypatch, tmp_path): - import csv - - monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "100") - - model = af.Model(af.m.MockClassx2) - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=[1.0, 2.0], - log_likelihood=-10.0, - ) - samples = self._samples_pdf(model=model, sample_list=sample_list) - - filename = tmp_path / "samples.csv" - samples.write_table(filename=filename) - - with open(filename) as f: - rows = list(csv.reader(f)) - - assert len(rows) == 101 - headers = [h.strip() for h in rows[0]] - assert headers == model.joined_paths + [ - "log_likelihood", - "log_prior", - "log_posterior", - "weight", - ] - - # Every written weight stays above the output.yaml - # samples_weight_threshold (1e-10), so threshold-applying loads - # keep the full set (cf. PyAutoFit#1375). - weight_index = headers.index("weight") - weights = [float(row[weight_index]) for row in rows[1:]] - assert min(weights) > 1.0e-10 - - def test__functional_at_fifty_thousand(self, monkeypatch): - monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "50000") - - model = af.Model(af.m.MockClassx2) - - sample_list = af.DynestyStatic._build_fake_samples( - model=model, - parameter_vector=[1.0, 2.0], - log_likelihood=-10.0, - ) - - assert len(sample_list) == 50000 - assert sample_list[0].parameter_lists_for_model(model) == [1.0, 2.0] - - weights = np.array([sample.weight for sample in sample_list]) - assert weights.sum() == pytest.approx(1.0) - assert weights.min() > 1.0e-10 - - -class TestUpdaterPathsRefresh: - """ - The cached ``SearchUpdater`` must be invalidated when ``self.paths`` - is reassigned to a new object — otherwise output (samples, viz, - profiling) keeps writing under the FIRST paths the search ever saw. - The EP loop does this routinely: - ``AbstractSearch.optimise(factor_approx)`` reassigns ``self.paths`` - to a fresh ``SubDirectoryPaths`` per factor and per EP iteration. - """ - - def test__updater_refreshes_when_paths_reassigned(self): - search = af.DynestyStatic(name="updater_paths_test_a") - first_updater = search._updater - first_paths = search.paths - # Sanity: cache hit on second access with no path change. - assert search._updater is first_updater - - search.paths = af.DirectoryPaths(name="updater_paths_test_b") - second_updater = search._updater - - assert second_updater is not first_updater, ( - "Expected a fresh SearchUpdater after self.paths was reassigned" - ) - assert second_updater._paths is search.paths - assert second_updater._paths is not first_paths - - -class TestLabels: - def test_param_names(self): - model = af.Model(af.m.MockClassx4) - assert [ - "one", - "two", - "three", - "four", - ] == model.model_component_and_parameter_names - - def test_label_config(self): - assert conf.instance["notation"]["label"]["label"]["one"] == "one_label" - assert conf.instance["notation"]["label"]["label"]["two"] == "two_label" - assert conf.instance["notation"]["label"]["label"]["three"] == "three_label" - assert conf.instance["notation"]["label"]["label"]["four"] == "four_label" - - -class TestBypassWritesCompleted: - """ - A bypassed fit (PYAUTO_TEST_MODE=2/3) must mark itself complete, exactly - as start_resume_fit does — otherwise paths.is_complete stays False and a - rerun re-bypasses the whole pipeline instead of resuming from the - completed output (found by the SLaM resume profiler, autolens_profiling#70). - """ - - def test__bypass_marks_complete__second_fit_takes_completed_path( - self, monkeypatch - ): - monkeypatch.setenv("PYAUTO_TEST_MODE", "3") - - unique_tag = "bypass_completed_test" - - model = af.Model(af.m.MockClassx2) - analysis = af.m.MockAnalysis() - - search = af.DynestyStatic(name="bypass_completed", unique_tag=unique_tag) - search.fit(model=model, analysis=analysis) - - # Under the test config's `remove_files: true` only the zip remains - # after fit() — the marker must have been zipped up with the output. - import zipfile - from pathlib import Path - - with zipfile.ZipFile(search.paths._zip_path) as f: - assert ".completed" in {Path(n).name for n in f.namelist()} - - search_resumed = af.DynestyStatic( - name="bypass_completed", unique_tag=unique_tag - ) - - def _poison(*args, **kwargs): - raise AssertionError( - "start_resume_fit taken — the .completed marker is missing" - ) - - monkeypatch.setattr(search_resumed, "start_resume_fit", _poison) - - result = search_resumed.fit( - model=af.Model(af.m.MockClassx2), analysis=af.m.MockAnalysis() - ) - - assert result is not None +import os + +import numpy as np +import pytest + +import autofit as af +from autonerves import conf + +pytestmark = pytest.mark.filterwarnings("ignore::FutureWarning") + + +@pytest.fixture(name="mapper") +def make_mapper(): + return af.ModelMapper() + + +@pytest.fixture(name="mock_list") +def make_mock_list(): + return [af.Model(af.m.MockClassx4), af.Model(af.m.MockClassx4)] + + +@pytest.fixture(name="result") +def make_result(): + mapper = af.ModelMapper() + mapper.component = af.m.MockClassx2Tuple + # noinspection PyTypeChecker + return af.mock.MockResult( + samples_summary=af.m.MockSamplesSummary( + model=mapper, + prior_means=[0, 1], + ), + ) + + +def test__environment_variable_override(): + os.environ["OPENBLAS_NUM_THREADS"] = "2" + os.environ["MKL_NUM_THREADS"] = "2" + os.environ["OMP_NUM_THREADS"] = "2" + os.environ["VECLIB_MAXIMUM_THREADS"] = "2" + os.environ["NUMEXPR_NUM_THREADS"] = "2" + + conf.instance["general"]["parallel"]["warn_environment_variables"] = True + + with pytest.warns(af.exc.SearchWarning): + af.mock.MockSearch(number_of_cores=2) + + conf.instance["general"]["parallel"]["warn_environment_variables"] = False + +class TestResult: + def test_model(self, result): + + component = result.model.component + assert component.one_tuple.one_tuple_0.mean == 0.5 + assert component.one_tuple.one_tuple_1.mean == 1 + + def test_model_centred(self, result): + + component = result.model_centred.component + assert component.one_tuple.one_tuple_0.mean == 0 + assert component.one_tuple.one_tuple_1.mean == 1 + assert component.one_tuple.one_tuple_0.sigma == 0.2 + assert component.one_tuple.one_tuple_1.sigma == 0.2 + + def test_model_centred_absolute(self, result): + component = result.model_centred_absolute(a=2.0).component + assert component.one_tuple.one_tuple_0.mean == 0 + assert component.one_tuple.one_tuple_1.mean == 1 + assert component.one_tuple.one_tuple_0.sigma == 2.0 + assert component.one_tuple.one_tuple_1.sigma == 2.0 + + def test_model_centred_relative(self, result): + component = result.model_centred_relative(r=1.0).component + assert component.one_tuple.one_tuple_0.mean == 0 + assert component.one_tuple.one_tuple_1.mean == 1 + assert component.one_tuple.one_tuple_0.sigma == 0.0 + assert component.one_tuple.one_tuple_1.sigma == 1.0 + + def test_raises(self, result): + with pytest.raises(af.exc.PriorException): + result.model.mapper_from_prior_means( + result.samples_summary.prior_means, a=2.0, r=1.0 + ) + + +class TestSearchConfig: + def test__explicit_params_accessible(self): + search = af.DynestyStatic(nlive=100) + assert search.nlive == 100 + + def test__run_params_accessible(self): + search = af.DynestyStatic(dlogz=0.5) + assert search.dlogz == 0.5 + + def test__unique_tag(self): + search = af.DynestyStatic(unique_tag="my_tag") + assert search.unique_tag == "my_tag" + + def test__path_prefix_and_name(self): + from pathlib import Path + + search = af.DynestyStatic( + path_prefix="prefix", + name="my_search", + ) + assert search.path_prefix == Path("prefix") + assert search.name == "my_search" + + def test__identifier_fields_differ_across_searches(self): + emcee = af.Emcee() + dynesty = af.DynestyStatic() + + assert emcee.__identifier_fields__ != dynesty.__identifier_fields__ + assert "nwalkers" in emcee.__identifier_fields__ + assert "nlive" in dynesty.__identifier_fields__ + + def test__bypass_fake_samples_support_multi_batch_checks(self): + model = af.Model(af.m.MockClassx2) + parameter_vector = [1.0, 2.0] + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=parameter_vector, + log_likelihood=-10.0, + ) + + assert len(sample_list) == 4 + assert sample_list[0].parameter_lists_for_model(model) == parameter_vector + assert sample_list[0].log_likelihood == -10.0 + assert [sample.log_likelihood for sample in sample_list] == [ + -10.0, + -11.0, + -12.0, + -13.0, + ] + assert all(sample.weight > 0.0 for sample in sample_list) + + +class TestBypassFakeSamplesSizeRealistic: + """ + PYAUTO_TEST_MODE_SAMPLES=N makes the bypass write N samples so + samples.csv row count / byte size match a production sampler stage + (PyAutoFit#1379; design locked on #1378). + """ + + def _samples_pdf(self, model, sample_list): + from autofit.non_linear.samples.pdf import SamplesPDF + + return SamplesPDF( + model=model, + sample_list=sample_list, + samples_info={ + "total_iterations": 1, + "time": 0.0, + "log_evidence": -10.0, + }, + ) + + def test__env_unset__legacy_four_samples_byte_identical(self, monkeypatch): + monkeypatch.delenv("PYAUTO_TEST_MODE_SAMPLES", raising=False) + + model = af.Model(af.m.MockClassx2) + parameter_vector = [1.0, 2.0] + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=parameter_vector, + log_likelihood=-10.0, + ) + + assert len(sample_list) == 4 + assert sample_list[0].parameter_lists_for_model(model) == [1.0, 2.0] + assert sample_list[1].parameter_lists_for_model(model) == [1.001, 2.002] + assert sample_list[2].parameter_lists_for_model(model) == [0.999, 1.998] + assert sample_list[3].parameter_lists_for_model(model) == [1.002, 2.004] + assert [sample.weight for sample in sample_list] == [1.0, 0.5, 0.25, 0.125] + + def test__env_below_four__raises(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "3") + + with pytest.raises(ValueError): + af.DynestyStatic._build_fake_samples( + model=af.Model(af.m.MockClassx2), + parameter_vector=[1.0, 2.0], + log_likelihood=-10.0, + ) + + def test__large_n__structure_and_determinism(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "50") + + model = af.Model(af.m.MockClassx2) + parameter_vector = [1.0, 2.0] + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=parameter_vector, + log_likelihood=-10.0, + ) + + assert len(sample_list) == 50 + + # Best sample is the unperturbed prior median, first, as in the + # 4-sample branch; likelihoods are monotone decreasing from it. + assert sample_list[0].parameter_lists_for_model(model) == [1.0, 2.0] + assert sample_list[0].log_likelihood == -10.0 + assert sample_list[-1].log_likelihood == -10.0 - 49.0 + + weights = [sample.weight for sample in sample_list] + assert all(w > 0.0 for w in weights) + assert weights == sorted(weights, reverse=True) + assert sum(weights) == pytest.approx(1.0) + + # Perturbed parameters stay within the 1e-3 scatter scale. + for sample in sample_list[1:]: + params = sample.parameter_lists_for_model(model) + assert params[0] == pytest.approx(1.0, abs=1e-2) + assert params[1] == pytest.approx(2.0, abs=2e-2) + + # Fixed seed: a second call reproduces the set exactly. + sample_list_repeat = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=parameter_vector, + log_likelihood=-10.0, + ) + assert [ + s.parameter_lists_for_model(model) for s in sample_list_repeat + ] == [s.parameter_lists_for_model(model) for s in sample_list] + + def test__zero_valued_parameter__perturbed_like_legacy_branch( + self, monkeypatch + ): + monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "20") + + model = af.Model(af.m.MockClassx2) + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=[0.0, 2.0], + log_likelihood=-10.0, + ) + + for sample in sample_list[1:]: + params = sample.parameter_lists_for_model(model) + assert params[0] != 0.0 + assert abs(params[0]) < 1e-2 + + def test__summary_and_median_pdf_on_synthetic_set(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "50") + + model = af.Model(af.m.MockClassx2) + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=[1.0, 2.0], + log_likelihood=-10.0, + ) + samples = self._samples_pdf(model=model, sample_list=sample_list) + + # Max weight ~10/N < 0.99, so the real weighted-quantile path runs + # (the 4-sample branch's max weight 1.0 forces the unconverged + # fallback) — this is the production-representative code path. + assert samples.pdf_converged is True + + median_pdf = samples.median_pdf(as_instance=False) + assert median_pdf[0] == pytest.approx(1.0, abs=1e-2) + assert median_pdf[1] == pytest.approx(2.0, abs=2e-2) + + summary = samples.summary() + assert summary.max_log_likelihood_sample.log_likelihood == -10.0 + + lower, upper = samples.values_at_sigma(sigma=1.0, as_instance=False) + assert all(np.isfinite(lower)) and all(np.isfinite(upper)) + lower, upper = samples.values_at_sigma(sigma=3.0, as_instance=False) + assert all(np.isfinite(lower)) and all(np.isfinite(upper)) + + instance = samples.max_log_likelihood() + assert instance.one == 1.0 + assert instance.two == 2.0 + + def test__write_table_round_trip(self, monkeypatch, tmp_path): + import csv + + monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "100") + + model = af.Model(af.m.MockClassx2) + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=[1.0, 2.0], + log_likelihood=-10.0, + ) + samples = self._samples_pdf(model=model, sample_list=sample_list) + + filename = tmp_path / "samples.csv" + samples.write_table(filename=filename) + + with open(filename) as f: + rows = list(csv.reader(f)) + + assert len(rows) == 101 + headers = [h.strip() for h in rows[0]] + assert headers == model.joined_paths + [ + "log_likelihood", + "log_prior", + "log_posterior", + "weight", + ] + + # Every written weight stays above the output.yaml + # samples_weight_threshold (1e-10), so threshold-applying loads + # keep the full set (cf. PyAutoFit#1375). + weight_index = headers.index("weight") + weights = [float(row[weight_index]) for row in rows[1:]] + assert min(weights) > 1.0e-10 + + def test__functional_at_fifty_thousand(self, monkeypatch): + monkeypatch.setenv("PYAUTO_TEST_MODE_SAMPLES", "50000") + + model = af.Model(af.m.MockClassx2) + + sample_list = af.DynestyStatic._build_fake_samples( + model=model, + parameter_vector=[1.0, 2.0], + log_likelihood=-10.0, + ) + + assert len(sample_list) == 50000 + assert sample_list[0].parameter_lists_for_model(model) == [1.0, 2.0] + + weights = np.array([sample.weight for sample in sample_list]) + assert weights.sum() == pytest.approx(1.0) + assert weights.min() > 1.0e-10 + + +class TestUpdaterPathsRefresh: + """ + The cached ``SearchUpdater`` must be invalidated when ``self.paths`` + is reassigned to a new object — otherwise output (samples, viz, + profiling) keeps writing under the FIRST paths the search ever saw. + The EP loop does this routinely: + ``AbstractSearch.optimise(factor_approx)`` reassigns ``self.paths`` + to a fresh ``SubDirectoryPaths`` per factor and per EP iteration. + """ + + def test__updater_refreshes_when_paths_reassigned(self): + search = af.DynestyStatic(name="updater_paths_test_a") + first_updater = search._updater + first_paths = search.paths + # Sanity: cache hit on second access with no path change. + assert search._updater is first_updater + + search.paths = af.DirectoryPaths(name="updater_paths_test_b") + second_updater = search._updater + + assert second_updater is not first_updater, ( + "Expected a fresh SearchUpdater after self.paths was reassigned" + ) + assert second_updater._paths is search.paths + assert second_updater._paths is not first_paths + + +class TestLabels: + def test_param_names(self): + model = af.Model(af.m.MockClassx4) + assert [ + "one", + "two", + "three", + "four", + ] == model.model_component_and_parameter_names + + def test_label_config(self): + assert conf.instance["notation"]["label"]["label"]["one"] == "one_label" + assert conf.instance["notation"]["label"]["label"]["two"] == "two_label" + assert conf.instance["notation"]["label"]["label"]["three"] == "three_label" + assert conf.instance["notation"]["label"]["label"]["four"] == "four_label" + + +class TestBypassWritesCompleted: + """ + A bypassed fit (PYAUTO_TEST_MODE=2/3) must mark itself complete, exactly + as start_resume_fit does — otherwise paths.is_complete stays False and a + rerun re-bypasses the whole pipeline instead of resuming from the + completed output (found by the SLaM resume profiler, autolens_profiling#70). + """ + + def test__bypass_marks_complete__second_fit_takes_completed_path( + self, monkeypatch + ): + monkeypatch.setenv("PYAUTO_TEST_MODE", "3") + + unique_tag = "bypass_completed_test" + + model = af.Model(af.m.MockClassx2) + analysis = af.m.MockAnalysis() + + search = af.DynestyStatic(name="bypass_completed", unique_tag=unique_tag) + search.fit(model=model, analysis=analysis) + + # Under the test config's `remove_files: true` only the zip remains + # after fit() — the marker must have been zipped up with the output. + import zipfile + from pathlib import Path + + with zipfile.ZipFile(search.paths._zip_path) as f: + assert ".completed" in {Path(n).name for n in f.namelist()} + + search_resumed = af.DynestyStatic( + name="bypass_completed", unique_tag=unique_tag + ) + + def _poison(*args, **kwargs): + raise AssertionError( + "start_resume_fit taken — the .completed marker is missing" + ) + + monkeypatch.setattr(search_resumed, "start_resume_fit", _poison) + + result = search_resumed.fit( + model=af.Model(af.m.MockClassx2), analysis=af.m.MockAnalysis() + ) + + assert result is not None class _FitExceptionAnalysis(af.m.MockAnalysis): diff --git a/test_autofit/non_linear/test_initializer.py b/test_autofit/non_linear/test_initializer.py index f45c619c1..b6253fd7e 100644 --- a/test_autofit/non_linear/test_initializer.py +++ b/test_autofit/non_linear/test_initializer.py @@ -1,369 +1,369 @@ -import os -from random import random - -import pytest - -import autofit as af - - -class MockFitness: - def __init__(self, figure_of_merit=0.0, change_figure_of_merit=True): - self.figure_of_merit = figure_of_merit - self.change_figure_of_merit = change_figure_of_merit - - def __call__(self, parameters): - if self.change_figure_of_merit: - return -random() * 10 - return self.figure_of_merit - - -@pytest.fixture -def model_and_samples(): - model = af.Model(af.m.MockClassx4) - model.one = af.UniformPrior(lower_limit=0.099, upper_limit=0.101) - model.two = af.UniformPrior(lower_limit=0.199, upper_limit=0.201) - model.three = af.UniformPrior(lower_limit=0.299, upper_limit=0.301) - model.four = af.UniformPrior(lower_limit=0.399, upper_limit=0.401) - - initializer = af.InitializerPrior() - - unit_parameter_lists, parameter_lists, _ = initializer.samples_from_model( - total_points=2, - model=model, - fitness=MockFitness(), - paths=af.DirectoryPaths(), - ) - - return unit_parameter_lists, parameter_lists - -@pytest.mark.parametrize("index, param_index, lower, upper", [ - (0, 0, 0.0, 1.0), - (1, 0, 0.0, 1.0), - (0, 1, 0.0, 1.0), - (1, 1, 0.0, 1.0), - (0, 2, 0.0, 1.0), - (1, 2, 0.0, 1.0), - (0, 3, 0.0, 1.0), - (1, 3, 0.0, 1.0), -]) -def test_unit_parameter_lists(model_and_samples, index, param_index, lower, upper): - unit_parameter_lists, _ = model_and_samples - assert lower < unit_parameter_lists[index][param_index] < upper - -@pytest.mark.parametrize("index, param_index, lower, upper", [ - (0, 0, 0.099, 0.101), - (1, 0, 0.099, 0.101), - (0, 1, 0.199, 0.201), - (1, 1, 0.199, 0.201), - (0, 2, 0.299, 0.301), - (1, 2, 0.299, 0.301), - (0, 3, 0.399, 0.401), - (1, 3, 0.399, 0.401), -]) -def test_parameter_lists(model_and_samples, index, param_index, lower, upper): - _, parameter_lists = model_and_samples - assert lower < parameter_lists[index][param_index] < upper - - -def test__priors__samples_from_model__raise_exception_if_all_likelihoods_identical(): - model = af.Model(af.m.MockClassx4) - - initializer = af.InitializerPrior() - - with pytest.raises(af.exc.InitializerException): - initializer.samples_from_model( - total_points=2, - model=model, - fitness=MockFitness(change_figure_of_merit=False), - paths=af.DirectoryPaths(), - ) - - -def test__priors__samples_in_test_mode(): - os.environ["PYAUTO_TEST_MODE"] = "1" - - model = af.Model(af.m.MockClassx4) - model.one = af.UniformPrior(lower_limit=0.099, upper_limit=0.101) - model.two = af.UniformPrior(lower_limit=0.199, upper_limit=0.201) - model.three = af.UniformPrior(lower_limit=0.299, upper_limit=0.301) - model.four = af.UniformPrior(lower_limit=0.399, upper_limit=0.401) - - initializer = af.InitializerPrior() - - ( - unit_parameter_lists, - parameter_lists, - figure_of_merit_list, - ) = initializer.samples_from_model( - total_points=2, - model=model, - fitness=None, - paths=af.DirectoryPaths(), - ) - - assert 0.0 < unit_parameter_lists[0][0] < 1.0 - assert 0.0 < unit_parameter_lists[1][0] < 1.0 - assert 0.0 < unit_parameter_lists[0][1] < 1.0 - assert 0.0 < unit_parameter_lists[1][1] < 1.0 - assert 0.0 < unit_parameter_lists[0][2] < 1.0 - assert 0.0 < unit_parameter_lists[1][2] < 1.0 - assert 0.0 < unit_parameter_lists[0][3] < 1.0 - assert 0.0 < unit_parameter_lists[1][3] < 1.0 - - assert 0.099 < parameter_lists[0][0] < 0.101 - assert 0.099 < parameter_lists[1][0] < 0.101 - assert 0.199 < parameter_lists[0][1] < 0.201 - assert 0.199 < parameter_lists[1][1] < 0.201 - assert 0.299 < parameter_lists[0][2] < 0.301 - assert 0.299 < parameter_lists[1][2] < 0.301 - assert 0.399 < parameter_lists[0][3] < 0.401 - assert 0.399 < parameter_lists[1][3] < 0.401 - - assert figure_of_merit_list == [-1.0e99, -1.0e100] - - os.environ["PYAUTO_TEST_MODE"] = "0" - - -def test__ball__samples_sample_centre_of_priors(): - model = af.Model(af.m.MockClassx4) - model.one = af.UniformPrior(lower_limit=0.0, upper_limit=1.0) - model.two = af.UniformPrior(lower_limit=0.0, upper_limit=2.0) - model.three = af.UniformPrior(lower_limit=0.0, upper_limit=3.0) - model.four = af.UniformPrior(lower_limit=0.0, upper_limit=4.0) - - initializer = af.InitializerBall(lower_limit=0.4999, upper_limit=0.5001) - - ( - unit_parameter_lists, - parameter_lists, - figure_of_merit_list, - ) = initializer.samples_from_model( - total_points=2, - model=model, - fitness=MockFitness(), - paths=af.DirectoryPaths(), - ) - - assert 0.4999 < unit_parameter_lists[0][0] < 0.5001 - assert 0.4999 < unit_parameter_lists[1][0] < 0.5001 - assert 0.4999 < unit_parameter_lists[0][1] < 0.5001 - assert 0.4999 < unit_parameter_lists[1][1] < 0.5001 - assert 0.4999 < unit_parameter_lists[0][2] < 0.5001 - assert 0.4999 < unit_parameter_lists[1][2] < 0.5001 - assert 0.4999 < unit_parameter_lists[0][3] < 0.5001 - assert 0.4999 < unit_parameter_lists[1][3] < 0.5001 - - assert 0.499 < parameter_lists[0][0] < 0.501 - assert 0.499 < parameter_lists[1][0] < 0.501 - assert 0.999 < parameter_lists[0][1] < 1.001 - assert 0.999 < parameter_lists[1][1] < 1.001 - assert 1.499 < parameter_lists[0][2] < 1.501 - assert 1.499 < parameter_lists[1][2] < 1.501 - assert 1.999 < parameter_lists[0][3] < 2.001 - assert 1.999 < parameter_lists[1][3] < 2.001 - - initializer = af.InitializerBall(lower_limit=0.7999, upper_limit=0.8001) - - ( - unit_parameter_lists, - parameter_lists, - figure_of_merit_list, - ) = initializer.samples_from_model( - total_points=2, - model=model, - fitness=MockFitness(), - paths=af.DirectoryPaths(), - ) - - assert 0.799 < parameter_lists[0][0] < 0.801 - assert 0.799 < parameter_lists[1][0] < 0.801 - assert 1.599 < parameter_lists[0][1] < 1.601 - assert 1.599 < parameter_lists[1][1] < 1.601 - assert 2.399 < parameter_lists[0][2] < 2.401 - assert 2.399 < parameter_lists[1][2] < 2.401 - assert 3.199 < parameter_lists[0][3] < 3.201 - assert 3.199 < parameter_lists[1][3] < 3.201 - - -@pytest.mark.parametrize( - "unit_value, physical_value", - [ - (0.0, 0.0), - (0.5, 0.5), - (1.0, 1.0), - ], -) -def test_invert_physical(unit_value, physical_value): - prior = af.UniformPrior( - lower_limit=0.0, - upper_limit=1.0, - ) - assert prior.unit_value_for(unit_value) == pytest.approx(physical_value) - - -@pytest.mark.parametrize( - "unit_value, physical_value", - [ - (1.0, 0.0), - (2.0, 0.5), - (3.0, 1.0), - ], -) -def test_invert_physical_offset(unit_value, physical_value): - prior = af.UniformPrior( - lower_limit=1.0, - upper_limit=3.0, - ) - assert prior.unit_value_for(unit_value) == pytest.approx(physical_value) - - -@pytest.mark.parametrize( - "unit_value, physical_value", - [ - (-float("inf"), 0.0), - (0.0, 0.5), - (float("inf"), 1.0), - ], -) -def test_invert_gaussian(unit_value, physical_value): - prior = af.GaussianPrior( - mean=0.0, - sigma=3.0, - ) - assert prior.unit_value_for(unit_value) == physical_value - - -@pytest.fixture(name="model") -def make_model(): - return af.Model( - af.ex.Gaussian, - centre=af.UniformPrior(1.0, 2.0), - normalization=af.UniformPrior(2.0, 3.0), - sigma=af.UniformPrior(-2.0, -1.0), - ) - - -def test_initializer_bounds(model): - initializer = af.InitializerParamBounds( - { - model.centre: (1.0, 2.0), - model.normalization: (2.0, 3.0), - model.sigma: (-2.0, -1.0), - } - ) - - parameter_list = initializer._generate_unit_parameter_list(model) - assert len(parameter_list) == 3 - for parameter in parameter_list: - assert 0.0 <= parameter <= 1.0 - - -def test__initializer_bounds__info_from_model(model): - - initializer = af.InitializerParamBounds( - { - model.centre: (1.0, 2.0), - model.normalization: (2.0, 3.0), - model.sigma: (-2.0, -1.0), - } - ) - - info = initializer.info_from_model(model) - - assert "Total Free Parameters = 3" in info - assert "centre: Start[(1.0, 2.0)]" in info - - -def test__initializer_start_point(model): - initializer = af.InitializerParamStartPoints( - { - model.centre: 1.5, - model.normalization: 2.5, - model.sigma: -1.5, - } - ) - - parameter_list = initializer._generate_unit_parameter_list(model) - assert len(parameter_list) == 3 - for parameter in parameter_list: - assert 0.0 <= parameter <= 1.0 - - -def test__initializer_start_point__info_from_model(model): - initializer = af.InitializerParamStartPoints( - { - model.centre: 1.5, - model.normalization: 2.5, - model.sigma: -1.5, - } - ) - info = initializer.info_from_model(model) - - assert "Total Free Parameters = 3" in info - assert "centre: Start[1.5]" in info - - -def test_offset(model): - initializer = af.InitializerParamBounds( - { - model.centre: (1.5, 2.0), - model.normalization: (2.5, 3.0), - model.sigma: (-1.5, -1.0), - } - ) - - parameter_list = initializer._generate_unit_parameter_list(model) - assert len(parameter_list) == 3 - for parameter in parameter_list: - assert 0.5 <= parameter <= 1.0 - - -def test_missing_parameter(model): - initializer = af.InitializerParamBounds( - { - model.centre: (1.5, 2.0), - model.normalization: (2.5, 3.0), - }, - lower_limit=0.5, - upper_limit=0.5, - ) - parameter_list = initializer._generate_unit_parameter_list(model) - - assert len(parameter_list) == 3 - for parameter in parameter_list: - assert 0.5 <= parameter <= 1.0 - - assert 0.5 in parameter_list - - -class MockFitnessNonFloat: - def __call__(self, parameters): - import numpy as _np - return _np.array(-1.5 - 0.01 * _np.random.rand()) - - -def test__figure_of_metric__coerces_non_float_scalar_to_python_float(): - import json - import numpy as _np - - model = af.Model(af.m.MockClassx4) - model.one = af.UniformPrior(lower_limit=0.099, upper_limit=0.101) - model.two = af.UniformPrior(lower_limit=0.199, upper_limit=0.201) - model.three = af.UniformPrior(lower_limit=0.299, upper_limit=0.301) - model.four = af.UniformPrior(lower_limit=0.399, upper_limit=0.401) - - initializer = af.InitializerPrior() - - _, _, figures_of_merit_list = initializer.samples_from_model( - total_points=3, - model=model, - fitness=MockFitnessNonFloat(), - paths=af.DirectoryPaths(), - test_mode_samples=False, - ) - - for fom in figures_of_merit_list: - assert type(fom) is float - assert not isinstance(fom, _np.ndarray) - - json.dumps(figures_of_merit_list) +import os +from random import random + +import pytest + +import autofit as af + + +class MockFitness: + def __init__(self, figure_of_merit=0.0, change_figure_of_merit=True): + self.figure_of_merit = figure_of_merit + self.change_figure_of_merit = change_figure_of_merit + + def __call__(self, parameters): + if self.change_figure_of_merit: + return -random() * 10 + return self.figure_of_merit + + +@pytest.fixture +def model_and_samples(): + model = af.Model(af.m.MockClassx4) + model.one = af.UniformPrior(lower_limit=0.099, upper_limit=0.101) + model.two = af.UniformPrior(lower_limit=0.199, upper_limit=0.201) + model.three = af.UniformPrior(lower_limit=0.299, upper_limit=0.301) + model.four = af.UniformPrior(lower_limit=0.399, upper_limit=0.401) + + initializer = af.InitializerPrior() + + unit_parameter_lists, parameter_lists, _ = initializer.samples_from_model( + total_points=2, + model=model, + fitness=MockFitness(), + paths=af.DirectoryPaths(), + ) + + return unit_parameter_lists, parameter_lists + +@pytest.mark.parametrize("index, param_index, lower, upper", [ + (0, 0, 0.0, 1.0), + (1, 0, 0.0, 1.0), + (0, 1, 0.0, 1.0), + (1, 1, 0.0, 1.0), + (0, 2, 0.0, 1.0), + (1, 2, 0.0, 1.0), + (0, 3, 0.0, 1.0), + (1, 3, 0.0, 1.0), +]) +def test_unit_parameter_lists(model_and_samples, index, param_index, lower, upper): + unit_parameter_lists, _ = model_and_samples + assert lower < unit_parameter_lists[index][param_index] < upper + +@pytest.mark.parametrize("index, param_index, lower, upper", [ + (0, 0, 0.099, 0.101), + (1, 0, 0.099, 0.101), + (0, 1, 0.199, 0.201), + (1, 1, 0.199, 0.201), + (0, 2, 0.299, 0.301), + (1, 2, 0.299, 0.301), + (0, 3, 0.399, 0.401), + (1, 3, 0.399, 0.401), +]) +def test_parameter_lists(model_and_samples, index, param_index, lower, upper): + _, parameter_lists = model_and_samples + assert lower < parameter_lists[index][param_index] < upper + + +def test__priors__samples_from_model__raise_exception_if_all_likelihoods_identical(): + model = af.Model(af.m.MockClassx4) + + initializer = af.InitializerPrior() + + with pytest.raises(af.exc.InitializerException): + initializer.samples_from_model( + total_points=2, + model=model, + fitness=MockFitness(change_figure_of_merit=False), + paths=af.DirectoryPaths(), + ) + + +def test__priors__samples_in_test_mode(): + os.environ["PYAUTO_TEST_MODE"] = "1" + + model = af.Model(af.m.MockClassx4) + model.one = af.UniformPrior(lower_limit=0.099, upper_limit=0.101) + model.two = af.UniformPrior(lower_limit=0.199, upper_limit=0.201) + model.three = af.UniformPrior(lower_limit=0.299, upper_limit=0.301) + model.four = af.UniformPrior(lower_limit=0.399, upper_limit=0.401) + + initializer = af.InitializerPrior() + + ( + unit_parameter_lists, + parameter_lists, + figure_of_merit_list, + ) = initializer.samples_from_model( + total_points=2, + model=model, + fitness=None, + paths=af.DirectoryPaths(), + ) + + assert 0.0 < unit_parameter_lists[0][0] < 1.0 + assert 0.0 < unit_parameter_lists[1][0] < 1.0 + assert 0.0 < unit_parameter_lists[0][1] < 1.0 + assert 0.0 < unit_parameter_lists[1][1] < 1.0 + assert 0.0 < unit_parameter_lists[0][2] < 1.0 + assert 0.0 < unit_parameter_lists[1][2] < 1.0 + assert 0.0 < unit_parameter_lists[0][3] < 1.0 + assert 0.0 < unit_parameter_lists[1][3] < 1.0 + + assert 0.099 < parameter_lists[0][0] < 0.101 + assert 0.099 < parameter_lists[1][0] < 0.101 + assert 0.199 < parameter_lists[0][1] < 0.201 + assert 0.199 < parameter_lists[1][1] < 0.201 + assert 0.299 < parameter_lists[0][2] < 0.301 + assert 0.299 < parameter_lists[1][2] < 0.301 + assert 0.399 < parameter_lists[0][3] < 0.401 + assert 0.399 < parameter_lists[1][3] < 0.401 + + assert figure_of_merit_list == [-1.0e99, -1.0e100] + + os.environ["PYAUTO_TEST_MODE"] = "0" + + +def test__ball__samples_sample_centre_of_priors(): + model = af.Model(af.m.MockClassx4) + model.one = af.UniformPrior(lower_limit=0.0, upper_limit=1.0) + model.two = af.UniformPrior(lower_limit=0.0, upper_limit=2.0) + model.three = af.UniformPrior(lower_limit=0.0, upper_limit=3.0) + model.four = af.UniformPrior(lower_limit=0.0, upper_limit=4.0) + + initializer = af.InitializerBall(lower_limit=0.4999, upper_limit=0.5001) + + ( + unit_parameter_lists, + parameter_lists, + figure_of_merit_list, + ) = initializer.samples_from_model( + total_points=2, + model=model, + fitness=MockFitness(), + paths=af.DirectoryPaths(), + ) + + assert 0.4999 < unit_parameter_lists[0][0] < 0.5001 + assert 0.4999 < unit_parameter_lists[1][0] < 0.5001 + assert 0.4999 < unit_parameter_lists[0][1] < 0.5001 + assert 0.4999 < unit_parameter_lists[1][1] < 0.5001 + assert 0.4999 < unit_parameter_lists[0][2] < 0.5001 + assert 0.4999 < unit_parameter_lists[1][2] < 0.5001 + assert 0.4999 < unit_parameter_lists[0][3] < 0.5001 + assert 0.4999 < unit_parameter_lists[1][3] < 0.5001 + + assert 0.499 < parameter_lists[0][0] < 0.501 + assert 0.499 < parameter_lists[1][0] < 0.501 + assert 0.999 < parameter_lists[0][1] < 1.001 + assert 0.999 < parameter_lists[1][1] < 1.001 + assert 1.499 < parameter_lists[0][2] < 1.501 + assert 1.499 < parameter_lists[1][2] < 1.501 + assert 1.999 < parameter_lists[0][3] < 2.001 + assert 1.999 < parameter_lists[1][3] < 2.001 + + initializer = af.InitializerBall(lower_limit=0.7999, upper_limit=0.8001) + + ( + unit_parameter_lists, + parameter_lists, + figure_of_merit_list, + ) = initializer.samples_from_model( + total_points=2, + model=model, + fitness=MockFitness(), + paths=af.DirectoryPaths(), + ) + + assert 0.799 < parameter_lists[0][0] < 0.801 + assert 0.799 < parameter_lists[1][0] < 0.801 + assert 1.599 < parameter_lists[0][1] < 1.601 + assert 1.599 < parameter_lists[1][1] < 1.601 + assert 2.399 < parameter_lists[0][2] < 2.401 + assert 2.399 < parameter_lists[1][2] < 2.401 + assert 3.199 < parameter_lists[0][3] < 3.201 + assert 3.199 < parameter_lists[1][3] < 3.201 + + +@pytest.mark.parametrize( + "unit_value, physical_value", + [ + (0.0, 0.0), + (0.5, 0.5), + (1.0, 1.0), + ], +) +def test_invert_physical(unit_value, physical_value): + prior = af.UniformPrior( + lower_limit=0.0, + upper_limit=1.0, + ) + assert prior.unit_value_for(unit_value) == pytest.approx(physical_value) + + +@pytest.mark.parametrize( + "unit_value, physical_value", + [ + (1.0, 0.0), + (2.0, 0.5), + (3.0, 1.0), + ], +) +def test_invert_physical_offset(unit_value, physical_value): + prior = af.UniformPrior( + lower_limit=1.0, + upper_limit=3.0, + ) + assert prior.unit_value_for(unit_value) == pytest.approx(physical_value) + + +@pytest.mark.parametrize( + "unit_value, physical_value", + [ + (-float("inf"), 0.0), + (0.0, 0.5), + (float("inf"), 1.0), + ], +) +def test_invert_gaussian(unit_value, physical_value): + prior = af.GaussianPrior( + mean=0.0, + sigma=3.0, + ) + assert prior.unit_value_for(unit_value) == physical_value + + +@pytest.fixture(name="model") +def make_model(): + return af.Model( + af.ex.Gaussian, + centre=af.UniformPrior(1.0, 2.0), + normalization=af.UniformPrior(2.0, 3.0), + sigma=af.UniformPrior(-2.0, -1.0), + ) + + +def test_initializer_bounds(model): + initializer = af.InitializerParamBounds( + { + model.centre: (1.0, 2.0), + model.normalization: (2.0, 3.0), + model.sigma: (-2.0, -1.0), + } + ) + + parameter_list = initializer._generate_unit_parameter_list(model) + assert len(parameter_list) == 3 + for parameter in parameter_list: + assert 0.0 <= parameter <= 1.0 + + +def test__initializer_bounds__info_from_model(model): + + initializer = af.InitializerParamBounds( + { + model.centre: (1.0, 2.0), + model.normalization: (2.0, 3.0), + model.sigma: (-2.0, -1.0), + } + ) + + info = initializer.info_from_model(model) + + assert "Total Free Parameters = 3" in info + assert "centre: Start[(1.0, 2.0)]" in info + + +def test__initializer_start_point(model): + initializer = af.InitializerParamStartPoints( + { + model.centre: 1.5, + model.normalization: 2.5, + model.sigma: -1.5, + } + ) + + parameter_list = initializer._generate_unit_parameter_list(model) + assert len(parameter_list) == 3 + for parameter in parameter_list: + assert 0.0 <= parameter <= 1.0 + + +def test__initializer_start_point__info_from_model(model): + initializer = af.InitializerParamStartPoints( + { + model.centre: 1.5, + model.normalization: 2.5, + model.sigma: -1.5, + } + ) + info = initializer.info_from_model(model) + + assert "Total Free Parameters = 3" in info + assert "centre: Start[1.5]" in info + + +def test_offset(model): + initializer = af.InitializerParamBounds( + { + model.centre: (1.5, 2.0), + model.normalization: (2.5, 3.0), + model.sigma: (-1.5, -1.0), + } + ) + + parameter_list = initializer._generate_unit_parameter_list(model) + assert len(parameter_list) == 3 + for parameter in parameter_list: + assert 0.5 <= parameter <= 1.0 + + +def test_missing_parameter(model): + initializer = af.InitializerParamBounds( + { + model.centre: (1.5, 2.0), + model.normalization: (2.5, 3.0), + }, + lower_limit=0.5, + upper_limit=0.5, + ) + parameter_list = initializer._generate_unit_parameter_list(model) + + assert len(parameter_list) == 3 + for parameter in parameter_list: + assert 0.5 <= parameter <= 1.0 + + assert 0.5 in parameter_list + + +class MockFitnessNonFloat: + def __call__(self, parameters): + import numpy as _np + return _np.array(-1.5 - 0.01 * _np.random.rand()) + + +def test__figure_of_metric__coerces_non_float_scalar_to_python_float(): + import json + import numpy as _np + + model = af.Model(af.m.MockClassx4) + model.one = af.UniformPrior(lower_limit=0.099, upper_limit=0.101) + model.two = af.UniformPrior(lower_limit=0.199, upper_limit=0.201) + model.three = af.UniformPrior(lower_limit=0.299, upper_limit=0.301) + model.four = af.UniformPrior(lower_limit=0.399, upper_limit=0.401) + + initializer = af.InitializerPrior() + + _, _, figures_of_merit_list = initializer.samples_from_model( + total_points=3, + model=model, + fitness=MockFitnessNonFloat(), + paths=af.DirectoryPaths(), + test_mode_samples=False, + ) + + for fom in figures_of_merit_list: + assert type(fom) is float + assert not isinstance(fom, _np.ndarray) + + json.dumps(figures_of_merit_list) diff --git a/test_autofit/non_linear/test_parallel.py b/test_autofit/non_linear/test_parallel.py index ff1e654b0..8e88f7cbd 100644 --- a/test_autofit/non_linear/test_parallel.py +++ b/test_autofit/non_linear/test_parallel.py @@ -1,27 +1,27 @@ -import autofit as af -from autofit.non_linear.parallel.sneaky import SneakyProcess - -from pathlib import Path - -class MockSneakyProcess(SneakyProcess): - def __init__( - self, - paths, - ): - - super().__init__( - name="test", - paths=paths - ) - -def test__test_mode_parallel_profile_outputs_prof_files(): - - paths = af.DirectoryPaths( - path_prefix=str(Path("non_linear") / "parallel"), - ) - - process = MockSneakyProcess(paths=paths) - - # TODO : I dont know how to make it so run doesn't end up in an infinite loop? - +import autofit as af +from autofit.non_linear.parallel.sneaky import SneakyProcess + +from pathlib import Path + +class MockSneakyProcess(SneakyProcess): + def __init__( + self, + paths, + ): + + super().__init__( + name="test", + paths=paths + ) + +def test__test_mode_parallel_profile_outputs_prof_files(): + + paths = af.DirectoryPaths( + path_prefix=str(Path("non_linear") / "parallel"), + ) + + process = MockSneakyProcess(paths=paths) + + # TODO : I dont know how to make it so run doesn't end up in an infinite loop? + # process.run() \ No newline at end of file diff --git a/test_autofit/non_linear/test_persistance.py b/test_autofit/non_linear/test_persistance.py index 7e2a784b0..927a25cfb 100644 --- a/test_autofit/non_linear/test_persistance.py +++ b/test_autofit/non_linear/test_persistance.py @@ -1,12 +1,12 @@ -import pickle - -import autofit as af -from autofit.non_linear.paths import DirectoryPaths - - -class TestCase: - def test_simple_pickle(self): - optimiser = af.DynestyStatic() - optimiser.paths = DirectoryPaths("name") - pickled_optimiser = pickle.loads(pickle.dumps(optimiser)) - assert optimiser.paths.path_prefix == pickled_optimiser.paths.path_prefix +import pickle + +import autofit as af +from autofit.non_linear.paths import DirectoryPaths + + +class TestCase: + def test_simple_pickle(self): + optimiser = af.DynestyStatic() + optimiser.paths = DirectoryPaths("name") + pickled_optimiser = pickle.loads(pickle.dumps(optimiser)) + assert optimiser.paths.path_prefix == pickled_optimiser.paths.path_prefix diff --git a/test_autofit/test_equality.py b/test_autofit/test_equality.py index c0c8d4a95..666400ffc 100644 --- a/test_autofit/test_equality.py +++ b/test_autofit/test_equality.py @@ -1,70 +1,70 @@ -from copy import deepcopy - -import pytest - -import autofit as af - - -@pytest.fixture(name="prior_model") -def make_prior_model(): - return af.Model(af.m.MockClassx2Tuple) - - -class TestCase: - def test_prior_model(self, prior_model): - prior_model_copy = deepcopy(prior_model) - assert prior_model == prior_model_copy - - prior_model_copy.centre_0 = af.UniformPrior() - - assert prior_model != prior_model_copy - - def test_list_prior_model(self, prior_model): - list_prior_model = af.Collection([prior_model]) - list_prior_model_copy = deepcopy(list_prior_model) - assert list_prior_model == list_prior_model_copy - - list_prior_model[0].centre_0 = af.UniformPrior() - - assert list_prior_model != list_prior_model_copy - - def test_model_mapper(self, prior_model): - model_mapper = af.ModelMapper() - model_mapper.prior_model = prior_model - model_mapper_copy = deepcopy(model_mapper) - - assert model_mapper == model_mapper_copy - - model_mapper.prior_model.centre_0 = af.UniformPrior() - - assert model_mapper != model_mapper_copy - - def test_non_trivial_equality(self): - mock_components = af.Model( - af.m.MockComponents, - components_0=af.Collection(mock_cls_0=af.m.MockChildTuplex2), - components_1=af.Collection( - mock_cls_2=af.m.MockChildTuplex3 - ), - ) - - model_mapper = af.ModelMapper() - model_mapper.mock_components = mock_components - model_mapper_copy = deepcopy(model_mapper) - - assert model_mapper == model_mapper_copy - - model_mapper.mock_components.components_0.tup_0 = af.UniformPrior() - - assert model_mapper != model_mapper_copy - - def test_model_instance_equality(self): - model_instance = af.ModelInstance() - model_instance.profile = af.m.MockClassx2Tuple() - model_instance_copy = deepcopy(model_instance) - - assert model_instance == model_instance_copy - - model_instance.profile.centre = (1.0, 2.0) - - assert model_instance != model_instance_copy +from copy import deepcopy + +import pytest + +import autofit as af + + +@pytest.fixture(name="prior_model") +def make_prior_model(): + return af.Model(af.m.MockClassx2Tuple) + + +class TestCase: + def test_prior_model(self, prior_model): + prior_model_copy = deepcopy(prior_model) + assert prior_model == prior_model_copy + + prior_model_copy.centre_0 = af.UniformPrior() + + assert prior_model != prior_model_copy + + def test_list_prior_model(self, prior_model): + list_prior_model = af.Collection([prior_model]) + list_prior_model_copy = deepcopy(list_prior_model) + assert list_prior_model == list_prior_model_copy + + list_prior_model[0].centre_0 = af.UniformPrior() + + assert list_prior_model != list_prior_model_copy + + def test_model_mapper(self, prior_model): + model_mapper = af.ModelMapper() + model_mapper.prior_model = prior_model + model_mapper_copy = deepcopy(model_mapper) + + assert model_mapper == model_mapper_copy + + model_mapper.prior_model.centre_0 = af.UniformPrior() + + assert model_mapper != model_mapper_copy + + def test_non_trivial_equality(self): + mock_components = af.Model( + af.m.MockComponents, + components_0=af.Collection(mock_cls_0=af.m.MockChildTuplex2), + components_1=af.Collection( + mock_cls_2=af.m.MockChildTuplex3 + ), + ) + + model_mapper = af.ModelMapper() + model_mapper.mock_components = mock_components + model_mapper_copy = deepcopy(model_mapper) + + assert model_mapper == model_mapper_copy + + model_mapper.mock_components.components_0.tup_0 = af.UniformPrior() + + assert model_mapper != model_mapper_copy + + def test_model_instance_equality(self): + model_instance = af.ModelInstance() + model_instance.profile = af.m.MockClassx2Tuple() + model_instance_copy = deepcopy(model_instance) + + assert model_instance == model_instance_copy + + model_instance.profile.centre = (1.0, 2.0) + + assert model_instance != model_instance_copy diff --git a/test_autofit/text/test_formatter.py b/test_autofit/text/test_formatter.py index f913384ff..4779c088b 100644 --- a/test_autofit/text/test_formatter.py +++ b/test_autofit/text/test_formatter.py @@ -1,81 +1,81 @@ -import os -import shutil -from pathlib import Path - -from autofit.text import formatter as frm - -text_path = Path(__file__).resolve().parent / "files" / "text" - - -def test__value_result_string(): - str0 = frm.value_result_string_from(parameter_name="param0", value=2.0) - assert str0 == "2.00" - - str0 = frm.value_result_string_from(parameter_name="param11", value=3.00) - - assert str0 == "3.0000" - - str0 = frm.value_result_string_from( - parameter_name="param0", value=2.0, values_at_sigma=(1.5, 2.5) - ) - assert str0 == "2.00 (1.50, 2.50)" - - str0 = frm.value_result_string_from( - parameter_name="param0", value=2.0, unit="arcsec" - ) - - assert str0 == "2.00 arcsec" - - -def test__parameter_result_latex(): - str0 = frm.parameter_result_latex_from(parameter_name="param0", value=2.0) - assert str0 == r"param0 = 2.00 & " - - str0 = frm.parameter_result_latex_from( - parameter_name="param0", value=2.0, errors=(0.1, 0.2) - ) - assert str0 == r"param0 = 2.00^{+0.20}_{-0.10} & " - - str0 = frm.parameter_result_latex_from( - parameter_name="param0", value=3.00, superscript="a", errors=(0.1, 0.2) - ) - - assert str0 == r"param0^{\rm{a}} = 3.00^{+0.20}_{-0.10} & " - - str0 = frm.parameter_result_latex_from( - parameter_name="param0", value=3.00, superscript="a", name_to_label=True - ) - - assert str0 == r"p0^{\rm{a}} = 3.00 & " - - str0 = frm.parameter_result_latex_from( - parameter_name="param0", value=3.00, superscript="a", unit="kg" - ) - - assert str0 == r"param0^{\rm{a}} = 3.00 kg & " - - -def test__output_list_of_strings_to_file(): - if text_path.exists(): - shutil.rmtree(text_path) - - os.mkdir(text_path) - - results = ["hi\n", "hello"] - frm.output_list_of_strings_to_file( - file=text_path / "model.results", list_of_strings=results - ) - - file = open(text_path / "model.results", "r") - - assert file.readlines() == ["hi\n", "hello"] - - -def test_string(): - assert frm.format_string_for_parameter_name("radius") == "{:.2f}" - assert frm.format_string_for_parameter_name("mass") == "{:.2f}" - - -def test_substring(): - assert frm.format_string_for_parameter_name("einstein_radius") == "{:.2f}" - assert frm.format_string_for_parameter_name("mass_value_something") == "{:.2f}" +import os +import shutil +from pathlib import Path + +from autofit.text import formatter as frm + +text_path = Path(__file__).resolve().parent / "files" / "text" + + +def test__value_result_string(): + str0 = frm.value_result_string_from(parameter_name="param0", value=2.0) + assert str0 == "2.00" + + str0 = frm.value_result_string_from(parameter_name="param11", value=3.00) + + assert str0 == "3.0000" + + str0 = frm.value_result_string_from( + parameter_name="param0", value=2.0, values_at_sigma=(1.5, 2.5) + ) + assert str0 == "2.00 (1.50, 2.50)" + + str0 = frm.value_result_string_from( + parameter_name="param0", value=2.0, unit="arcsec" + ) + + assert str0 == "2.00 arcsec" + + +def test__parameter_result_latex(): + str0 = frm.parameter_result_latex_from(parameter_name="param0", value=2.0) + assert str0 == r"param0 = 2.00 & " + + str0 = frm.parameter_result_latex_from( + parameter_name="param0", value=2.0, errors=(0.1, 0.2) + ) + assert str0 == r"param0 = 2.00^{+0.20}_{-0.10} & " + + str0 = frm.parameter_result_latex_from( + parameter_name="param0", value=3.00, superscript="a", errors=(0.1, 0.2) + ) + + assert str0 == r"param0^{\rm{a}} = 3.00^{+0.20}_{-0.10} & " + + str0 = frm.parameter_result_latex_from( + parameter_name="param0", value=3.00, superscript="a", name_to_label=True + ) + + assert str0 == r"p0^{\rm{a}} = 3.00 & " + + str0 = frm.parameter_result_latex_from( + parameter_name="param0", value=3.00, superscript="a", unit="kg" + ) + + assert str0 == r"param0^{\rm{a}} = 3.00 kg & " + + +def test__output_list_of_strings_to_file(): + if text_path.exists(): + shutil.rmtree(text_path) + + os.mkdir(text_path) + + results = ["hi\n", "hello"] + frm.output_list_of_strings_to_file( + file=text_path / "model.results", list_of_strings=results + ) + + file = open(text_path / "model.results", "r") + + assert file.readlines() == ["hi\n", "hello"] + + +def test_string(): + assert frm.format_string_for_parameter_name("radius") == "{:.2f}" + assert frm.format_string_for_parameter_name("mass") == "{:.2f}" + + +def test_substring(): + assert frm.format_string_for_parameter_name("einstein_radius") == "{:.2f}" + assert frm.format_string_for_parameter_name("mass_value_something") == "{:.2f}" diff --git a/test_autofit/text/test_samples_text.py b/test_autofit/text/test_samples_text.py index 12eb148cb..e5e0421b2 100644 --- a/test_autofit/text/test_samples_text.py +++ b/test_autofit/text/test_samples_text.py @@ -1,75 +1,75 @@ -from pathlib import Path - -import pytest - -import autofit as af - -from autofit.non_linear.samples import Sample -from autofit import SamplesStored -from autofit.text import samples_text - -text_path = Path(__file__).resolve().parent / "files" / "samples" - - -@pytest.fixture(name="model") -def make_model(): - return af.ModelMapper(mock_class=af.m.MockClassx2) - - -@pytest.fixture(name="samples") -def make_samples(model): - parameters = [[1.0, 2.0], [1.2, 2.2]] - - log_likelihood_list = [1.0, 0.0] - - return SamplesStored( - model=model, - sample_list=Sample.from_lists( - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=[0.0, 0.0], - weight_list=log_likelihood_list, - model=model - ), - ) - - -def test__summary(samples): - results_at_sigma = samples_text.summary(samples=samples, sigma=3.0) - - assert "one 1.00 (1.00, 1.20)" in results_at_sigma - assert "two 2.00 (2.00, 2.20)" in results_at_sigma - - -def test__latex(samples): - latex_results_at_sigma = samples_text.latex(samples=samples, sigma=3.0) - - assert r"one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00} & " in latex_results_at_sigma - assert r"two_label^{\rm{o}} = 2.00^{+0.20}_{-0.00}" in latex_results_at_sigma - - latex_results_at_sigma = samples_text.latex(samples=samples, sigma=3.0, include_quickmath=True) - - assert r"$one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00}$ & " in latex_results_at_sigma - assert r"$two_label^{\rm{o}} = 2.00^{+0.20}_{-0.00}$" in latex_results_at_sigma - - model = af.ModelMapper(mock_class=af.m.MockClassx2FormatExp) - - parameters = [[1.0, 200.0], [1.2, 200.0]] - - log_likelihood_list = [1.0, 0.0] - - samples_exp = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=[0.0, 0.0], - weight_list=log_likelihood_list, - model=model - ), - ) - - latex_results_at_sigma = samples_text.latex(samples=samples_exp, sigma=3.0, include_quickmath=True) - - assert r"$one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00}$ & " in latex_results_at_sigma +from pathlib import Path + +import pytest + +import autofit as af + +from autofit.non_linear.samples import Sample +from autofit import SamplesStored +from autofit.text import samples_text + +text_path = Path(__file__).resolve().parent / "files" / "samples" + + +@pytest.fixture(name="model") +def make_model(): + return af.ModelMapper(mock_class=af.m.MockClassx2) + + +@pytest.fixture(name="samples") +def make_samples(model): + parameters = [[1.0, 2.0], [1.2, 2.2]] + + log_likelihood_list = [1.0, 0.0] + + return SamplesStored( + model=model, + sample_list=Sample.from_lists( + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=[0.0, 0.0], + weight_list=log_likelihood_list, + model=model + ), + ) + + +def test__summary(samples): + results_at_sigma = samples_text.summary(samples=samples, sigma=3.0) + + assert "one 1.00 (1.00, 1.20)" in results_at_sigma + assert "two 2.00 (2.00, 2.20)" in results_at_sigma + + +def test__latex(samples): + latex_results_at_sigma = samples_text.latex(samples=samples, sigma=3.0) + + assert r"one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00} & " in latex_results_at_sigma + assert r"two_label^{\rm{o}} = 2.00^{+0.20}_{-0.00}" in latex_results_at_sigma + + latex_results_at_sigma = samples_text.latex(samples=samples, sigma=3.0, include_quickmath=True) + + assert r"$one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00}$ & " in latex_results_at_sigma + assert r"$two_label^{\rm{o}} = 2.00^{+0.20}_{-0.00}$" in latex_results_at_sigma + + model = af.ModelMapper(mock_class=af.m.MockClassx2FormatExp) + + parameters = [[1.0, 200.0], [1.2, 200.0]] + + log_likelihood_list = [1.0, 0.0] + + samples_exp = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=[0.0, 0.0], + weight_list=log_likelihood_list, + model=model + ), + ) + + latex_results_at_sigma = samples_text.latex(samples=samples_exp, sigma=3.0, include_quickmath=True) + + assert r"$one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00}$ & " in latex_results_at_sigma assert r"$one_label^{\rm{o}} = 1.00^{+0.20}_{-0.00}$ & $2.00^{+0.00}_{-0.00} \times 10^{2}$" in latex_results_at_sigma \ No newline at end of file diff --git a/test_autofit/text/test_text_util.py b/test_autofit/text/test_text_util.py index 3999152da..a659174be 100644 --- a/test_autofit/text/test_text_util.py +++ b/test_autofit/text/test_text_util.py @@ -1,103 +1,103 @@ -from pathlib import Path -import pytest - -import autofit as af - -from autofit.text import text_util - -text_path = Path(__file__).resolve().parent / "files" / "samples" - - -@pytest.fixture(name="model") -def make_model(): - return af.ModelMapper(mock_class=af.m.MockClassx2) - - -@pytest.fixture(name="samples") -def make_samples(model): - parameters = [[1.0, 2.0], [1.2, 2.2]] - - log_likelihood_list = [1.0, 0.0] - - return af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=[0.0, 0.0], - weight_list=log_likelihood_list, - model=model - ) - ) - - -def test__results_to_file(samples): - - result_info =text_util.result_info_from( - samples=samples, - ) - - assert "Maximum Log Likelihood 1.00000000\n" in result_info - -def test__search_summary_to_file(model): - file_search_summary = text_path / "search.summary" - - parameters = [[1.0, 2.0], [1.2, 2.2]] - - log_likelihood_list = [1.0, 0.0] - - samples = af.m.MockSamples( - model=model, - sample_list=af.Sample.from_lists( - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list, - log_prior_list=[0.0, 0.0], - weight_list=log_likelihood_list, - model=model - ), - samples_info={ - "time": "1", - "total_accepted_samples" : 2 - } - ) - - text_util.search_summary_to_file( - samples=samples, - log_likelihood_function_time=1.0, - filename=file_search_summary - ) - - results = open(file_search_summary) - lines = results.readlines() - assert lines[0] == "Total Samples = 2\n" - results.close() - - samples = af.m.MockSamplesNest( - model=model, - sample_list=af.Sample.from_lists( - parameter_lists=parameters, - log_likelihood_list=log_likelihood_list + [2.0], - log_prior_list=[1.0, 1.0], - weight_list=log_likelihood_list, - model=model - ), - samples_info={ - "total_samples": 10, - "total_accepted_samples": 2, - "time": "1", - "number_live_points": 1, - "log_evidence": 1.0 - } - ) - - text_util.search_summary_to_file(samples=samples, log_likelihood_function_time=1.0, filename=file_search_summary) - - results = open(file_search_summary) - lines = results.readlines() - assert lines[0] == "Total Samples = 10\n" - assert lines[1] == "Total Accepted Samples = 2\n" - assert lines[2] == "Acceptance Ratio = 0.2\n" - assert lines[3] == "Time To Run = 0:00:01\n" - assert lines[4] == "Time Per Sample (seconds) = 0.1\n" - assert lines[5] == "Log Likelihood Function Evaluation Time (seconds) = 1.0\n" - results.close() +from pathlib import Path +import pytest + +import autofit as af + +from autofit.text import text_util + +text_path = Path(__file__).resolve().parent / "files" / "samples" + + +@pytest.fixture(name="model") +def make_model(): + return af.ModelMapper(mock_class=af.m.MockClassx2) + + +@pytest.fixture(name="samples") +def make_samples(model): + parameters = [[1.0, 2.0], [1.2, 2.2]] + + log_likelihood_list = [1.0, 0.0] + + return af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=[0.0, 0.0], + weight_list=log_likelihood_list, + model=model + ) + ) + + +def test__results_to_file(samples): + + result_info =text_util.result_info_from( + samples=samples, + ) + + assert "Maximum Log Likelihood 1.00000000\n" in result_info + +def test__search_summary_to_file(model): + file_search_summary = text_path / "search.summary" + + parameters = [[1.0, 2.0], [1.2, 2.2]] + + log_likelihood_list = [1.0, 0.0] + + samples = af.m.MockSamples( + model=model, + sample_list=af.Sample.from_lists( + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list, + log_prior_list=[0.0, 0.0], + weight_list=log_likelihood_list, + model=model + ), + samples_info={ + "time": "1", + "total_accepted_samples" : 2 + } + ) + + text_util.search_summary_to_file( + samples=samples, + log_likelihood_function_time=1.0, + filename=file_search_summary + ) + + results = open(file_search_summary) + lines = results.readlines() + assert lines[0] == "Total Samples = 2\n" + results.close() + + samples = af.m.MockSamplesNest( + model=model, + sample_list=af.Sample.from_lists( + parameter_lists=parameters, + log_likelihood_list=log_likelihood_list + [2.0], + log_prior_list=[1.0, 1.0], + weight_list=log_likelihood_list, + model=model + ), + samples_info={ + "total_samples": 10, + "total_accepted_samples": 2, + "time": "1", + "number_live_points": 1, + "log_evidence": 1.0 + } + ) + + text_util.search_summary_to_file(samples=samples, log_likelihood_function_time=1.0, filename=file_search_summary) + + results = open(file_search_summary) + lines = results.readlines() + assert lines[0] == "Total Samples = 10\n" + assert lines[1] == "Total Accepted Samples = 2\n" + assert lines[2] == "Acceptance Ratio = 0.2\n" + assert lines[3] == "Time To Run = 0:00:01\n" + assert lines[4] == "Time Per Sample (seconds) = 0.1\n" + assert lines[5] == "Log Likelihood Function Evaluation Time (seconds) = 1.0\n" + results.close() diff --git a/test_autofit/tools/test_path_util.py b/test_autofit/tools/test_path_util.py index f97a7365d..3b6eab35a 100644 --- a/test_autofit/tools/test_path_util.py +++ b/test_autofit/tools/test_path_util.py @@ -1,23 +1,23 @@ -import os -from pathlib import Path -import numpy as np -import autofit as af - -test_path = Path(__file__).resolve().parent / "files" / "path" - - -class TestJson: - def test__numpy_array_to_json__output_and_load(self): - - if (test_path / "array_out.json").exists(): - os.remove(test_path / "array_out.json") - - arr = np.array([10.0, 30.0, 40.0, 92.0, 19.0, 20.0]) - - af.util.numpy_array_to_json(arr, file_path=test_path / "array_out.json") - - array_load = af.util.numpy_array_from_json( - file_path=test_path / "array_out.json" - ) - - assert (arr == array_load).all() +import os +from pathlib import Path +import numpy as np +import autofit as af + +test_path = Path(__file__).resolve().parent / "files" / "path" + + +class TestJson: + def test__numpy_array_to_json__output_and_load(self): + + if (test_path / "array_out.json").exists(): + os.remove(test_path / "array_out.json") + + arr = np.array([10.0, 30.0, 40.0, 92.0, 19.0, 20.0]) + + af.util.numpy_array_to_json(arr, file_path=test_path / "array_out.json") + + array_load = af.util.numpy_array_from_json( + file_path=test_path / "array_out.json" + ) + + assert (arr == array_load).all() diff --git a/test_autofit/tools/test_paths.py b/test_autofit/tools/test_paths.py index 7cbdf7f0f..ff8d8d091 100644 --- a/test_autofit/tools/test_paths.py +++ b/test_autofit/tools/test_paths.py @@ -1,37 +1,37 @@ -import os -import shutil -from pathlib import Path - -import pytest - -import autofit as af - -directory = Path(__file__).parent - - -class PatchPaths(af.DirectoryPaths): - @property - def sym_path(self) -> Path: - return directory / "sym_path" - - @property - def output_path(self) -> Path: - return directory / "phase_output_path" - - -@pytest.fixture(name="paths") -def make_paths(): - return PatchPaths() - - -def test_restore(paths): - paths.model = af.Model(af.ex.Gaussian) - paths.save_all({}, {}) - - paths.zip_remove() - paths.restore() - - assert paths.output_path.exists() - assert not Path(paths._zip_path).exists() - - shutil.rmtree(paths.output_path) +import os +import shutil +from pathlib import Path + +import pytest + +import autofit as af + +directory = Path(__file__).parent + + +class PatchPaths(af.DirectoryPaths): + @property + def sym_path(self) -> Path: + return directory / "sym_path" + + @property + def output_path(self) -> Path: + return directory / "phase_output_path" + + +@pytest.fixture(name="paths") +def make_paths(): + return PatchPaths() + + +def test_restore(paths): + paths.model = af.Model(af.ex.Gaussian) + paths.save_all({}, {}) + + paths.zip_remove() + paths.restore() + + assert paths.output_path.exists() + assert not Path(paths._zip_path).exists() + + shutil.rmtree(paths.output_path)