From 785264fb7158c86374369c3a1d6f17abafa77805 Mon Sep 17 00:00:00 2001 From: Zayne Date: Sun, 28 Jun 2026 20:20:13 -0400 Subject: [PATCH 1/4] Redesign into a fit/plot API and relicense to GPL (>= 2) Split the monolithic plotness() into a compute/draw architecture while keeping the public function fully backward compatible (same signature; plot=TRUE -> a ggplot, plot=FALSE -> the identical legacy 8-column data frame). - New exported plotness_fit() returns a `plotness_fit` S3 object carrying the plotting data and the fitted estimates, with print(), coef(), as.data.frame(), and a ggplot2::autoplot() method (the generic is re-exported). Estimates are now reachable via fit$estimates / coef() instead of only as on-plot text. - plotness() becomes a thin facade over plotness_fit() + autoplot(). - Input is validated up front via check_plotness_input() with class-tagged cli errors (negative/non-integer counts, count > size for binomial, conf_level outside (0, 1), missing values, and degenerate fits); lambda is now ignored with a warning for non-Poisson types. - autoplot() uses a colourblind-safe Okabe-Ito palette mapped by a named vector (removing the alphabetical-ordering fragility), grey confidence bars, and the fit summary as a subtitle so it can no longer overlap the data. - Relicense to GPL (>= 2): the per-type metameter statistics are adapted verbatim from vcd::distplot() (GPL-2), so MIT was incompatible. Credit the upstream vcd authors (Meyer, Zeileis, Hornik, Friendly) in Authors@R. - Declare cli and rlang in Imports. Co-Authored-By: Claude Opus 4.8 --- DESCRIPTION | 32 +++- LICENSE | 2 - LICENSE.md | 357 ++++++++++++++++++++++++++++++++--- NAMESPACE | 22 ++- R/fit.R | 312 ++++++++++++++++++++++++++++++ R/plot.R | 132 +++++++++++++ R/plotness-package.R | 13 +- R/plotness.R | 198 ++++--------------- man/autoplot.plotness_fit.Rd | 55 ++++++ man/check_plotness_input.Rd | 33 ++++ man/plotness-package.Rd | 31 +++ man/plotness.Rd | 30 +-- man/plotness_fit.Rd | 86 +++++++++ man/reexports.Rd | 16 ++ 14 files changed, 1111 insertions(+), 208 deletions(-) delete mode 100644 LICENSE create mode 100644 R/fit.R create mode 100644 R/plot.R create mode 100644 man/autoplot.plotness_fit.Rd create mode 100644 man/check_plotness_input.Rd create mode 100644 man/plotness-package.Rd create mode 100644 man/plotness_fit.Rd create mode 100644 man/reexports.Rd diff --git a/DESCRIPTION b/DESCRIPTION index f83f205..c7bfd32 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -2,21 +2,32 @@ Package: plotness Type: Package Title: Poissonness, Binomialness, and Negative Binomialness Plots Version: 0.1.0.9000 -Authors@R: +Authors@R: c( person("Zayne", "Sember", email = "zaynesember@gmail.com", - role = c("aut", "cre")) -Description: Creates a diagnostic distribution plot or data frame. The function - is adapted from the 'vcd' package's distplot() function to produce ggplots - and to allow the option to return only the data needed for the plot so the - user's preferred graphics package can be used. + role = c("aut", "cre")), + person("David", "Meyer", role = c("ctb", "cph"), + comment = "author of vcd::distplot(), from which the per-type statistics are adapted"), + person("Achim", "Zeileis", role = c("ctb", "cph"), + comment = "author of vcd::distplot()"), + person("Kurt", "Hornik", role = c("ctb", "cph"), + comment = "author of vcd::distplot()"), + person("Michael", "Friendly", role = c("ctb", "cph"), + comment = "author of vcd::distplot()")) +Description: Creates a diagnostic distribution plot or data frame. The per-type + metameter statistics are adapted from the 'vcd' package's distplot() + function (GPL-2); this package produces 'ggplot2' graphics and can return + only the data needed for the plot so the user's preferred graphics package + can be used. Depends: R (>= 4.0) Imports: + cli, ggplot2, + rlang, stats, vcd -License: MIT + file LICENSE -URL: https://github.com/zaynesember/plotness +License: GPL (>= 2) +URL: https://github.com/zaynesember/plotness, https://zaynesember.github.io/plotness/ BugReports: https://github.com/zaynesember/plotness/issues Encoding: UTF-8 Roxygen: list(markdown = TRUE) @@ -24,6 +35,9 @@ RoxygenNote: 7.3.3 Suggests: knitr, rmarkdown, - testthat (>= 3.0.0) + spelling, + testthat (>= 3.0.0), + vdiffr Config/testthat/edition: 3 VignetteBuilder: knitr +Language: en-US diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f2dab1f..0000000 --- a/LICENSE +++ /dev/null @@ -1,2 +0,0 @@ -YEAR: 2021 -COPYRIGHT HOLDER: Zayne Sember diff --git a/LICENSE.md b/LICENSE.md index 10aeb04..0daa041 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,336 @@ -MIT License - -Copyright (c) 2021 Zayne Sember - -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. +GNU General Public License +========================== + +_Version 2, June 1991_ +_Copyright © 1989, 1991 Free Software Foundation, Inc.,_ +_51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +### Preamble + +The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + +To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + +We protect your rights with two steps: **(1)** copyright the software, and +**(2)** offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + +Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + +Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +**0.** This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The “Program”, below, +refers to any such program or work, and a “work based on the Program” +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term “modification”.) Each licensee is addressed as “you”. + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + +**1.** You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + +**2.** You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + +* **a)** You must cause the modified files to carry prominent notices +stating that you changed the files and the date of any change. +* **b)** You must cause any work that you distribute or publish, that in +whole or in part contains or is derived from the Program or any +part thereof, to be licensed as a whole at no charge to all third +parties under the terms of this License. +* **c)** If the modified program normally reads commands interactively +when run, you must cause it, when started running for such +interactive use in the most ordinary way, to print or display an +announcement including an appropriate copyright notice and a +notice that there is no warranty (or else, saying that you provide +a warranty) and that users may redistribute the program under +these conditions, and telling the user how to view a copy of this +License. (Exception: if the Program itself is interactive but +does not normally print such an announcement, your work based on +the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + +**3.** You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + +* **a)** Accompany it with the complete corresponding machine-readable +source code, which must be distributed under the terms of Sections +1 and 2 above on a medium customarily used for software interchange; or, +* **b)** Accompany it with a written offer, valid for at least three +years, to give any third party, for a charge no more than your +cost of physically performing source distribution, a complete +machine-readable copy of the corresponding source code, to be +distributed under the terms of Sections 1 and 2 above on a medium +customarily used for software interchange; or, +* **c)** Accompany it with the information you received as to the offer +to distribute corresponding source code. (This alternative is +allowed only for noncommercial distribution and only if you +received the program in object code or executable form with such +an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + +**4.** You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + +**5.** You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + +**6.** Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + +**7.** If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + +**8.** If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + +**9.** The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and “any +later version”, you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + +**10.** If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + +### NO WARRANTY + +**11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + +**12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +### How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the “copyright” line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w` and `show c` should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w` and `show c`; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a “copyright disclaimer” for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/NAMESPACE b/NAMESPACE index 3467f34..4f1ded9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,26 @@ # Generated by roxygen2: do not edit by hand +S3method(as.data.frame,plotness_fit) +S3method(coef,plotness_fit) +S3method(ggplot2::autoplot,plotness_fit) +S3method(print,plotness_fit) +export(autoplot) export(plotness) -import(ggplot2) +export(plotness_fit) import(stats) +importFrom(ggplot2,aes) +importFrom(ggplot2,autoplot) +importFrom(ggplot2,coord_cartesian) +importFrom(ggplot2,geom_errorbar) +importFrom(ggplot2,geom_line) +importFrom(ggplot2,geom_point) +importFrom(ggplot2,ggplot) +importFrom(ggplot2,labs) +importFrom(ggplot2,scale_colour_manual) +importFrom(ggplot2,theme) +importFrom(ggplot2,theme_bw) +importFrom(rlang,"%||%") +importFrom(rlang,.data) +importFrom(rlang,arg_match) +importFrom(rlang,caller_env) importFrom(vcd,goodfit) diff --git a/R/fit.R b/R/fit.R new file mode 100644 index 0000000..077780f --- /dev/null +++ b/R/fit.R @@ -0,0 +1,312 @@ +#' Fit a distribution diagnostic ("-ness") plot +#' +#' @description +#' `plotness_fit()` runs the statistics behind a Poissonness, binomialness, or +#' negative binomialness plot and returns a `plotness_fit` object. The object +#' carries the per-count plotting data, the fitted line, and the parameter +#' estimates, and has [autoplot()][autoplot.plotness_fit], `print()`, `coef()`, +#' and `as.data.frame()` methods. +#' +#' Most users can call [plotness()] directly; use `plotness_fit()` when you want +#' the fitted estimates programmatically (via `fit$estimates` or `coef(fit)`) +#' rather than only as text on the plot. +#' +#' @inheritParams plotness +#' @return An object of class `plotness_fit`: a list with elements +#' \describe{ +#' \item{`type`}{the distribution, one of `"poisson"`, `"binomial"`, +#' `"nbinomial"`.} +#' \item{`data`}{the plotting data frame (one row per distinct count).} +#' \item{`tidy`}{a snake_case copy of `data`.} +#' \item{`estimates`}{a named list of fitted estimates (see below).} +#' \item{`model`}{the underlying [stats::lm()] fit.} +#' \item{`size`, `conf_level`}{the resolved size and confidence level.} +#' \item{`n_distinct`, `degenerate`}{number of distinct observed counts and +#' whether the fit is degenerate (exactly two distinct counts).} +#' } +#' `estimates` contains `par_name`, `par_estim` (the regression-implied +#' estimate), `par_ml` (the [vcd::goodfit()] maximum-likelihood estimate), +#' `slope`, `intercept`, `size`, `lambda`, and `conf_level`. +#' @seealso [plotness()], [autoplot.plotness_fit()] +#' @examples +#' fit <- plotness_fit(rpois(150, 8), type = "poisson") +#' fit +#' coef(fit) +#' fit$estimates$par_estim +#' @importFrom vcd goodfit +#' @importFrom rlang arg_match +#' @import stats +#' @export +plotness_fit <- function(x, type = c("poisson", "binomial", "nbinomial"), + size = NULL, lambda = NULL, conf_level = 0.95) { + type <- arg_match(type) + cl <- match.call() + + # ---- reshape input to (count, freq) -------------------------------------- + # Flag missing values rather than silently dropping them in table(); report + # the true count of missing observations. + if (is.vector(x)) { + if (anyNA(x)) { + cli::cli_abort( + c( + "{.arg x} contains {sum(is.na(x))} missing value{?s}.", + "i" = "Remove or impute them before plotting." + ), + class = "plotness_error_na" + ) + } + x <- table(x) + } + if (is.table(x)) { + if (length(dim(x)) > 1) { + cli::cli_abort("{.arg x} must be a 1-way table.") + } + freq <- as.vector(x) + count <- as.numeric(names(x)) + } else { + if (!(!is.null(ncol(x)) && ncol(x) == 2)) { + cli::cli_abort( + "{.arg x} must be a 2-column matrix or data frame of (freq, count)." + ) + } + freq <- as.vector(x[, 1]) + count <- as.vector(x[, 2]) + } + + # ---- validation (shared by the plot and data paths) ---------------------- + check_plotness_input(count, freq, type, size, lambda, conf_level) + + # ---- binomial size fallback (counts already validated; max() is safe) ---- + if (type == "binomial" && is.null(size)) { + size <- max(count) + cli::cli_warn("size was not given, taken as maximum count") + } + + # lambda only levels the Poissonness plot; ignore it elsewhere (already warned) + if (type != "poisson") lambda <- NULL + + n_distinct <- length(unique(count[freq > 0])) + degenerate <- n_distinct == 2 + + myindex <- which(freq > 0) + mycount <- count[myindex] + myfreq <- freq[myindex] + + # ---- per-type metameter + fit (arithmetic preserved from vcd::distplot) --- + switch(type, + poisson = { + par_ml <- suppressWarnings(goodfit(x, type = type)$par$lambda) + phi <- function(nk, k, n_total, size = NULL) { + ifelse(nk > 0, lgamma(k + 1) + log(nk / n_total), NA) + } + y <- phi(myfreq, mycount, sum(freq)) + if (!is.null(lambda)) y <- y + lambda - mycount * log(lambda) + fm <- lm(y ~ mycount) + par_estim <- exp(coef(fm)[2]) + if (!is.null(lambda)) par_estim <- par_estim * lambda + par_estim <- unname(par_estim) + par_name <- "lambda" + }, + binomial = { + par_ml <- suppressWarnings( + goodfit(x, type = type, par = list(size = size))$par$prob + ) + phi <- function(nk, k, n_total, size = NULL) { + log(nk) - log(n_total * choose(size, k)) + } + y <- phi(myfreq, mycount, sum(freq), size = size) + fm <- lm(y ~ mycount) + b <- exp(coef(fm)[2]) + par_estim <- unname(b / (1 + b)) + par_name <- "prob" + }, + nbinomial = { + if (is.null(size)) { + gf <- suppressWarnings(goodfit(x, type = type)$par) + size <- gf$size + par_ml <- gf$prob + } else { + xbar <- weighted.mean(mycount, myfreq) + par_ml <- size / (size + xbar) + } + phi <- function(nk, k, n_total, size = NULL) { + log(nk) - log(n_total * choose(size + k - 1, k)) + } + y <- phi(myfreq, mycount, sum(freq), size = size) + fm <- lm(y ~ mycount) + par_estim <- unname(1 - exp(coef(fm)[2])) + par_name <- "prob" + } + ) + + yhat <- ifelse(myfreq > 1.5, myfreq - 0.67, 1 / exp(1)) + yhat <- phi(yhat, mycount, sum(freq), size = size) + if (!is.null(lambda)) yhat <- yhat + lambda - mycount * log(lambda) + + phat <- myfreq / sum(myfreq) + ci_width <- qnorm(1 - (1 - conf_level) / 2) * + sqrt(1 - phat) / sqrt(myfreq - (0.25 * phat + 0.47) * sqrt(myfreq)) + + plot_data <- cbind(count, freq, NA, NA, NA, NA, NA) + plot_data[myindex, 3:7] <- cbind(y, yhat, ci_width, yhat - ci_width, yhat + ci_width) + plot_data <- as.data.frame(plot_data) + names(plot_data) <- c( + "Counts", "Freq", "Metameter", "CI.center", + "CI.width", "CI.lower", "CI.upper" + ) + # Fitted "perfect distribution" line evaluated at every count so the column + # aligns with plot_data even when some counts have zero frequency. + plot_data$y_line <- predict(fm, newdata = data.frame(mycount = plot_data$Counts)) + + if (anyNA(ci_width)) { + cli::cli_warn(c( + "Some confidence intervals are undefined and were dropped.", + "i" = "This can happen for very small frequencies." + )) + } + + tidy <- plot_data + names(tidy) <- c( + "count", "freq", "metameter", "ci_center", + "ci_width", "ci_lower", "ci_upper", "y_line" + ) + + estimates <- list( + type = type, + par_name = par_name, + par_estim = par_estim, + par_ml = unname(par_ml), + slope = unname(coef(fm)[2]), + intercept = unname(coef(fm)[1]), + size = size, + lambda = lambda, + conf_level = conf_level + ) + + structure( + list( + type = type, + call = cl, + data = plot_data, + tidy = tidy, + estimates = estimates, + model = fm, + size = size, + conf_level = conf_level, + n_distinct = n_distinct, + degenerate = degenerate + ), + class = "plotness_fit" + ) +} + +#' Validate `plotness()` / `plotness_fit()` input +#' +#' Internal helper; runs every guard once so the plot and data paths share +#' identical validation. All conditions are class-tagged for testability. +#' +#' @param count,freq Numeric vectors of counts and their frequencies. +#' @param type Resolved distribution string. +#' @param size,lambda,conf_level As in [plotness_fit()]. +#' @param call Environment to report errors against. +#' @return `invisible(NULL)`, called for its side effects (errors/warnings). +#' @keywords internal +#' @importFrom rlang caller_env +check_plotness_input <- function(count, freq, type, size, lambda, conf_level, + call = caller_env()) { + if (!is.numeric(conf_level) || length(conf_level) != 1 || + is.na(conf_level) || conf_level <= 0 || conf_level >= 1) { + cli::cli_abort( + "{.arg conf_level} must be a single number in (0, 1), not {.val {conf_level}}.", + class = "plotness_error_conf_level", call = call + ) + } + + n_na <- sum(is.na(count)) + sum(is.na(freq)) + if (n_na > 0) { + cli::cli_abort( + c( + "{.arg x} contains {n_na} missing value{?s}.", + "i" = "Remove or impute them before plotting." + ), + class = "plotness_error_na", call = call + ) + } + + if (any(count < 0)) { + cli::cli_abort( + "Counts must be non-negative; found {.val {count[count < 0]}}.", + class = "plotness_error_negative_count", call = call + ) + } + + noninteger <- abs(count - round(count)) > 1e-8 + if (any(noninteger)) { + cli::cli_abort( + "Counts must be whole numbers; found {.val {count[noninteger]}}.", + class = "plotness_error_noninteger_count", call = call + ) + } + + if (any(freq < 0)) { + cli::cli_abort( + "Frequencies must be non-negative.", + class = "plotness_error_negative_freq", call = call + ) + } + + if (!is.null(size)) { + if (!is.numeric(size) || length(size) != 1 || is.na(size) || + size <= 0 || abs(size - round(size)) > 1e-8) { + cli::cli_abort( + "{.arg size} must be a single positive whole number.", + class = "plotness_error_size", call = call + ) + } + if (type == "binomial" && any(count > size)) { + cli::cli_abort( + c( + "All counts must be <= {.arg size} ({size}) for a binomialness plot.", + "x" = "The largest observed count is {max(count)}.", + "i" = "Increase {.arg size} or check the data." + ), + class = "plotness_error_count_gt_size", call = call + ) + } + } + + if (!is.null(lambda)) { + if (!is.numeric(lambda) || length(lambda) != 1 || !is.finite(lambda) || + lambda <= 0) { + cli::cli_abort( + "{.arg lambda} must be a single positive number.", + class = "plotness_error_lambda", call = call + ) + } + if (type != "poisson") { + cli::cli_warn( + "{.arg lambda} is ignored for {.val {type}}.", + class = "plotness_warning_lambda" + ) + } + } + + n_distinct <- length(unique(count[freq > 0])) + if (n_distinct <= 1) { + cli::cli_abort( + c( + "Cannot fit a line through fewer than two distinct counts.", + "i" = "A distributionness plot needs at least two distinct observed counts." + ), + class = "plotness_error_degenerate", call = call + ) + } + if (n_distinct == 2) { + cli::cli_warn( + "With only two distinct counts the fit is exact and the plot is not diagnostic.", + class = "plotness_warning_degenerate" + ) + } + + invisible(NULL) +} diff --git a/R/plot.R b/R/plot.R new file mode 100644 index 0000000..707624f --- /dev/null +++ b/R/plot.R @@ -0,0 +1,132 @@ +# Okabe-Ito based palette: colourblind-safe and order-independent. +plotness_pal <- c(observed = "#0072B2", line = "#D55E00", ci = "grey40") + +# How the regression slope maps to the parameter estimate, per distribution. +rel_label <- function(object) { + switch(object$type, + poisson = if (is.null(object$estimates$lambda)) { + "exp(slope)" + } else { + "exp(slope) x lambda" + }, + binomial = "inv.logit(slope)", + nbinomial = "1-exp(slope)" + ) +} + +# Build the fit-summary subtitle from the (single source of truth) estimates. +fit_subtitle <- function(object) { + e <- object$estimates + paste0( + "slope = ", round(e$slope, 3), + ", intercept = ", round(e$intercept, 3), "\n", + e$par_name, ": ML = ", round(e$par_ml, 3), + ", ", rel_label(object), " = ", round(e$par_estim, 3) + ) +} + +#' Plot a `plotness_fit` object +#' +#' Draws the Poissonness / binomialness / negative binomialness plot from a +#' fitted [plotness_fit()] object. This is the [ggplot2::autoplot()] method, so +#' `autoplot(fit)` and `plotness(x, ...)` produce the same figure. +#' +#' @param object A [plotness_fit()] object. +#' @param ... Unused; present for `autoplot()` method compatibility. +#' @inheritParams plotness +#' @return A [ggplot2::ggplot] object. Because it is an ordinary ggplot, you can +#' append further layers or a different theme with `+`. +#' @seealso [plotness_fit()], [plotness()] +#' @examples +#' fit <- plotness_fit(rpois(150, 8), type = "poisson") +#' ggplot2::autoplot(fit) +#' ggplot2::autoplot(fit, conf_int = FALSE, label = FALSE) +#' @importFrom ggplot2 autoplot ggplot aes geom_line geom_point geom_errorbar +#' @importFrom ggplot2 scale_colour_manual labs theme_bw theme coord_cartesian +#' @importFrom rlang .data %||% +#' @exportS3Method ggplot2::autoplot +autoplot.plotness_fit <- function(object, ..., title = NULL, legend = TRUE, + label = TRUE, conf_int = TRUE, xlim = NULL, + xlab = "Number of occurrences", + ylab = "Distribution metameter") { + df <- object$data + title <- title %||% switch(object$type, + poisson = "Poissonness Plot", + binomial = "Binomialness Plot", + nbinomial = "Negative Binomialness Plot" + ) + + p <- ggplot(df, aes(x = .data$Counts)) + + # Confidence intervals first (drawn underneath), fixed grey, not a series. + if (isTRUE(conf_int)) { + p <- p + geom_errorbar( + aes(ymin = .data$CI.lower, ymax = .data$CI.upper), + colour = plotness_pal[["ci"]], width = 0.2, na.rm = TRUE + ) + } + + p <- p + + geom_line( + aes(y = .data$y_line, colour = "Perfect distribution"), + linewidth = 0.75, key_glyph = "point" + ) + + geom_point( + aes(y = .data$Metameter, colour = "Observed distribution"), + na.rm = TRUE, key_glyph = "point" + ) + + scale_colour_manual(values = c( + "Observed distribution" = unname(plotness_pal[["observed"]]), + "Perfect distribution" = unname(plotness_pal[["line"]]) + )) + + labs(x = xlab, y = ylab, title = title, colour = "") + + theme_bw() + + if (isTRUE(label)) { + p <- p + labs(subtitle = fit_subtitle(object)) + } + p <- p + theme(legend.position = if (isTRUE(legend)) "top" else "none") + if (!is.null(xlim)) { + p <- p + coord_cartesian(xlim = xlim) + } + p +} + +#' @rdname plotness_fit +#' @param x A `plotness_fit` object. +#' @param ... Ignored. +#' @export +print.plotness_fit <- function(x, ...) { + e <- x$estimates # nolint: object_usage_linter. (used in cli/glue strings below) + cli::cli_text("{.cls plotness_fit}: {.val {x$type}}") + cli::cli_text("distinct counts: {x$n_distinct}") + cli::cli_text( + "slope = {round(e$slope, 3)}, intercept = {round(e$intercept, 3)}" + ) + cli::cli_text( + "{e$par_name}: ML = {round(e$par_ml, 3)}, {rel_label(x)} = {round(e$par_estim, 3)}" + ) + if (isTRUE(x$degenerate)) { + cli::cli_alert_warning( + "Only two distinct counts: the fit is exact, not diagnostic." + ) + } + invisible(x) +} + +#' @rdname plotness_fit +#' @param object A `plotness_fit` object. +#' @return `coef()` returns a named numeric vector `c(intercept, slope)`. +#' @export +coef.plotness_fit <- function(object, ...) { + c(intercept = object$estimates$intercept, slope = object$estimates$slope) +} + +#' @rdname plotness_fit +#' @param legacy Logical. If `TRUE` (default) return the CamelCase data frame +#' used by the plot; if `FALSE` return a tidy snake_case version. +#' @return `as.data.frame()` returns the per-count plotting data frame. +#' @export +as.data.frame.plotness_fit <- function(x, ..., legacy = TRUE) { + if (isTRUE(legacy)) x$data else x$tidy +} diff --git a/R/plotness-package.R b/R/plotness-package.R index 3bc414f..51a87e3 100644 --- a/R/plotness-package.R +++ b/R/plotness-package.R @@ -1,5 +1,8 @@ -# Column names referenced inside ggplot2 aes() are evaluated via data masking, -# which R CMD check cannot see. Declare them so the check is clean. -utils::globalVariables(c( - "Counts", "Metameter", "y_line", "CI.lower", "CI.upper" -)) +#' @keywords internal +"_PACKAGE" + +# Re-export the ggplot2 autoplot generic so `autoplot(fit)` works for users who +# have only attached plotness. +#' @importFrom ggplot2 autoplot +#' @export +ggplot2::autoplot diff --git a/R/plotness.R b/R/plotness.R index 9401897..f7f54b2 100644 --- a/R/plotness.R +++ b/R/plotness.R @@ -1,12 +1,15 @@ #' Poissonness, Binomialness, and Negative Binomialness Plots -#' @author Zayne Sember -#' @description Creates a diagnostic distribution plot or dataframe. The function is adapted -#' from the vcd library's distplot function to produce ggplots and to allow the -#' option to return only the data needed for the plot so the user's preferred -#' graphics package can be used. -#' @importFrom vcd goodfit -#' @import ggplot2 -#' @import stats +#' +#' @description +#' Creates a diagnostic distribution plot or its underlying data frame. Adapted +#' from the \pkg{vcd} package's `distplot()` to produce \pkg{ggplot2} graphics +#' and to allow returning only the data needed for the plot, so the user's +#' preferred graphics package can be used. +#' +#' `plotness()` is a convenience wrapper: it fits the model with +#' [plotness_fit()] and then either draws it (via +#' [autoplot()][autoplot.plotness_fit]) or returns the plotting data. +#' #' @param x either a vector of counts, a 1-way table of frequencies of counts or #' a data frame or matrix with frequencies in the first column and the #' corresponding counts in the second column. @@ -28,156 +31,35 @@ #' @param xlim limits for the x-axis. #' @param xlab a label for the x-axis. #' @param ylab a label for the y-axis. -#' @return If `plot`=`TRUE`, returns a ggplot object; otherwise returns a -#' data frame containing the data necessary to produce a plot. +#' @return If `plot = TRUE`, a [ggplot2::ggplot] object; otherwise a data frame +#' containing the data necessary to produce a plot. For programmatic access to +#' the fitted parameter estimates, use [plotness_fit()]. +#' @seealso [plotness_fit()] for the underlying fit object and estimates, and +#' [autoplot.plotness_fit()] for the plotting method. #' @examples -#' plotness(rpois(15,10),type="poisson"); -#' plotness(rbinom(15,10,prob=0.5),type="binomial"); -#' plotness(rnbinom(15,10,prob=0.5),type="nbinomial"); -#' plotness(rpois(15,10),type="poisson", plot=FALSE); +#' plotness(rpois(15, 10), type = "poisson") +#' plotness(rbinom(15, 10, prob = 0.5), type = "binomial") +#' plotness(rnbinom(15, 10, prob = 0.5), type = "nbinomial") +#' plotness(rpois(15, 10), type = "poisson", plot = FALSE) #' @export -plotness <- - function(x, type = c("poisson", "binomial", "nbinomial"), - size = NULL, lambda = NULL, plot=TRUE, title=NULL, legend = TRUE, - label = TRUE, conf_int = TRUE, conf_level = 0.95, xlim = NULL, - xlab = "Number of occurrences", ylab = "Distribution metameter") - { - if(is.vector(x)) { - x <- table(x) - } - if(is.table(x)) { - if(length(dim(x)) > 1) stop ("x must be a 1-way table") - freq <- as.vector(x) - count <- as.numeric(names(x)) - } else { - if(!(!is.null(ncol(x)) && ncol(x) == 2)) - stop("x must be a 2-column matrix or data.frame") - freq <- as.vector(x[,1]) - count <- as.vector(x[,2]) - } - - myindex <- (1:length(freq))[freq > 0] - mycount <- count[myindex] - myfreq <- freq[myindex] - - switch(match.arg(type), - - "poisson" = { - par.ml <- suppressWarnings(goodfit(x, type = type)$par$lambda) - - phi <- function(nk, k, N, size = NULL) - ifelse(nk > 0, lgamma(k + 1) + log(nk/N), NA) - y <- phi(myfreq, mycount, sum(freq)) - if(!is.null(lambda)) y <- y + lambda - mycount * log(lambda) - fm <- lm(y ~ mycount) - par.estim <- exp(coef(fm)[2]) - names(par.estim) <- "lambda" - txt <- "exp(slope)" - if(!is.null(lambda)) { - par.estim <- par.estim * lambda - txt <- paste(txt, "x lambda") - } - legend.text <- paste(txt, "=", round(par.estim, digits = 3)) - }, - - "binomial" = { - if(is.null(size)) { - size <- max(count) - warning("size was not given, taken as maximum count") - } - par.ml <- suppressWarnings(goodfit(x, type = type, par = list(size = size))$par$prob) - - phi <- function(nk, k, N, size = NULL) - log(nk) - log(N * choose(size, k)) - y <- phi(myfreq, mycount, sum(freq), size = size) - fm <- lm(y ~ mycount) - par.estim <- exp(coef(fm)[2]) - par.estim <- par.estim / (1 + par.estim) - names(par.estim) <- "prob" - legend.text <- paste("inv.logit(slope) =", round(par.estim, digits = 3)) - }, - - "nbinomial" = { - if(is.null(size)) { - par.ml <- suppressWarnings(goodfit(x, type = type)$par) - size <- par.ml$size - par.ml <- par.ml$prob - }else{ - xbar <- weighted.mean(mycount, myfreq) - par.ml <- size / (size+xbar) - } - phi <- function(nk, k, N, size = NULL) - log(nk) - log(N * choose(size + k - 1, k)) - y <- phi(myfreq, mycount, sum(freq), size = size) - fm <- lm(y ~ mycount) - par.estim <- 1 - exp(coef(fm)[2]) - names(par.estim) <- "prob" - legend.text <- paste("1-exp(slope) =", round(par.estim, digits = 3)) - }) - - yhat <- ifelse(myfreq > 1.5, myfreq - 0.67, 1/exp(1)) - yhat <- phi(yhat, mycount, sum(freq), size = size) - if(!is.null(lambda)) yhat <- yhat + lambda - mycount * log(lambda) - - phat <- myfreq / sum(myfreq) - ci.width <- qnorm(1-(1 - conf_level)/2) * - sqrt(1-phat)/sqrt(myfreq - (0.25 * phat + 0.47)*sqrt(myfreq)) - RVAL <- cbind(count, freq, NA, NA, NA, NA, NA) - RVAL[myindex,3:7] <- cbind(y,yhat,ci.width, yhat-ci.width, yhat + ci.width) - RVAL <- as.data.frame(RVAL) - names(RVAL) <- c("Counts", "Freq", "Metameter", "CI.center", - "CI.width", "CI.lower", "CI.upper") - # Fitted "perfect distribution" line, evaluated at every count so the - # column aligns with RVAL even when some counts have zero frequency. - RVAL$y_line <- predict(fm, newdata = data.frame(mycount = RVAL$Counts)) - - if(plot){ - switch(match.arg(type), - "poisson" = { - if(is.null(title)){title="Poissonness Plot"} - label_x = 0.9*quantile(RVAL$Counts)[4] - label_y = quantile(RVAL$Metameter, na.rm=TRUE)[2] - }, - "binomial" = { - if(is.null(title)){title="Binomialness Plot"} - label_x = 0.9*quantile(RVAL$Counts)[4] - label_y = quantile(RVAL$Metameter, na.rm=TRUE)[2] - }, - "nbinomial" = { - if(is.null(title)){title="Negative Binomialness Plot"} - label_x = 0.8*quantile(RVAL$Counts)[2] - label_y = quantile(RVAL$Metameter, na.rm=TRUE)[2] - } - ) - - legend.text <- c(paste("slope =", round(coef(fm)[2], digits = 3)), - paste("intercept =", round(coef(fm)[1], digits = 3)), - "", paste(names(par.estim),": ML =", round(par.ml, digits=3)), - legend.text) - legend.text <- paste(legend.text, collapse = "\n") - - p <- ggplot(RVAL,aes()) + - geom_line(aes(x=Counts,y=y_line, color="Perfect distribution"), - linewidth=0.75, key_glyph="point") + - geom_point(aes(x=Counts, y=Metameter, color="Observed distribution"), - key_glyph="point") + - xlab(xlab) + - ylab(ylab) + - ggtitle(title) + - scale_colour_manual(values=c("red", "blue")) + - theme_bw() + - theme(legend.position="none") - - if(conf_int){ p <- p + geom_errorbar(aes(ymin=CI.lower, - ymax=CI.upper, - x=Counts, - y=Metameter), color="red")} - if(label){p <- p + annotate(geom="text", x=label_x, y=label_y, - label=legend.text,hjust=0)} - if(legend){p <- p + labs(color="") + - theme(legend.position="top")} - if(!is.null(xlim)){p <- p + coord_cartesian(xlim=xlim)} - return(p) - } - return(RVAL) +plotness <- function(x, type = c("poisson", "binomial", "nbinomial"), + size = NULL, lambda = NULL, plot = TRUE, title = NULL, + legend = TRUE, label = TRUE, conf_int = TRUE, + conf_level = 0.95, xlim = NULL, + xlab = "Number of occurrences", + ylab = "Distribution metameter") { + type <- match.arg(type) + fit <- plotness_fit( + x, + type = type, size = size, lambda = lambda, conf_level = conf_level + ) + if (isTRUE(plot)) { + autoplot( + fit, + title = title, legend = legend, label = label, + conf_int = conf_int, xlim = xlim, xlab = xlab, ylab = ylab + ) + } else { + fit$data } +} diff --git a/man/autoplot.plotness_fit.Rd b/man/autoplot.plotness_fit.Rd new file mode 100644 index 0000000..447e3c5 --- /dev/null +++ b/man/autoplot.plotness_fit.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot.R +\name{autoplot.plotness_fit} +\alias{autoplot.plotness_fit} +\title{Plot a \code{plotness_fit} object} +\usage{ +\method{autoplot}{plotness_fit}( + object, + ..., + title = NULL, + legend = TRUE, + label = TRUE, + conf_int = TRUE, + xlim = NULL, + xlab = "Number of occurrences", + ylab = "Distribution metameter" +) +} +\arguments{ +\item{object}{A \code{\link[=plotness_fit]{plotness_fit()}} object.} + +\item{...}{Unused; present for \code{autoplot()} method compatibility.} + +\item{title}{a character string to be used as the title of the plot. If set +to \code{NULL} a generic title is produced based on the distribution type.} + +\item{legend}{logical. Should a legend be plotted?} + +\item{label}{logical. Should fit data be printed on the plot?} + +\item{conf_int}{logical. Should confidence intervals be plotted?} + +\item{xlim}{limits for the x-axis.} + +\item{xlab}{a label for the x-axis.} + +\item{ylab}{a label for the y-axis.} +} +\value{ +A \link[ggplot2:ggplot]{ggplot2::ggplot} object. Because it is an ordinary ggplot, you can +append further layers or a different theme with \code{+}. +} +\description{ +Draws the Poissonness / binomialness / negative binomialness plot from a +fitted \code{\link[=plotness_fit]{plotness_fit()}} object. This is the \code{\link[ggplot2:autoplot]{ggplot2::autoplot()}} method, so +\code{autoplot(fit)} and \code{plotness(x, ...)} produce the same figure. +} +\examples{ +fit <- plotness_fit(rpois(150, 8), type = "poisson") +ggplot2::autoplot(fit) +ggplot2::autoplot(fit, conf_int = FALSE, label = FALSE) +} +\seealso{ +\code{\link[=plotness_fit]{plotness_fit()}}, \code{\link[=plotness]{plotness()}} +} diff --git a/man/check_plotness_input.Rd b/man/check_plotness_input.Rd new file mode 100644 index 0000000..177e755 --- /dev/null +++ b/man/check_plotness_input.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/fit.R +\name{check_plotness_input} +\alias{check_plotness_input} +\title{Validate \code{plotness()} / \code{plotness_fit()} input} +\usage{ +check_plotness_input( + count, + freq, + type, + size, + lambda, + conf_level, + call = caller_env() +) +} +\arguments{ +\item{count, freq}{Numeric vectors of counts and their frequencies.} + +\item{type}{Resolved distribution string.} + +\item{size, lambda, conf_level}{As in \code{\link[=plotness_fit]{plotness_fit()}}.} + +\item{call}{Environment to report errors against.} +} +\value{ +\code{invisible(NULL)}, called for its side effects (errors/warnings). +} +\description{ +Internal helper; runs every guard once so the plot and data paths share +identical validation. All conditions are class-tagged for testability. +} +\keyword{internal} diff --git a/man/plotness-package.Rd b/man/plotness-package.Rd new file mode 100644 index 0000000..0c9650f --- /dev/null +++ b/man/plotness-package.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plotness-package.R +\docType{package} +\name{plotness-package} +\alias{plotness-package} +\title{plotness: Poissonness, Binomialness, and Negative Binomialness Plots} +\description{ +Creates a diagnostic distribution plot or data frame. The per-type metameter statistics are adapted from the 'vcd' package's distplot() function (GPL-2); this package produces 'ggplot2' graphics and can return only the data needed for the plot so the user's preferred graphics package can be used. +} +\seealso{ +Useful links: +\itemize{ + \item \url{https://github.com/zaynesember/plotness} + \item \url{https://zaynesember.github.io/plotness/} + \item Report bugs at \url{https://github.com/zaynesember/plotness/issues} +} + +} +\author{ +\strong{Maintainer}: Zayne Sember \email{zaynesember@gmail.com} + +Other contributors: +\itemize{ + \item David Meyer (author of vcd::distplot(), from which the per-type statistics are adapted) [contributor, copyright holder] + \item Achim Zeileis (author of vcd::distplot()) [contributor, copyright holder] + \item Kurt Hornik (author of vcd::distplot()) [contributor, copyright holder] + \item Michael Friendly (author of vcd::distplot()) [contributor, copyright holder] +} + +} +\keyword{internal} diff --git a/man/plotness.Rd b/man/plotness.Rd index a7236ef..60bc908 100644 --- a/man/plotness.Rd +++ b/man/plotness.Rd @@ -56,21 +56,27 @@ to \code{NULL} a generic title is produced based on the distribution type.} \item{ylab}{a label for the y-axis.} } \value{ -If \code{plot}=\code{TRUE}, returns a ggplot object; otherwise returns a -data frame containing the data necessary to produce a plot. +If \code{plot = TRUE}, a \link[ggplot2:ggplot]{ggplot2::ggplot} object; otherwise a data frame +containing the data necessary to produce a plot. For programmatic access to +the fitted parameter estimates, use \code{\link[=plotness_fit]{plotness_fit()}}. } \description{ -Creates a diagnostic distribution plot or dataframe. The function is adapted -from the vcd library's distplot function to produce ggplots and to allow the -option to return only the data needed for the plot so the user's preferred -graphics package can be used. +Creates a diagnostic distribution plot or its underlying data frame. Adapted +from the \pkg{vcd} package's \code{distplot()} to produce \pkg{ggplot2} graphics +and to allow returning only the data needed for the plot, so the user's +preferred graphics package can be used. + +\code{plotness()} is a convenience wrapper: it fits the model with +\code{\link[=plotness_fit]{plotness_fit()}} and then either draws it (via +\link[=autoplot.plotness_fit]{autoplot()}) or returns the plotting data. } \examples{ -plotness(rpois(15,10),type="poisson"); -plotness(rbinom(15,10,prob=0.5),type="binomial"); -plotness(rnbinom(15,10,prob=0.5),type="nbinomial"); -plotness(rpois(15,10),type="poisson", plot=FALSE); +plotness(rpois(15, 10), type = "poisson") +plotness(rbinom(15, 10, prob = 0.5), type = "binomial") +plotness(rnbinom(15, 10, prob = 0.5), type = "nbinomial") +plotness(rpois(15, 10), type = "poisson", plot = FALSE) } -\author{ -Zayne Sember \href{mailto:zaynesember@gmail.com}{zaynesember@gmail.com} +\seealso{ +\code{\link[=plotness_fit]{plotness_fit()}} for the underlying fit object and estimates, and +\code{\link[=autoplot.plotness_fit]{autoplot.plotness_fit()}} for the plotting method. } diff --git a/man/plotness_fit.Rd b/man/plotness_fit.Rd new file mode 100644 index 0000000..d177160 --- /dev/null +++ b/man/plotness_fit.Rd @@ -0,0 +1,86 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/fit.R, R/plot.R +\name{plotness_fit} +\alias{plotness_fit} +\alias{print.plotness_fit} +\alias{coef.plotness_fit} +\alias{as.data.frame.plotness_fit} +\title{Fit a distribution diagnostic ("-ness") plot} +\usage{ +plotness_fit( + x, + type = c("poisson", "binomial", "nbinomial"), + size = NULL, + lambda = NULL, + conf_level = 0.95 +) + +\method{print}{plotness_fit}(x, ...) + +\method{coef}{plotness_fit}(object, ...) + +\method{as.data.frame}{plotness_fit}(x, ..., legacy = TRUE) +} +\arguments{ +\item{x}{A \code{plotness_fit} object.} + +\item{type}{a character string indicating the distribution.} + +\item{size}{the size argument for the binomial and negative binomial +distribution. If set to NULL and type is "binomial", then size is taken to +be the maximum count. If set to NULL and type is "nbinomial", then size is +estimated from the data.} + +\item{lambda}{parameter of the poisson distribution. If type is "poisson" +and lambda is specified a leveled Poissonness plot is produced.} + +\item{conf_level}{confidence level for confidence intervals.} + +\item{...}{Ignored.} + +\item{object}{A \code{plotness_fit} object.} + +\item{legacy}{Logical. If \code{TRUE} (default) return the CamelCase data frame +used by the plot; if \code{FALSE} return a tidy snake_case version.} +} +\value{ +An object of class \code{plotness_fit}: a list with elements +\describe{ +\item{\code{type}}{the distribution, one of \code{"poisson"}, \code{"binomial"}, +\code{"nbinomial"}.} +\item{\code{data}}{the plotting data frame (one row per distinct count).} +\item{\code{tidy}}{a snake_case copy of \code{data}.} +\item{\code{estimates}}{a named list of fitted estimates (see below).} +\item{\code{model}}{the underlying \code{\link[stats:lm]{stats::lm()}} fit.} +\item{\code{size}, \code{conf_level}}{the resolved size and confidence level.} +\item{\code{n_distinct}, \code{degenerate}}{number of distinct observed counts and +whether the fit is degenerate (exactly two distinct counts).} +} +\code{estimates} contains \code{par_name}, \code{par_estim} (the regression-implied +estimate), \code{par_ml} (the \code{\link[vcd:goodfit]{vcd::goodfit()}} maximum-likelihood estimate), +\code{slope}, \code{intercept}, \code{size}, \code{lambda}, and \code{conf_level}. + +\code{coef()} returns a named numeric vector \code{c(intercept, slope)}. + +\code{as.data.frame()} returns the per-count plotting data frame. +} +\description{ +\code{plotness_fit()} runs the statistics behind a Poissonness, binomialness, or +negative binomialness plot and returns a \code{plotness_fit} object. The object +carries the per-count plotting data, the fitted line, and the parameter +estimates, and has \link[=autoplot.plotness_fit]{autoplot()}, \code{print()}, \code{coef()}, +and \code{as.data.frame()} methods. + +Most users can call \code{\link[=plotness]{plotness()}} directly; use \code{plotness_fit()} when you want +the fitted estimates programmatically (via \code{fit$estimates} or \code{coef(fit)}) +rather than only as text on the plot. +} +\examples{ +fit <- plotness_fit(rpois(150, 8), type = "poisson") +fit +coef(fit) +fit$estimates$par_estim +} +\seealso{ +\code{\link[=plotness]{plotness()}}, \code{\link[=autoplot.plotness_fit]{autoplot.plotness_fit()}} +} diff --git a/man/reexports.Rd b/man/reexports.Rd new file mode 100644 index 0000000..8813bf2 --- /dev/null +++ b/man/reexports.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plotness-package.R +\docType{import} +\name{reexports} +\alias{reexports} +\alias{autoplot} +\title{Objects exported from other packages} +\keyword{internal} +\description{ +These objects are imported from other packages. Follow the links +below to see their documentation. + +\describe{ + \item{ggplot2}{\code{\link[ggplot2]{autoplot}}} +}} + From 7e0b3141807dd934d43260a786dbf0e7d0284c14 Mon Sep 17 00:00:00 2001 From: Zayne Date: Sun, 28 Jun 2026 20:20:24 -0400 Subject: [PATCH 2/4] Expand the test suite with value-level and visual-regression tests - Add vdiffr snapshot tests for all three plot types. - Add plotness_fit() tests: object shape, estimate recovery and coef() consistency, the explicit-size nbinomial branch, leveled-lambda numerics, and every class-tagged validation error. - Strengthen the legacy data-frame tests to pin NA placement at zero-frequency rows and anchor metameter values (catches row-misalignment regressions). - Replace the tautological y_line monotonicity check with a value check against the fit, and add facade-vs-autoplot equivalence and xlim plot-path tests. Co-Authored-By: Claude Opus 4.8 --- .../_snaps/autoplot-vdiffr/binomialness.svg | 112 +++++++++++ .../autoplot-vdiffr/negative-binomialness.svg | 157 ++++++++++++++++ .../autoplot-vdiffr/poissonness-bare.svg | 80 ++++++++ .../_snaps/autoplot-vdiffr/poissonness.svg | 120 ++++++++++++ tests/testthat/test-autoplot-vdiffr.R | 25 +++ tests/testthat/test-plotness.R | 74 ++++++-- tests/testthat/test-plotness_fit.R | 174 ++++++++++++++++++ 7 files changed, 727 insertions(+), 15 deletions(-) create mode 100644 tests/testthat/_snaps/autoplot-vdiffr/binomialness.svg create mode 100644 tests/testthat/_snaps/autoplot-vdiffr/negative-binomialness.svg create mode 100644 tests/testthat/_snaps/autoplot-vdiffr/poissonness-bare.svg create mode 100644 tests/testthat/_snaps/autoplot-vdiffr/poissonness.svg create mode 100644 tests/testthat/test-autoplot-vdiffr.R create mode 100644 tests/testthat/test-plotness_fit.R diff --git a/tests/testthat/_snaps/autoplot-vdiffr/binomialness.svg b/tests/testthat/_snaps/autoplot-vdiffr/binomialness.svg new file mode 100644 index 0000000..4832072 --- /dev/null +++ b/tests/testthat/_snaps/autoplot-vdiffr/binomialness.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-12 +-10 +-8 +-6 + + + + + + + + +2 +4 +6 +8 +Number of occurrences +Distribution metameter + + + + + +Observed distribution +Perfect distribution +slope = -0.461, intercept = -4.869 +prob: ML = 0.397, inv.logit(slope) = 0.387 +Binomialness Plot + + diff --git a/tests/testthat/_snaps/autoplot-vdiffr/negative-binomialness.svg b/tests/testthat/_snaps/autoplot-vdiffr/negative-binomialness.svg new file mode 100644 index 0000000..339c01b --- /dev/null +++ b/tests/testthat/_snaps/autoplot-vdiffr/negative-binomialness.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-16 +-12 +-8 +-4 + + + + + + + + + +0 +5 +10 +15 +20 +Number of occurrences +Distribution metameter + + + + + +Observed distribution +Perfect distribution +slope = -0.478, intercept = -4.931 +prob: ML = 0.41, 1-exp(slope) = 0.38 +Negative Binomialness Plot + + diff --git a/tests/testthat/_snaps/autoplot-vdiffr/poissonness-bare.svg b/tests/testthat/_snaps/autoplot-vdiffr/poissonness-bare.svg new file mode 100644 index 0000000..4715117 --- /dev/null +++ b/tests/testthat/_snaps/autoplot-vdiffr/poissonness-bare.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +-5 +0 +5 +10 +15 + + + + + + + +5 +10 +Number of occurrences +Distribution metameter +Poissonness Plot + + diff --git a/tests/testthat/_snaps/autoplot-vdiffr/poissonness.svg b/tests/testthat/_snaps/autoplot-vdiffr/poissonness.svg new file mode 100644 index 0000000..84e1b43 --- /dev/null +++ b/tests/testthat/_snaps/autoplot-vdiffr/poissonness.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +10 +20 + + + + + +5 +10 +Number of occurrences +Distribution metameter + + + + + +Observed distribution +Perfect distribution +slope = 1.82, intercept = -6.327 +lambda: ML = 6.12, exp(slope) = 6.172 +Poissonness Plot + + diff --git a/tests/testthat/test-autoplot-vdiffr.R b/tests/testthat/test-autoplot-vdiffr.R new file mode 100644 index 0000000..8c7412e --- /dev/null +++ b/tests/testthat/test-autoplot-vdiffr.R @@ -0,0 +1,25 @@ +# Visual-regression tests. Baselines live in tests/testthat/_snaps/. +# These are skipped on CRAN and only fail locally/CI when a figure changes. + +test_that("autoplot produces stable figures", { + skip_if_not_installed("vdiffr") + + set.seed(1) + pois <- plotness_fit(rpois(200, 6), type = "poisson") + vdiffr::expect_doppelganger("poissonness", ggplot2::autoplot(pois)) + vdiffr::expect_doppelganger( + "poissonness-bare", + ggplot2::autoplot(pois, conf_int = FALSE, label = FALSE, legend = FALSE) + ) + + set.seed(2) + binom <- plotness_fit(rbinom(200, 10, prob = 0.4), + type = "binomial", + size = 10 + ) + vdiffr::expect_doppelganger("binomialness", ggplot2::autoplot(binom)) + + set.seed(3) + nbin <- plotness_fit(rnbinom(200, size = 5, prob = 0.4), type = "nbinomial") + vdiffr::expect_doppelganger("negative-binomialness", ggplot2::autoplot(nbin)) +}) diff --git a/tests/testthat/test-plotness.R b/tests/testthat/test-plotness.R index 97bee75..f3c1f41 100644 --- a/tests/testthat/test-plotness.R +++ b/tests/testthat/test-plotness.R @@ -6,8 +6,10 @@ test_that("each distribution type returns a ggplot", { suppressWarnings(plotness(rbinom(50, 10, prob = 0.5), type = "binomial")), "ggplot" ) - expect_s3_class(plotness(rnbinom(50, 10, prob = 0.5), type = "nbinomial"), - "ggplot") + expect_s3_class( + plotness(rnbinom(50, 10, prob = 0.5), type = "nbinomial"), + "ggplot" + ) }) test_that("plot = FALSE returns the expected data frame", { @@ -16,32 +18,42 @@ test_that("plot = FALSE returns the expected data frame", { expect_s3_class(df, "data.frame") expect_identical( names(df), - c("Counts", "Freq", "Metameter", "CI.center", "CI.width", - "CI.lower", "CI.upper", "y_line") + c( + "Counts", "Freq", "Metameter", "CI.center", "CI.width", + "CI.lower", "CI.upper", "y_line" + ) ) # one row per distinct observed count expect_equal(nrow(df), length(unique(counts))) expect_equal(sum(df$Freq), length(counts)) }) -test_that("two-column input with a zero frequency does not crash (regression: B1)", { - # Counts with a gap: count 1 has zero frequency. The fitted-line column must - # still align with every row of the result, plot or no plot. +test_that("two-column input with a zero frequency aligns values to rows (regression: B1)", { + # Counts with a gap: count 1 has zero frequency. Metameter/CI must be NA at + # the gap and defined elsewhere, and the values must land on the right rows. gap <- data.frame(freq = c(5, 0, 8, 3), count = c(0, 1, 2, 3)) - expect_no_error(plotness(gap, type = "poisson", plot = FALSE)) expect_no_error(plotness(gap, type = "poisson", plot = TRUE)) df <- plotness(gap, type = "poisson", plot = FALSE) expect_equal(nrow(df), 4) - expect_false(any(is.na(df$y_line))) # line is defined at every count + + # the zero-frequency row (Counts == 1) is NA in every fitted column ... + na_cols <- c("Metameter", "CI.center", "CI.width", "CI.lower", "CI.upper") + expect_true(all(is.na(df[df$Counts == 1, na_cols]))) + # ... but the non-gap rows are fully populated, and y_line everywhere. + expect_false(any(is.na(df[df$Counts != 1, na_cols]))) + expect_false(any(is.na(df$y_line))) + + # value anchor: metameter at Counts==2 is lgamma(2+1) + log(8/16) == 0 + expect_equal(df$Metameter[df$Counts == 2], lgamma(3) + log(8 / 16)) }) -test_that("y_line is the fitted regression line, one value per count", { - df <- plotness(rpois(50, 10), type = "poisson", plot = FALSE) - expect_equal(length(df$y_line), nrow(df)) - # fitted line through a single lm() is monotone in Counts - ord <- order(df$Counts) - expect_true(!is.unsorted(df$y_line[ord]) || !is.unsorted(rev(df$y_line[ord]))) +test_that("y_line equals the fitted line through every count", { + fit <- plotness_fit(rpois(50, 10), type = "poisson") + expect_equal( + fit$data$y_line, + fit$estimates$intercept + fit$estimates$slope * fit$data$Counts + ) }) test_that("passing xlim does not corrupt the fitted line (regression: B1)", { @@ -51,6 +63,38 @@ test_that("passing xlim does not corrupt the fitted line (regression: B1)", { expect_gt(length(unique(round(df$y_line, 6))), 2) }) +test_that("xlim is applied to the plot via coord_cartesian", { + p <- plotness(rpois(50, 10), type = "poisson", xlim = c(0, 20)) + expect_s3_class(p$coordinates, "CoordCartesian") + expect_equal(p$coordinates$limits$x, c(0, 20)) + # without xlim the x limits are left unset + p0 <- plotness(rpois(50, 10), type = "poisson") + expect_null(p0$coordinates$limits$x) +}) + +test_that("plotness() facade matches autoplot() with options threaded through", { + x <- rpois(80, 8) + fit <- plotness_fit(x, type = "poisson") + for (ci in c(TRUE, FALSE)) { + for (lab in c(TRUE, FALSE)) { + expect_equal( + ggplot2::ggplot_build( + plotness(x, "poisson", conf_int = ci, label = lab) + )$data, + ggplot2::ggplot_build( + ggplot2::autoplot(fit, conf_int = ci, label = lab) + )$data + ) + } + } + p1 <- plotness(x, "poisson", title = "T", xlab = "XX", legend = FALSE) + p2 <- ggplot2::autoplot(fit, title = "T", xlab = "XX", legend = FALSE) + expect_identical( + p1$labels[c("title", "x", "y", "subtitle")], + p2$labels[c("title", "x", "y", "subtitle")] + ) +}) + test_that("binomial without size warns and defaults to the maximum count", { expect_warning( plotness(rbinom(50, 10, prob = 0.5), type = "binomial"), diff --git a/tests/testthat/test-plotness_fit.R b/tests/testthat/test-plotness_fit.R new file mode 100644 index 0000000..3479338 --- /dev/null +++ b/tests/testthat/test-plotness_fit.R @@ -0,0 +1,174 @@ +set.seed(42) + +test_that("plotness_fit() returns a well-formed object", { + fit <- plotness_fit(rpois(200, 6), type = "poisson") + expect_s3_class(fit, "plotness_fit") + expect_named( + fit, + c( + "type", "call", "data", "tidy", "estimates", "model", + "size", "conf_level", "n_distinct", "degenerate" + ) + ) + expect_s3_class(fit$model, "lm") + expect_identical(names(fit$data), names(plotness(rpois(50, 6), "poisson", + plot = FALSE + ))) +}) + +test_that("estimates are exposed and consistent", { + fit <- plotness_fit(rpois(500, 6), type = "poisson") + e <- fit$estimates + expect_named( + e, + c( + "type", "par_name", "par_estim", "par_ml", "slope", "intercept", + "size", "lambda", "conf_level" + ) + ) + expect_type(e$par_estim, "double") + expect_equal(e$par_name, "lambda") + # exp(slope) recovers a rate near the simulated lambda = 6 + expect_equal(e$par_estim, 6, tolerance = 0.5) + # coef() agrees with the stored estimates + expect_named(coef(fit), c("intercept", "slope")) + expect_equal(unname(coef(fit)["slope"]), e$slope) +}) + +test_that("binomial / nbinomial estimates use prob and agree with coef()", { + bfit <- plotness_fit(rbinom(300, 12, 0.4), type = "binomial", size = 12) + expect_equal(bfit$estimates$par_name, "prob") + expect_equal(bfit$estimates$par_estim, 0.4, tolerance = 0.15) + expect_true(is.finite(bfit$estimates$par_ml)) + expect_equal(unname(coef(bfit)["slope"]), bfit$estimates$slope) + expect_equal(unname(coef(bfit)["intercept"]), bfit$estimates$intercept) + # the regression and ML estimators are computed differently, so differ + expect_false(isTRUE(all.equal(bfit$estimates$par_estim, bfit$estimates$par_ml))) + + # nbinomial: size estimated from the data + nfit <- plotness_fit(rnbinom(300, size = 5, prob = 0.4), type = "nbinomial") + expect_equal(nfit$estimates$par_name, "prob") + expect_equal(unname(coef(nfit)["slope"]), nfit$estimates$slope) +}) + +test_that("nbinomial with explicit size uses the method-of-moments prob branch", { + set.seed(99) + y <- rnbinom(300, size = 5, prob = 0.4) + nfit <- plotness_fit(y, type = "nbinomial", size = 5) + expect_equal(nfit$size, 5) + # par_ml = size / (size + weighted-mean count) on the positive-freq rows + tab <- table(y) + cnt <- as.numeric(names(tab)) + fr <- as.vector(tab) + xbar <- weighted.mean(cnt[fr > 0], fr[fr > 0]) + expect_equal(nfit$estimates$par_ml, 5 / (5 + xbar)) +}) + +test_that("as.data.frame(legacy=) toggles the column style but not the values", { + fit <- plotness_fit(rpois(100, 6), type = "poisson") + legacy <- as.data.frame(fit) + tidy <- as.data.frame(fit, legacy = FALSE) + expect_identical(names(legacy), names(fit$data)) + expect_identical( + names(tidy), + c( + "count", "freq", "metameter", "ci_center", "ci_width", + "ci_lower", "ci_upper", "y_line" + ) + ) + # only the column names differ; the underlying values are identical + expect_equal(unname(as.matrix(legacy)), unname(as.matrix(tidy))) +}) + +test_that("print() shows a summary and returns the object invisibly", { + fit <- plotness_fit(rpois(100, 6), type = "poisson") + out <- cli::cli_fmt(print(fit)) + expect_true(any(grepl("plotness_fit", out))) + expect_true(any(grepl("slope", out))) + expect_invisible(print(fit)) +}) + +test_that("input validation rejects bad data with tagged conditions", { + expect_error(plotness_fit(c(2, 5, 11), "binomial", size = 10), + class = "plotness_error_count_gt_size" + ) + expect_error(plotness_fit(rpois(50, 6), "poisson", conf_level = 1.2), + class = "plotness_error_conf_level" + ) + expect_error(plotness_fit(c(2, 2, 3, -1), "poisson"), + class = "plotness_error_negative_count" + ) + expect_error(plotness_fit(c(1.5, 2, 3, 3), "poisson"), + class = "plotness_error_noninteger_count" + ) + expect_error(plotness_fit(c(1, 2, NA, 3), "poisson"), + class = "plotness_error_na" + ) + expect_error(plotness_fit(rep(3, 20), "poisson"), + class = "plotness_error_degenerate" + ) + expect_error(plotness_fit(c(2, 5, 8), "binomial", size = -3), + class = "plotness_error_size" + ) + expect_error(plotness_fit(rbinom(50, 10, 0.4), "binomial", size = 2.5), + class = "plotness_error_size" + ) + expect_error( + plotness_fit(data.frame(f = c(-1, 2, 3), c = c(0, 1, 2)), "poisson"), + class = "plotness_error_negative_freq" + ) +}) + +test_that("the NA error reports the true number of missing observations", { + expect_error(plotness_fit(c(1, 2, NA, NA, 3), "poisson"), + class = "plotness_error_na", regexp = "2 missing" + ) +}) + +test_that("leveled Poissonness plot shifts the metameter (numeric)", { + set.seed(7) + x <- rpois(200, 6) + lam <- 5 + base <- plotness_fit(x, type = "poisson") + lev <- plotness_fit(x, type = "poisson", lambda = lam) + + expect_equal(lev$estimates$lambda, lam) + expect_equal(rel_label(lev), "exp(slope) x lambda") + + # leveled metameter = unleveled + lambda - count * log(lambda) at observed rows + obs <- !is.na(base$data$Metameter) + cnt <- base$data$Counts[obs] + expect_equal( + lev$data$Metameter[obs], + base$data$Metameter[obs] + lam - cnt * log(lam) + ) + + # exp(slope) * lambda is invariant to leveling, but the intercept changes + expect_equal(lev$estimates$par_estim, base$estimates$par_estim) + expect_false( + isTRUE(all.equal(lev$estimates$intercept, base$estimates$intercept)) + ) +}) + +test_that("two distinct counts warn but still fit", { + expect_warning(plotness_fit(c(1, 1, 3, 3), "poisson"), + class = "plotness_warning_degenerate" + ) + fit <- suppressWarnings(plotness_fit(c(1, 1, 3, 3), "poisson")) + expect_true(fit$degenerate) +}) + +test_that("lambda is rejected/ignored appropriately", { + expect_error(plotness_fit(rpois(50, 6), "poisson", lambda = -1), + class = "plotness_error_lambda" + ) + expect_warning( + plotness_fit(rbinom(80, 10, 0.5), "binomial", size = 10, lambda = 5), + class = "plotness_warning_lambda" + ) + # leveled Poissonness plot accepts a valid lambda + expect_s3_class( + plotness_fit(rpois(80, 6), "poisson", lambda = 6), + "plotness_fit" + ) +}) From b39a9d71f35614eff6db5138f1c02f9fcfb77337 Mon Sep 17 00:00:00 2001 From: Zayne Date: Sun, 28 Jun 2026 20:20:33 -0400 Subject: [PATCH 3/4] Add CI, pkgdown, spell-check, and lint configuration - GitHub Actions workflows: R-CMD-check (multi-OS/R), test coverage (Codecov), and pkgdown deploy. - pkgdown site config; spelling check (tests/spelling.R + inst/WORDLIST). - .lintr config (lintr is clean); build-ignore .lintr. Co-Authored-By: Claude Opus 4.8 --- .Rbuildignore | 4 ++ .github/.gitignore | 1 + .github/workflows/R-CMD-check.yaml | 50 +++++++++++++++++++++++ .github/workflows/pkgdown.yaml | 47 ++++++++++++++++++++++ .github/workflows/test-coverage.yaml | 60 ++++++++++++++++++++++++++++ .gitignore | 1 + .lintr | 5 +++ _pkgdown.yml | 3 ++ inst/WORDLIST | 28 +++++++++++++ tests/spelling.R | 6 +++ 10 files changed, 205 insertions(+) create mode 100644 .github/.gitignore create mode 100644 .github/workflows/R-CMD-check.yaml create mode 100644 .github/workflows/pkgdown.yaml create mode 100644 .github/workflows/test-coverage.yaml create mode 100644 .lintr create mode 100644 _pkgdown.yml create mode 100644 inst/WORDLIST create mode 100644 tests/spelling.R diff --git a/.Rbuildignore b/.Rbuildignore index a8664fd..6acac42 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -4,4 +4,8 @@ ^LICENSE\.md$ ^README\.Rmd$ ^TODO\.md$ +^\.lintr$ ^\.git$ +^_pkgdown\.yml$ +^docs$ +^pkgdown$ diff --git a/.github/.gitignore b/.github/.gitignore new file mode 100644 index 0000000..2d19fc7 --- /dev/null +++ b/.github/.gitignore @@ -0,0 +1 @@ +*.html diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..73bfb21 --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,50 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + +name: R-CMD-check.yaml + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v6 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml new file mode 100644 index 0000000..16c6c8b --- /dev/null +++ b/.github/workflows/pkgdown.yaml @@ -0,0 +1,47 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + release: + types: [published] + workflow_dispatch: + +name: pkgdown.yaml + +permissions: read-all + +jobs: + pkgdown: + runs-on: ubuntu-latest + # Only restrict concurrency for non-PR jobs + concurrency: + group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::pkgdown, local::. + needs: website + + - name: Build site + run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) + shell: Rscript {0} + + - name: Deploy to GitHub pages 🚀 + if: github.event_name != 'pull_request' + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..5e820d3 --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,60 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + +name: test-coverage.yaml + +permissions: read-all + +jobs: + test-coverage: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + steps: + - uses: actions/checkout@v6 + + - uses: r-lib/actions/setup-r@v2 + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::covr, any::xml2 + needs: coverage + + - name: Test coverage + run: | + cov <- covr::package_coverage( + quiet = FALSE, + clean = FALSE, + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") + ) + print(cov) + covr::to_cobertura(cov) + shell: Rscript {0} + + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 + with: + # Fail if error if not on PR, or if on PR and token is given + fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }} + files: ./cobertura.xml + plugins: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Show testthat output + if: always() + run: | + ## -------------------------------------------------------------------- + find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true + shell: bash + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: coverage-test-failures + path: ${{ runner.temp }}/package diff --git a/.gitignore b/.gitignore index 5388025..2c18941 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ # macOS .DS_Store +docs diff --git a/.lintr b/.lintr new file mode 100644 index 0000000..52b1fc2 --- /dev/null +++ b/.lintr @@ -0,0 +1,5 @@ +linters: linters_with_defaults( + line_length_linter(100L), + indentation_linter = NULL + ) +encoding: "UTF-8" diff --git a/_pkgdown.yml b/_pkgdown.yml new file mode 100644 index 0000000..07774e6 --- /dev/null +++ b/_pkgdown.yml @@ -0,0 +1,3 @@ +url: https://zaynesember.github.io/plotness/ +template: + bootstrap: 5 diff --git a/inst/WORDLIST b/inst/WORDLIST new file mode 100644 index 0000000..ce2fb0f --- /dev/null +++ b/inst/WORDLIST @@ -0,0 +1,28 @@ +Acknowledgements +Behaviour +Binomialness +CMD +CamelCase +Codecov +Hoaglin +Hornik +Lifecycle +Okabe +Poissonness +Relicensed +Zeileis +binomialness +colourblind +colours +distplot +ggplot +ggplots +grey +metameter +nbinomial +ness +poisson +reskins +testability +vcd +λ diff --git a/tests/spelling.R b/tests/spelling.R new file mode 100644 index 0000000..13f77d9 --- /dev/null +++ b/tests/spelling.R @@ -0,0 +1,6 @@ +if (requireNamespace("spelling", quietly = TRUE)) { + spelling::spell_check_test( + vignettes = TRUE, error = FALSE, + skip_on_cran = TRUE + ) +} From 0c47c59f397a3124c2ea7749fabc26cb4a879861 Mon Sep 17 00:00:00 2001 From: Zayne Date: Sun, 28 Jun 2026 20:20:49 -0400 Subject: [PATCH 4/4] Document the new API in README, NEWS, and the vignette - README: add a "Getting the fitted estimates" section (plotness_fit/coef/ autoplot), status badges, and the GPL license note. - NEWS: record the new features, plot changes, behaviour changes (degenerate and lambda warnings), and the relicense. - Vignette: add a programmatic-estimates section and match the new palette. Co-Authored-By: Claude Opus 4.8 --- NEWS.md | 41 ++++++++++++++++++++++++++++++++++++++--- README.md | 23 ++++++++++++++++++++++- TODO.md | 6 ++++++ vignettes/plotness.Rmd | 36 +++++++++++++++++++++++++++++------- 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/NEWS.md b/NEWS.md index 5112129..2f00043 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,39 @@ # plotness 0.1.0.9000 +## New features + +* New `plotness_fit()` computes the fit and returns a `plotness_fit` object that + exposes the fitted parameter estimates programmatically (via `fit$estimates` + or `coef(fit)`) instead of only as text on the plot. It has `print()`, + `coef()`, `as.data.frame()`, and an `ggplot2::autoplot()` method. +* `plotness()` is now a thin wrapper over `plotness_fit()` + `autoplot()`; its + signature and dual return type are unchanged. +* Inputs are validated with clear, class-tagged error messages (negative or + non-integer counts, counts exceeding `size` for a binomialness plot, + `conf_level` outside `(0, 1)`, missing values, and degenerate fits). + +## Plot changes + +* The palette is now the colourblind-safe Okabe-Ito scheme, mapped by a named + vector so colours no longer depend on factor ordering. Confidence-interval + bars are grey and visually separate from the observed points. +* The fit summary is drawn as a subtitle rather than positioned inside the panel + with quantile heuristics, so it can no longer overlap the data. + +## Behaviour change + +* Input with only one distinct count now errors (class + `plotness_error_degenerate`) instead of returning a data frame with an + undefined slope; exactly two distinct counts warn but still plot. +* Supplying `lambda` with `type = "binomial"` or `type = "nbinomial"` now warns + (class `plotness_warning_lambda`) and ignores it; previously it was silently + applied to the metameter. + +## Licensing + +* Relicensed to `GPL (>= 2)`. `plotness` adapts code from `vcd::distplot()`, + which is GPL-2, so a compatible GPL license is required. + ## Bug fixes * `plotness()` no longer errors on two-column `(freq, count)` input that @@ -20,7 +54,8 @@ ## Documentation & packaging * Corrected the spelling "Poissonness" throughout (title, plot titles, docs). -* Moved to `Authors@R`, declared `stats` in `Imports`, dropped the spurious +* Moved to `Authors@R`, declared dependencies in `Imports`, dropped the spurious `LazyData` field, and added `URL`/`BugReports`. -* Switched the license metadata to the CRAN `MIT + file LICENSE` form. -* Added a `testthat` test suite, `.Rbuildignore`, and `.gitignore`. +* Added a `testthat` test suite (including `vdiffr` visual-regression tests), a + vignette, a `pkgdown` site, GitHub Actions CI, spell checking, `.Rbuildignore`, + and `.gitignore`. diff --git a/README.md b/README.md index 70cd217..b7ffabf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # plotness + +[![R-CMD-check](https://github.com/zaynesember/plotness/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/zaynesember/plotness/actions/workflows/R-CMD-check.yaml) +[![Codecov test coverage](https://codecov.io/gh/zaynesember/plotness/graph/badge.svg)](https://app.codecov.io/gh/zaynesember/plotness) +[![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) + + An R package improving upon the `vcd` package's `distplot()` function. It lets you produce **Poissonness**, **binomialness**, and **negative binomialness** plots with `ggplot2` — or return just the data needed for the plot so you can @@ -46,6 +52,20 @@ plotness(rpois(15, 10), type = "poisson", plot = FALSE) See `?plotness` for the full list of arguments (confidence intervals, leveled Poissonness plots via `lambda`, custom titles/labels, and more). +### Getting the fitted estimates + +`plotness_fit()` runs the same statistics and returns a `plotness_fit` object +that exposes the fitted parameter estimates (rather than only printing them on +the plot). `plotness()` is a thin wrapper around it plus `autoplot()`: + +```r +fit <- plotness_fit(rpois(200, 6), type = "poisson") +fit # prints slope/intercept and the parameter estimates +coef(fit) # c(intercept, slope) +fit$estimates$par_estim # exp(slope): the implied rate +ggplot2::autoplot(fit) # the same figure plotness() would draw +``` + ## Acknowledgements The statistical machinery is adapted from `vcd::distplot()` (Meyer, Zeileis & @@ -53,4 +73,5 @@ Hornik). `plotness` reskins it with `ggplot2` and adds a data-only return mode. ## License -MIT © Zayne Sember +GPL (>= 2) © Zayne Sember. `plotness` adapts code from `vcd::distplot()`, which +is GPL-2, so the package is distributed under a compatible GPL license. diff --git a/TODO.md b/TODO.md index 8ba9552..fa0753d 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,12 @@ Status legend: `[ ]` open · `[x]` done **As of this pass: all items resolved; `R CMD check` is clean (0/0/0).** +> A subsequent enhancement round (relicense to GPL (>= 2) + vcd attribution, +> input validation, the `plotness_fit()`/`autoplot()` API with exposed +> estimates, Okabe-Ito plot polish, vdiffr/value-level tests, CI + pkgdown + +> spell-check, and a styler/lintr pass) is recorded in `NEWS.md`. It passed an +> adversarial multi-agent review; `R CMD check --as-cran` stays 0/0/0. + --- ## 🔴 Correctness bugs diff --git a/vignettes/plotness.Rmd b/vignettes/plotness.Rmd index a871853..0af6bd8 100644 --- a/vignettes/plotness.Rmd +++ b/vignettes/plotness.Rmd @@ -79,8 +79,10 @@ should bend the points away from the line: ```{r misfit} overdispersed <- rnbinom(200, size = 1.5, prob = 0.2) -plotness(overdispersed, type = "poisson", - title = "Overdispersed counts on a Poissonness plot") +plotness(overdispersed, + type = "poisson", + title = "Overdispersed counts on a Poissonness plot" +) ``` The curvature — points sagging below the line in the middle — is the tell-tale @@ -111,12 +113,30 @@ A minimal hand-rolled version of the plot from that data: ```{r data-plot} ggplot(df, aes(Counts)) + - geom_line(aes(y = y_line), colour = "blue") + - geom_point(aes(y = Metameter), colour = "red") + + geom_line(aes(y = y_line), colour = "#D55E00") + + geom_point(aes(y = Metameter), colour = "#0072B2") + labs(y = "Metameter", title = "Built from plotness(plot = FALSE)") + theme_bw() ``` +## Getting the fitted estimates + +The on-plot summary is convenient, but the fitted numbers are also available +programmatically. `plotness_fit()` returns a `plotness_fit` object that prints a +short summary and exposes the estimates directly: + +```{r fit} +fit <- plotness_fit(pois_counts, type = "poisson") +fit + +coef(fit) +fit$estimates$par_estim # exp(slope): the implied rate +``` + +`plotness(x, ...)` is simply `autoplot()` applied to this object, so +`ggplot2::autoplot(fit)` reproduces the plot and you never have to recompute the +fit to get both the figure and the numbers. + ## Useful options A few arguments worth knowing: @@ -129,9 +149,11 @@ A few arguments worth knowing: plot (useful when comparing against a hypothesized rate). ```{r options} -plotness(pois_counts, type = "poisson", - conf_int = FALSE, label = FALSE, - title = "Stripped-down Poissonness plot") +plotness(pois_counts, + type = "poisson", + conf_int = FALSE, label = FALSE, + title = "Stripped-down Poissonness plot" +) ``` ## Reference